Compare commits

...
23 Commits
Author SHA1 Message Date
shenlei d72959df70 fix: 输入校验忽略多个连续空格与大小写;统一中英文引号与空白规范化 2026-09-16 17:55:08 +09:00
shenleiandClaude Opus 5 4d324903b4 fix: AI 复习变式请求字段与校验一致;转写后删除不保留的录音
- generateReviewVariant 的指令要求 stimulus/answer/acceptedAnswers 等字段,
  而 decodeGeneratedReviewVariant 只接受 schemaVersion、variantId、
  targetItemId、prompt、expectedAnswer 五个字段,导致 AI 变式始终校验失败。
  现在指令只要求这五个字段,并补充了请求与解码一致性的测试。
- finishVoiceInput(keepAudio: false) 转写完成(或页面已关闭)后删除音频,
  补练页和测评页的语音输入不再在本机残留录音文件。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:29:56 +09:00
shenleiandClaude Opus 5 29b1738162 refactor: 拆分对话页 build,抽出消息气泡组件
- _TurnBubble:单条对话气泡(翻译、播放/翻译/句型解析操作)
- _TurnAction:气泡下方的图标文字按钮,替代三段重复布局
- 提示按钮逻辑移到 _showHint()

_DialoguePageState.build 从约 320 行降到约 170 行,无用户可见变化。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:24:29 +09:00
shenleiandClaude Opus 5 f79ba6327e refactor: 将课程各步骤拆分到 features/lesson/steps/
lesson_flow.dart 保留流程状态、公共脚手架与小组件,预习/听/说/读/写/独立表达
六个步骤各自成为 part 文件。代码仅搬移并经 dart format 格式化,无用户可见变化。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:23:26 +09:00
shenleiandClaude Opus 5 9b0f9d64cb refactor: 按领域将 AppState 拆分为 mixin
- app_state.dart 只保留持久化字段(_AppStateData)、加载/存档、设置项和清空进度
- app_state_review.dart:复习队列与掌握度
- app_state_lesson.dart:课程解锁、分段与课内步骤流程
- app_state_assessment.dart:阶段测评
- app_state_ai_content.dart:临时释义、句子分析缓存与补练课
- 后台同步调用统一为 _syncInBackground()

纯搬移,方法体未改,无用户可见变化。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:22:19 +09:00
shenleiandClaude Opus 5 6c0aeec628 refactor: 抽出语音作答与录音回听的公共逻辑
对话页、AI 补练页、课程跟读步骤、独立表达步骤和评估页各自复制了一套
"录音 → AI 转写 → 填入答案"的流程,其中 3 页还复制了"录音回听/播放/删除"
的按钮和方法。现在统一到 lib/widgets/voice_answer.dart:
- VoiceAnswerMixin:startVoiceInput / finishVoiceInput / toggleRecording /
  playRecording / deleteRecording / disposeVoiceAnswer,状态字段名沿用各页原名,
  页面的 build 代码基本不动;
- RecordingControls:录音回听按钮行。

用户可见的变化:
- "未识别到语音"提示统一为「未识别到清晰语音,请再试一次或直接输入文字。」
  (对话页、补练页、独立表达步骤、评估页原本各有不同说法;跟读步骤保留
  原来提示"播放示范音"的文案)。麦克风不可用、开始录音的提示保持各页原样。
- 对话页的录音回听按钮与播放按钮之间增加 8px 间距,与其他页面一致。
- 删除录音时先停止正在播放的录音(原来只有跟读步骤这样做)。
- 离开页面时,如果还在录音或设备识别中,会先停止;不保留录音时一并删除
  这段录音文件。原来课程两个步骤离开时不停止录音,各页离开时也不停止
  "录音回听"的录音。

flutter analyze 无问题,flutter test 142 个测试通过;现有测试未覆盖麦克风流程。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:18:45 +09:00
shenleiandClaude Opus 5 70f7be9b88 refactor: 合并 AiService 各文本接口重复的请求流程
8 个文本接口原本各自重复一套:取 API Key、校验模型与地址、按服务商拼请求头和请求体、
超时、判断 2xx、提取回复内容。现在统一到 _requestContent / _requestPrompt,
底层发送逻辑为 _postJson,请求体由 _buildTextPayload 构造(testConnection 和
transcribeAudio 也改用它们)。各接口只保留提示词和结果解析。

纯重构,不改变行为。已用临时测试录下 3 种服务商、4 种响应(成功、500、401、
网络异常)以及配置无效时的全部请求:共 134 个请求的地址、请求头、请求体和
所有返回值,重构前后逐字节一致。ai_service.dart 从 1291 行减为 1051 行。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:14:32 +09:00
shenleiandClaude Opus 5 a69d221ab6 refactor: 将 AppState 本地存档序列化拆分到 app_state_snapshot.dart
纯代码搬移,不改行为:_restore、快照 JSON 构建及相关解析函数移入 part 文件。
已用覆盖全部存档字段的快照比对验证,重构前后写出的存档逐字节一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:07:23 +09:00
shenleiandClaude Opus 5 2795987c3a fix: 登录同步后首页恢复课程位置与复习卡片
- 拉取合并时还原当前课程与分段位置,位置只前进不后退,跳过其他设备已完成的课
- 由云端掌握项重建复习卡片,并纠正旧客户端写坏的首次复习到期时间
- 同 checkpoint 时以证据更多的一方为准;推送缺卡片项时不再用当前时间作到期时间
- 登录/注册及合并逻辑升级后强制全量拉取一次

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:05:15 +09:00
shenleiandClaude Opus 5 c2361c5c3a chore: 联网测试端点变量接受完整地址
应用里配置的地址本身就带 /v1/responses,直接当基址拼接会变成
.../v1/responses/v1/responses。改用 KOUYU_AI_ENDPOINT,填完整端点或
基址都可以,内部归一化后再拼出待测的两个端点,并补一条不联网的归一化测试。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:42:58 +09:00
shenleiandClaude Opus 5 3124bee288 chore: 联网测试密钥改从环境变量读取
live_endpoint_test.dart 里硬编码了一个真实 API key。改为读取
KOUYU_AI_API_KEY(可选 KOUYU_AI_BASE_URL、KOUYU_AI_MODEL),未设置时
整组测试跳过,不再把缺少凭据的联网测试当成通过。

注意:旧 key 仍留在提交历史中,需要在服务端作废并重新签发。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:40:54 +09:00
shenleiandClaude Opus 5 7e29c5449f feat: 重做阅读找答案题与 AI 情境对话,并归拢本地识别、查词等既有改动
阅读"在对话里找到答案":
- 16 道题全部重写,干扰项真实出现在对话里,靠说话人归属或否定句才能作答
- 选项按题目内容确定性打乱,答案不再固定排第一;听力环节同样处理
- 去掉超纲干扰项、重复题干,收紧自由作答匹配(过去单个字母也能判对)

AI 情境对话:
- 提示词区分"AI 这一句要做什么"与"学习者随后要完成什么",并下发已教词句清单
- JSON 只强制 reply,translation/feedback 可选;不再索要用不上的 slots/evidence
- AI 不可用时页面明确提示当前回复来自内置示范脚本
- 删掉按 stage 下标猜中文翻译的兜底,避免译文与英文对不上
- 整课对话改用逐轮必需表达校验,替换"关键词沾边就算过";修正自由场景正则误伤
- 总结的"完成任务"按实际通过的轮次生成;模型点评只在结束页呈现一次
- 自由场景支持草稿续练(独立存储槽);修正回答轮数文案与永不解锁的场景标注

同时提交此前工作区中累积的改动:SenseVoice 本地识别、查词/句型解析卡、
复习与测评页调整等,并补充对话校验、选项分布和句子解析的测试。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:38:23 +09:00
shenlei ef2fce207a feat: install_app.sh 脚本支持打包安装 macOS 桌面版 2026-09-16 10:36:57 +09:00
shenandshen 14f934aa45 feat: 完善芽说英语品牌Logo与图标配置,完成跨平台云端同步服务开发与自动化部署 2026-09-16 09:15:53 +08:00
shenandshen 11929d6a03 feat: save repeat audio on mic record and rename button to play repeat 2026-09-15 22:52:49 +08:00
shenandshen 2a2668829d feat: upgrade local speech recognition engine to SenseVoice-Small with hardware audio enhancements 2026-09-15 22:36:10 +08:00
shenandshen 9215931b13 feat: integrate Sherpa-ONNX local offline speech recognition engine 2026-09-15 22:22:32 +08:00
shenandshen 82b1ecdf84 feat: set default reasoning effort to low for all AI requests 2026-09-15 22:06:52 +08:00
shenandshen 35b493e652 feat: add AI voice transcription fallback for domestic Android ROMs 2026-09-15 22:02:26 +08:00
shenandshen 670260f8e2 config: 将默认AI配置端点切换为 /v1/responses 2026-09-15 21:33:18 +08:00
shenandshen 4f22e974af fix: 支持明文网络请求,增强安装脚本自动卸载冲突签名,优化设置页AI配置响应 2026-09-15 21:30:56 +08:00
shenandshen 6fcffc968f feat: 原生支持 /v1/responses 接口与 Chat Completions 双协议自适应,增加端到端联调测试 2026-09-15 20:44:27 +08:00
shenlei 4341726162 feat: 增加AI配置文件与TTS自动朗读,修复二级页面返回按钮与音频播放 2026-09-15 19:11:39 +09:00
107 changed files with 36704 additions and 3633 deletions
+13
View File
@@ -94,3 +94,16 @@ app.*.map.json
.env
.env.*
*.local
# ==============================================================================
# 7. Python 服务端构建与运行产物
# ==============================================================================
__pycache__/
*.py[cod]
*$py.class
venv/
.venv/
data/
*.db
*.sqlite
*.sqlite3
+35 -37
View File
@@ -16,6 +16,11 @@
- 本文“完成判定”是教学任务完成,不是词句掌握或阶段通过。带提示/文字可完成课程,但不算独立听说证据。出口门槛见 [A0 阶段标准](A0-STAGE-STANDARD.md),内容契约见 [开发与验收约定](M1-IMPLEMENTATION-CONTRACT.md)。
- 每段最多 8 个新增词汇项、2 个主动产出句型和 2 个理解用词块;同句型的槽位替换、缩写不重复计数。下面列的是主题总内容,不能一次全部塞入一个短课。
- 教学可看字幕/中文;独立听力先隐藏文字,查看后标记辅助。课内阅读题保留英文;正式写作评估不用课内填空替代。
- 听力题和阅读题都给三个选项,正确答案在界面上随机但稳定地排序,不能固定在第一项。阅读题必须靠对话内容才能回答:
只看说话人姓名标签或只凭常识就能猜中的题(如“谁叫 Mia?”)不合格,应改问对话里说过的句子、数字或信息。
- 阅读题至少要有两个选项在对话里真出现过,让用户必须靠“谁说的、有没有被否定”来区分;
如果只有正确答案出现在对话中,用户不读题也能挑出唯一见过的那个。干扰项一律使用已教词句,
未教过的英文(如 Goodbye./teacher/louder)不得作为选项。选项之间不能互为子串,否则判分会把两个选项当成同一个答案。
- 课程中可点英文单词或完整词块查词,优先显示当前短语的中文意思、示范音和本课例句;用户可加入复习。正式独立练习和出口评估隐藏查词入口,若需查词则改为教学练习并换题补测。
### 准备词块与分段(先教后用)
@@ -90,12 +95,12 @@
2. **词句跟读**:依次跟读 `Hello.``Im Shen.``Nice to meet you.`。每句可慢放和重录。
3. **阅读**
```text
Mia: Hello. Im Mia.
Mia: Hello. Im Mia. Whats your name?
Shen: Hi. Im Shen.
Mia: Nice to meet you.
Shen: Nice to meet you, too.
```
题目:谁叫 Mia?正确答案:第一位说话的人。
题目:Shen 说的最后一句是什么?选项为“Nice to meet you, too. / Whats your name? / Hi. Im Shen.”,正确答案:Nice to meet you, too.(三个选项都在对话里,必须分清谁说、说在哪一轮。)
4. **写一写**:填空 `Hello. Im _____.`;用户填写自己的名字。
5. **AI 对话**AI`Hi! Im Mia. Whats your name?`;用户说姓名。AI`Nice to meet you, [name].`;用户回应 `Nice to meet you, too.`
@@ -131,12 +136,12 @@
3. **跟读**:读 `S-H-E-N`,听示范并回听录音;然后拼读自己的所选姓名。识别不确定时先确认,不宣称已定位最不清晰的字母。
4. **阅读**
```text
Mia: Whats your name?
Shen: Shen.
Mia: How do you spell that?
Shen: S-H-E-N.
Mia: Im Mia. M-I-A.
Shen: Hi, Mia. Im Shen. S-H-E-N.
Mia: How do you spell Sam?
Shen: S-A-M.
```
排序题:把 `S / H / E / N` 排成正确顺序。
题目:Shen 自己的名字怎么拼写?选项为“S-H-E-N / M-I-A / S-A-M”,正确答案:S-H-E-N。三个拼写都在对话里,必须区分是谁的名字。另可加排序题:把 `S / H / E / N` 排成正确顺序。
5. **写一写**`My name is _____.` 填姓名,按实际姓名长度生成字母格,不固定三个字母。
6. **AI 对话**AI`Whats your name?` → 用户回答;AI`How do you spell that?` → 用户拼读;AI 用 `Thank you.` 收尾。
@@ -177,9 +182,9 @@
```text
Mia: Hi, Shen. How are you?
Shen: Im good, thanks. How are you?
Mia: Im okay.
Mia: Im tired today.
```
题目:Mia 状态如何?答案:okay。
题目:Mia 说自己今天怎么样?选项为“I’m tired today. / Im good, thanks. / Im okay.”,正确答案:Im tired today.(前两个选项都在对话里,必须分清是 Mia 还是 Shen 说的。)
5. **写一写**:选择一个真实状态完成 `Im ____ today.`
6. **AI 对话**AI`Hi, [name]. How are you today?`;用户回答并反问。AI 用一种状态回答。
@@ -217,10 +222,11 @@
4. **阅读**
```text
Mia: Whats your phone number?
Shen: One-three-eight.
Mia: Thank you.
Shen: My number is one-three-eight.
Mia: One-eight-three?
Shen: No. One-three-eight.
```
题目:Shen 的号码是?答案:138。
题目:Shen 的号码到底是哪一个?选项为“138 / 183 / 318”,正确答案:138。(138 和 183 都在对话里出现,必须读懂 Mia 复述错了、Shen 纠正了。C 段另用 502/520/205 一组,不与本题重复。)
5. **写一写**:将 `139` 写成英文数字(`one-three-nine`)。
6. **AI 对话**AI`Whats your phone number?`;用户用 3 位虚拟号码回答。AI 复述:`One-three-eight?`;用户说 `Yes.`
@@ -259,12 +265,10 @@
3. **跟读**`Whats this?`、`Its a pen.`、`Its my bag.`
4. **阅读**
```text
Mia: Whats this?
Shen: Its a book.
Mia: Is it your book?
Shen: Yes.
Mia: Whats this? Is it a pen?
Shen: No. Its a book.
```
题目:这是谁的书?答案:Shen 的。
题目:Shen 说那件东西是什么?选项为“A book / A pen / A phone”,正确答案:A book。(pen 也在对话里,必须读到被否定。)
5. **写一写**:先看手机图填 `It's my _____.`;再排序 `It's / a / pen`;收起句框和词库,换已教物品图片,独立写一句。笔图参考 `It's a pen.`,书图参考 `It's a book.`;需要帮助可恢复,但标记辅助完成。
6. **AI 对话**:AI 描述并出示物品图:`Whats this?`;用户回答。连续 3 个物品后结束。
@@ -302,11 +306,10 @@
3. **跟读**`Where are you from?` 和两个回答句。
4. **阅读**
```text
Mia: Where are you from?
Mia: Im from London. Where are you from?
Shen: Im from Hong Kong.
Mia: Nice!
```
题目:Shen 来自哪里?答案:Hong Kong。
题目:Shen 来自哪里?选项为“Hong Kong / London / Beijing”,正确答案: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。
@@ -345,12 +348,10 @@
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.
Mia: Who is this? Is this your father?
Shen: No. This is my mother.
```
题目:照片里的人是谁?答案:Shen 的父亲。
题目:Shen 介绍的是哪一位家人?选项为“My mother / My father / My sister”,正确答案:My mother。(father 也在对话里,必须读到被否定;干扰项只用已教的家庭成员词。)
5. **写一写**:先选词填 `This is my _____.`,再收起句框和词库,换一张已教家庭成员图独立写完整句。父亲图参考 `This is my father.`;允许虚构家庭。
6. **AI 对话**AI`Who is this?`(显示家庭成员图);用户回答。AI 追问 `Is this your mother?`;用户答 `Yes.` 或 `No.`
@@ -395,12 +396,10 @@
3. **跟读**:三句核心句,重点练 Wednesday。
4. **阅读**
```text
Mia: What day is it today?
Shen: Its Monday.
Mia: What time is it?
Shen: Its three oclock.
Mia: What day is it today? Is it Tuesday?
Shen: No. Its Monday.
```
题目:现在几点?答案:three oclock。
题目:对话中今天到底是星期几?选项为“Monday / Tuesday / Sunday”,正确答案:Monday。A/B/C 三段各用不同题干与不同星期/整点(A:Monday 猜错→TuesdayBSaturday 猜错→SundayCtwo oclock 猜错→three oclock),避免三段撞题
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 整点钟面。未教过的星期和数字不计错。
@@ -441,10 +440,9 @@
4. **阅读**
```text
Mia: I like tea. Do you like tea?
Shen: Yes, I do.
Mia: Great!
Shen: No, I dont. I like coffee.
```
题目:Shen 喜欢茶吗?答案:喜欢。
题目:Shen 喜欢喝什么?选项为“Coffee / Tea / Music”,正确答案:Coffee。(tea 也在对话里,必须读懂 `No, I dont.` 这个短答。)
5. **写一写**:先选词填 `I like _____.`,再收起句框和词库,只给中文目标“写一句你自己的喜好”。参考 `I like music.`,也接受其他已教对象,不要求照抄例句。
6. **AI 对话**AI`I like music. Do you like music?`;用户回答。AI 随后问另一项喜好,用户反问一次。
@@ -479,11 +477,11 @@
Alex: Hi! Im Alex. Whats your name?
Shen: Hi! Im Shen. Nice to meet you.
Alex: Nice to meet you, too. Where are you from?
Shen: Im from Hong Kong. How are you?
Alex: Im good, thanks. Do you like coffee?
Shen: Yes, I do.
Shen: Im from Hong Kong. Where are you from?
Alex: Im from London. Do you like coffee?
Shen: No, I dont. I like tea.
```
题目:Shen 来自哪里Shen 喜欢什么?
题目:Shen 来自哪里喜欢什么?选项为“Hong Kong, tea / London, coffee / Hong Kong, coffee”,正确答案:Hong Kong, tea。(两个地点、两种饮料都在对话里,必须同时读对来源和喜好。)
2. **排序**:将五张卡按自然对话顺序排列:问候 → 姓名 → 来自哪里 → 状态 → 喜好。
3. **关键句跟读**:用户任选 3 句重说;系统给“清楚/再慢一点/可重说”的支持性提示。
4. **写一写**:先填小抄 `I'm _____. I'm from _____. I like _____.` 作准备;完成另一项练习后收起小抄,仅按中文目标独立写姓名、地点和喜好三句,不提供句框或词库。原样立即抄写仅算练习;再用不同物品图片/钟面补一项完整句练习,可留到次日预算内完成。
+6
View File
@@ -108,6 +108,10 @@ M1 先提供与 A0 核心课对应的受控对话;这些场景是起始内容
AI 必须记住当前场景、用户身份、完成目标、已知信息及难度上限。它不能突然转换角色、重复问已回答的问题,或为了“自然聊天”而跳过任务目标。
给模型的指令必须把两件事分开:**本轮 AI 自己要说什么**,以及**学习者随后要完成什么任务**。两者混成一句会让模型替学习者把答案说出来。同时把当前课程及之前课程已教的词句作为可用语言清单随请求下发,A0 每轮最多 1 个清单外的词。
模型只需返回 `reply`(英文回复);`translation`(中文翻译)与 `feedback`(一句中文点评)可选,缺少可选字段不丢弃整条回复。是否完成任务由客户端按本轮必需表达判定,不采信模型的完成声明。AI 不可用时必须在对话页显示提示,说明当前这句来自内置示范脚本,不能让备用文案冒充 AI 回复。
## 8. 对话交互细节
### 8.1 输入与输出
@@ -135,6 +139,8 @@ AI 必须记住当前场景、用户身份、完成目标、已知信息及难
对话结束后才给完整的微反馈,且一次最多一个语言问题。零基础用户首先需要成功表达,不需要一次性接受语法课。
模型每轮返回的 `feedback` 只保留最后一条,在对话结束页和总结页以“下一次说得更好”呈现,不在对话过程中逐轮弹出。
M1 发音反馈限示范音、慢放、录音回听和可选通用练习,不评价像不像母语者。`three` 多次被转写为 `free` 可能是噪声或 ASR 错误,不能据此诊断 /θ/ 或降低掌握度;可在用户选择后展示通用对比词与动作提示,并标注“非个性化发音诊断”。真正的音素诊断需要音频证据和验证过的能力,后续另行评估。
## 9. 会话总结与复习
@@ -12,6 +12,10 @@ android {
// toolchain satisfies all plugin requirements for reproducible builds.
ndkVersion = "28.2.13676358"
androidResources {
noCompress += listOf("onnx")
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
@@ -2,7 +2,8 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:label="kouyu_english"
android:usesCleartextTraffic="true"
android:label="芽说英语"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
@@ -33,12 +34,13 @@
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
<!-- Required to query activities that can process text, speech recognition, and TTS engines on Android 11+:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT. -->
<queries>
<intent>
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 544 B

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 B

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 721 B

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

@@ -0,0 +1,8 @@
{
"provider": "compatible",
"endpoint": "https://kmwq8ckvr0ehsgqyudcer1.slcydia.fun/v1/responses",
"model": "gemini-3.7-flash-high",
"reasoningEffort": "low",
"apiKey": "sk-gZKpQQ5ybcL6WFersPKMiDDEZFxjC8xCASHzc08SNFTwa3LwRr8SaNNuvPzal5Tg",
"description": "默认 AI 对话服务配置。provider 可选: compatible (OpenAI 兼容/CLIProxyAPI/OneAPI), openAi, gemini, mock"
}
File diff suppressed because it is too large Load Diff
+325
View File
@@ -0,0 +1,325 @@
import os
import math
from PIL import Image, ImageDraw, ImageFont, ImageFilter
def create_super_sampled_icon(size=1024, supersample=4):
ss_size = size * supersample
img = Image.new("RGBA", (ss_size, ss_size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
# 1. Background Rounded Squircle with rich forest-emerald gradient
# Gradient from top-left (#1F8A5B) to bottom-right (#0D452B)
radius = int(ss_size * 0.22)
# Create mask for squircle
mask = Image.new("L", (ss_size, ss_size), 0)
mask_draw = ImageDraw.Draw(mask)
margin = int(ss_size * 0.04)
mask_draw.rounded_rectangle(
[margin, margin, ss_size - margin, ss_size - margin],
radius=radius,
fill=255
)
# Base gradient image
gradient = Image.new("RGBA", (ss_size, ss_size), (0, 0, 0, 0))
c_tl = (38, 155, 102) # #269B66
c_br = (14, 66, 42) # #0E422A
for y in range(ss_size):
for x in range(0, ss_size, 4):
t = (x + y) / (ss_size * 2)
r = int(c_tl[0] * (1 - t) + c_br[0] * t)
g = int(c_tl[1] * (1 - t) + c_br[1] * t)
b = int(c_tl[2] * (1 - t) + c_br[2] * t)
# fill horizontal run
for dx in range(4):
if x + dx < ss_size:
gradient.putpixel((x + dx, y), (r, g, b, 255))
# Composite gradient with squircle mask
bg = Image.composite(gradient, Image.new("RGBA", (ss_size, ss_size), (0, 0, 0, 0)), mask)
# Inner soft glow ring
inner_glow = Image.new("RGBA", (ss_size, ss_size), (0, 0, 0, 0))
glow_draw = ImageDraw.Draw(inner_glow)
glow_draw.rounded_rectangle(
[margin + 6 * supersample, margin + 6 * supersample, ss_size - margin - 6 * supersample, ss_size - margin - 6 * supersample],
radius=radius - 6 * supersample,
outline=(255, 255, 255, 45),
width=int(3 * supersample)
)
bg = Image.alpha_composite(bg, inner_glow)
# 2. Main Speech Bubble + Sprout Motif
# Center coordinates
cx, cy = ss_size // 2, ss_size // 2
# A subtle companion speech bubble in the background (Mia AI partner)
comp_bubble = Image.new("RGBA", (ss_size, ss_size), (0, 0, 0, 0))
comp_draw = ImageDraw.Draw(comp_bubble)
comp_rect = [
int(cx + 40 * supersample),
int(cy - 280 * supersample),
int(cx + 340 * supersample),
int(cy - 20 * supersample)
]
comp_draw.rounded_rectangle(
comp_rect,
radius=int(60 * supersample),
fill=(226, 243, 232, 180) # AppColors.softGreen with opacity
)
# Companion bubble tail
comp_tail = [
(int(cx + 300 * supersample), int(cy - 40 * supersample)),
(int(cx + 360 * supersample), int(cy + 40 * supersample)),
(int(cx + 250 * supersample), int(cy - 20 * supersample)),
]
comp_draw.polygon(comp_tail, fill=(226, 243, 232, 180))
# Sound wave dots in companion bubble
dot_color = (23, 107, 70, 220)
for i in range(3):
dx_dot = int(cx + (140 + i * 55) * supersample)
dy_dot = int(cy - 150 * supersample)
r_dot = int((10 + (1 if i==1 else 0)*4) * supersample)
comp_draw.ellipse([dx_dot - r_dot, dy_dot - r_dot, dx_dot + r_dot, dy_dot + r_dot], fill=dot_color)
# Composite companion bubble
bg = Image.alpha_composite(bg, comp_bubble)
# 3. Primary Speech Bubble (Clean Crisp White with subtle drop shadow)
primary_shadow = Image.new("RGBA", (ss_size, ss_size), (0, 0, 0, 0))
sh_draw = ImageDraw.Draw(primary_shadow)
b_left = int(cx - 320 * supersample)
b_top = int(cy - 180 * supersample)
b_right = int(cx + 180 * supersample)
b_bottom = int(cy + 260 * supersample)
b_radius = int(100 * supersample)
sh_draw.rounded_rectangle(
[b_left, b_top + 16 * supersample, b_right, b_bottom + 16 * supersample],
radius=b_radius,
fill=(0, 0, 0, 70)
)
# Primary tail shadow
p_tail_sh = [
(int(b_left + 80 * supersample), int(b_bottom + 10 * supersample)),
(int(b_left - 30 * supersample), int(b_bottom + 120 * supersample)),
(int(b_left + 190 * supersample), int(b_bottom + 10 * supersample)),
]
sh_draw.polygon(p_tail_sh, fill=(0, 0, 0, 70))
primary_shadow = primary_shadow.filter(ImageFilter.GaussianBlur(radius=int(16 * supersample)))
bg = Image.alpha_composite(bg, primary_shadow)
# Draw actual Primary Bubble
primary_bubble = Image.new("RGBA", (ss_size, ss_size), (0, 0, 0, 0))
pb_draw = ImageDraw.Draw(primary_bubble)
pb_draw.rounded_rectangle(
[b_left, b_top, b_right, b_bottom],
radius=b_radius,
fill=(255, 255, 255, 255)
)
p_tail = [
(int(b_left + 80 * supersample), int(b_bottom - 10 * supersample)),
(int(b_left - 30 * supersample), int(b_bottom + 95 * supersample)),
(int(b_left + 190 * supersample), int(b_bottom - 10 * supersample)),
]
pb_draw.polygon(p_tail, fill=(255, 255, 255, 255))
bg = Image.alpha_composite(bg, primary_bubble)
# 4. Sprout & Dynamic Voice Wave Graphic inside Primary Bubble
# Center of primary bubble
pcx = (b_left + b_right) // 2
pcy = (b_top + b_bottom) // 2 - int(10 * supersample)
icon_art = Image.new("RGBA", (ss_size, ss_size), (0, 0, 0, 0))
art_draw = ImageDraw.Draw(icon_art)
# Sprout Stem & Leaves (Vibrant Emerald & Spring Green)
# Main Leaf (Right): curving upwards with life
leaf_r_points = []
steps = 40
# Center anchor of stem: (pcx - 30, pcy + 110)
stem_x, stem_y = pcx - int(20 * supersample), pcy + int(110 * supersample)
# Left leaf
left_leaf = [
(stem_x, stem_y - int(30 * supersample)),
(stem_x - int(120 * supersample), stem_y - int(60 * supersample)),
(stem_x - int(150 * supersample), stem_y - int(150 * supersample)),
(stem_x - int(60 * supersample), stem_y - int(140 * supersample)),
(stem_x, stem_y - int(70 * supersample))
]
# Draw smooth left leaf
art_draw.polygon(left_leaf, fill=(43, 174, 107, 255))
# Right bigger primary leaf
right_leaf = [
(stem_x + int(10 * supersample), stem_y - int(40 * supersample)),
(stem_x + int(70 * supersample), stem_y - int(60 * supersample)),
(stem_x + int(160 * supersample), stem_y - int(170 * supersample)),
(stem_x + int(130 * supersample), stem_y - int(220 * supersample)),
(stem_x + int(40 * supersample), stem_y - int(190 * supersample)),
(stem_x - int(10 * supersample), stem_y - int(100 * supersample))
]
art_draw.polygon(right_leaf, fill=(23, 107, 70, 255))
# Sprout Dewdrop / Energy Spark (Warm Sun Gold #F59E0B)
spark_x = stem_x + int(145 * supersample)
spark_y = stem_y - int(235 * supersample)
spark_r = int(22 * supersample)
art_draw.ellipse(
[spark_x - spark_r, spark_y - spark_r, spark_x + spark_r, spark_y + spark_r],
fill=(245, 158, 11, 255)
)
# 3 Arched Voice Waves radiating from the sprout (representing speaking & pronunciation)
wave_color = (23, 107, 70, 220)
# Wave 1 (inner)
w1_box = [
int(pcx - 180 * supersample),
int(pcy - 160 * supersample),
int(pcx - 20 * supersample),
int(pcy + 0 * supersample)
]
art_draw.arc(w1_box, start=140, end=270, fill=wave_color, width=int(14 * supersample))
# Wave 2 (middle)
w2_box = [
int(pcx - 240 * supersample),
int(pcy - 210 * supersample),
int(pcx - 10 * supersample),
int(pcy + 30 * supersample)
]
art_draw.arc(w2_box, start=145, end=265, fill=(43, 174, 107, 200), width=int(14 * supersample))
# Wave 3 (outer)
w3_box = [
int(pcx - 295 * supersample),
int(pcy - 260 * supersample),
int(pcx - 0 * supersample),
int(pcy + 60 * supersample)
]
art_draw.arc(w3_box, start=150, end=260, fill=(245, 158, 11, 230), width=int(13 * supersample))
# Composite artwork
bg = Image.alpha_composite(bg, icon_art)
# Downsample using high quality Lanczos filter for razor-sharp antialiasing
final_icon = bg.resize((size, size), Image.Resampling.LANCZOS)
return final_icon
def create_brand_banner(icon_img):
w, h = 1200, 600
banner = Image.new("RGBA", (w, h), (245, 247, 243, 255)) # AppColors.paper #F5F7F3
draw = ImageDraw.Draw(banner)
# Background subtle decorative circles
dec_draw = ImageDraw.Draw(banner)
dec_draw.ellipse([800, -100, 1400, 500], fill=(226, 243, 232, 120))
dec_draw.ellipse([-100, 300, 400, 800], fill=(255, 240, 227, 120))
# Paste resized icon on the left
icon_w = 340
icon_resized = icon_img.resize((icon_w, icon_w), Image.Resampling.LANCZOS)
banner.paste(icon_resized, (100, (h - icon_w) // 2), icon_resized)
# Typography on the right
# Try finding available fonts or use default with clear layout
font_path_candidates = [
"/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
"/System/Library/Fonts/Supplemental/Arial.ttf"
]
title_font = None
en_font = None
sub_font = None
badge_font = None
for p in font_path_candidates:
if os.path.exists(p):
try:
title_font = ImageFont.truetype(p, 58, index=0)
en_font = ImageFont.truetype(p, 30, index=0)
sub_font = ImageFont.truetype(p, 24, index=0)
badge_font = ImageFont.truetype(p, 18, index=0)
break
except Exception:
continue
if not title_font:
title_font = ImageFont.load_default()
en_font = ImageFont.load_default()
sub_font = ImageFont.load_default()
badge_font = ImageFont.load_default()
tx = 490
ty = 135
# Tag / Pill: "AI 伴学 · 零基础开口"
pill_w = 210
pill_h = 36
draw.rounded_rectangle([tx, ty, tx + pill_w, ty + pill_h], radius=18, fill=(226, 243, 232, 255))
draw.text((tx + 18, ty + 7), "🌱 AI 伴学 · 轻松开口", fill=(23, 107, 70, 255), font=badge_font)
# Main Brand Name
draw.text((tx, ty + 50), "芽说英语", fill=(25, 33, 27, 255), font=title_font)
# English Name
draw.text((tx + 270, ty + 72), "SpeakSprout", fill=(23, 107, 70, 255), font=en_font)
# Slogan / Value Proposition
draw.text((tx, ty + 145), "每一次开口,都是成长的萌芽", fill=(100, 114, 104, 255), font=sub_font)
draw.text((tx, ty + 190), "• 真实场景 1v1 AI 语伴 Mia", fill=(25, 33, 27, 230), font=sub_font)
draw.text((tx, ty + 235), "• 听 / 说 / 读 / 写 四维科学进阶", fill=(25, 33, 27, 230), font=sub_font)
draw.text((tx, ty + 280), "• 本地离线高精语音识别 · 极速跟读", fill=(25, 33, 27, 230), font=sub_font)
return banner
if __name__ == "__main__":
out_dir = "assets/branding"
os.makedirs(out_dir, exist_ok=True)
print("🎨 正在生成超采样高清 Logo (1024x1024)...")
icon1024 = create_super_sampled_icon(size=1024, supersample=4)
master_path = os.path.join(out_dir, "logo_master_1024.png")
icon1024.save(master_path, "PNG")
print(f"✅ 保存主图标: {master_path}")
# 512x512
icon512 = icon1024.resize((512, 512), Image.Resampling.LANCZOS)
p512 = os.path.join(out_dir, "logo_512.png")
icon512.save(p512, "PNG")
# Brand Banner
print("🖼️ 正在生成品牌展示图 (1200x600)...")
banner = create_brand_banner(icon1024)
banner_path = os.path.join(out_dir, "brand_banner.png")
banner.save(banner_path, "PNG")
print(f"✅ 保存品牌展示图: {banner_path}")
# Android launcher icons
mipmaps = {
"mipmap-mdpi": 48,
"mipmap-hdpi": 72,
"mipmap-xhdpi": 96,
"mipmap-xxhdpi": 144,
"mipmap-xxxhdpi": 192,
}
print("📱 正在更新 Android App 各分辨率图标...")
for folder, sz in mipmaps.items():
dir_path = os.path.join("android/app/src/main/res", folder)
os.makedirs(dir_path, exist_ok=True)
out_f = os.path.join(dir_path, "ic_launcher.png")
resized = icon1024.resize((sz, sz), Image.Resampling.LANCZOS)
resized.save(out_f, "PNG")
print(f" -> {out_f} ({sz}x{sz})")
print("🎉 全部 Logo 和图标生成完成!")
+308
View File
@@ -0,0 +1,308 @@
import os
import math
from PIL import Image, ImageDraw, ImageFont, ImageFilter
def cubic_bezier(p0, p1, p2, p3, steps=60):
points = []
for i in range(steps + 1):
t = i / float(steps)
x = (1-t)**3 * p0[0] + 3*(1-t)**2 * t * p1[0] + 3*(1-t) * t**2 * p2[0] + t**3 * p3[0]
y = (1-t)**3 * p0[1] + 3*(1-t)**2 * t * p1[1] + 3*(1-t) * t**2 * p2[1] + t**3 * p3[1]
points.append((x, y))
return points
def create_sprout_icon(size=1024, supersample=4, transparent_bg=False):
ss = size * supersample
img = Image.new("RGBA", (ss, ss), (0, 0, 0, 0))
radius = int(ss * 0.225)
margin = int(ss * 0.045)
mask = Image.new("L", (ss, ss), 0)
mask_draw = ImageDraw.Draw(mask)
mask_draw.rounded_rectangle(
[margin, margin, ss - margin, ss - margin],
radius=radius,
fill=255
)
gradient = Image.new("RGBA", (ss, ss), (0, 0, 0, 0))
# Colors: top-left fresh emerald #198754, bottom-right deep forest #0D4E30
c_tl = (28, 148, 92)
c_br = (12, 68, 42)
for y in range(ss):
for x in range(0, ss, 4):
t = (x * 0.7 + y * 1.0) / (ss * 1.7)
t = max(0.0, min(1.0, t))
r = int(c_tl[0] * (1 - t) + c_br[0] * t)
g = int(c_tl[1] * (1 - t) + c_br[1] * t)
b = int(c_tl[2] * (1 - t) + c_br[2] * t)
for dx in range(4):
if x + dx < ss:
gradient.putpixel((x + dx, y), (r, g, b, 255))
bg = Image.composite(gradient, Image.new("RGBA", (ss, ss), (0, 0, 0, 0)), mask)
# Ambient subtle highlight on top edge
highlight = Image.new("RGBA", (ss, ss), (0, 0, 0, 0))
hl_draw = ImageDraw.Draw(highlight)
hl_draw.rounded_rectangle(
[margin + 4*supersample, margin + 4*supersample, ss - margin - 4*supersample, ss - margin - 4*supersample],
radius=radius - 4*supersample,
outline=(255, 255, 255, 35),
width=int(2.5 * supersample)
)
bg = Image.alpha_composite(bg, highlight)
cx, cy = ss // 2, ss // 2
# 2. Main Center Hero Badge: Rounded Speech Bubble with clean geometry
bw = int(580 * supersample)
bh = int(480 * supersample)
bx = cx - bw // 2
by = cy - bh // 2 - int(25 * supersample)
br = int(120 * supersample)
# Bubble Drop Shadow
bubble_shadow = Image.new("RGBA", (ss, ss), (0, 0, 0, 0))
bsh_draw = ImageDraw.Draw(bubble_shadow)
bsh_draw.rounded_rectangle(
[bx, by + int(18 * supersample), bx + bw, by + bh + int(18 * supersample)],
radius=br,
fill=(0, 0, 0, 75)
)
tail_pts_sh = [
(bx + int(90 * supersample), by + bh + int(10 * supersample)),
(bx + int(30 * supersample), by + bh + int(105 * supersample)),
(bx + int(190 * supersample), by + bh + int(10 * supersample)),
]
bsh_draw.polygon(tail_pts_sh, fill=(0, 0, 0, 75))
bubble_shadow = bubble_shadow.filter(ImageFilter.GaussianBlur(radius=int(18 * supersample)))
bg = Image.alpha_composite(bg, bubble_shadow)
# Bubble Body (Pure White with subtle warm sheen)
bubble = Image.new("RGBA", (ss, ss), (0, 0, 0, 0))
b_draw = ImageDraw.Draw(bubble)
b_draw.rounded_rectangle(
[bx, by, bx + bw, by + bh],
radius=br,
fill=(255, 255, 255, 255)
)
tail_pts = [
(bx + int(90 * supersample), by + bh - int(8 * supersample)),
(bx + int(30 * supersample), by + bh + int(85 * supersample)),
(bx + int(190 * supersample), by + bh - int(8 * supersample)),
]
b_draw.polygon(tail_pts, fill=(255, 255, 255, 255))
bg = Image.alpha_composite(bg, bubble)
# 3. Inside the Bubble: Sprout + Smile Soundwave Iconography
icx = bx + bw // 2
icy = by + bh // 2 - int(10 * supersample)
sprout_layer = Image.new("RGBA", (ss, ss), (0, 0, 0, 0))
sp_draw = ImageDraw.Draw(sprout_layer)
# 3.1 Right Leaf (Primary, lush emerald)
p0 = (icx - int(10 * supersample), icy + int(35 * supersample))
p1 = (icx + int(70 * supersample), icy + int(15 * supersample))
p2 = (icx + int(165 * supersample), icy - int(50 * supersample))
p3 = (icx + int(135 * supersample), icy - int(130 * supersample)) # Tip
p4 = (icx + int(60 * supersample), icy - int(115 * supersample))
p5 = (icx + int(0 * supersample), icy - int(45 * supersample))
p6 = p0
curve_right_top = cubic_bezier(p0, p1, p2, p3, steps=40)
curve_right_bot = cubic_bezier(p3, p4, p5, p6, steps=40)
right_leaf_pts = curve_right_top + curve_right_bot
sp_draw.polygon(right_leaf_pts, fill=(23, 107, 70, 255)) # #176B46
# 3.2 Left Leaf (Secondary, fresh bright mint green)
lp0 = (icx - int(18 * supersample), icy + int(50 * supersample))
lp1 = (icx - int(65 * supersample), icy + int(25 * supersample))
lp2 = (icx - int(135 * supersample), icy - int(15 * supersample))
lp3 = (icx - int(120 * supersample), icy - int(80 * supersample)) # Tip
lp4 = (icx - int(60 * supersample), icy - int(65 * supersample))
lp5 = (icx - int(15 * supersample), icy - int(10 * supersample))
lp6 = lp0
curve_left_top = cubic_bezier(lp0, lp1, lp2, lp3, steps=40)
curve_left_bot = cubic_bezier(lp3, lp4, lp5, lp6, steps=40)
left_leaf_pts = curve_left_top + curve_left_bot
sp_draw.polygon(left_leaf_pts, fill=(52, 185, 118, 255)) # Bright fresh green
# 3.3 Sprout Sun Dot / Golden Energy Droplet (Golden Sun #F59E0B)
dot_x = icx + int(148 * supersample)
dot_y = icy - int(142 * supersample)
dot_r = int(22 * supersample)
sp_draw.ellipse(
[dot_x - dot_r, dot_y - dot_r, dot_x + dot_r, dot_y + dot_r],
fill=(245, 158, 11, 255)
)
# 3.4 Smile / Vocal Flow Arc
smile_y = icy + int(135 * supersample)
smile_w = int(140 * supersample)
smile_pts = cubic_bezier(
(icx - smile_w, smile_y - int(15 * supersample)),
(icx - smile_w // 2, smile_y + int(30 * supersample)),
(icx + smile_w // 2, smile_y + int(30 * supersample)),
(icx + smile_w, smile_y - int(15 * supersample)),
steps=50
)
for i in range(len(smile_pts) - 1):
sp_draw.line([smile_pts[i], smile_pts[i+1]], fill=(23, 107, 70, 230), width=int(14 * supersample))
s_cap_r = int(7 * supersample)
sp_draw.ellipse([smile_pts[0][0]-s_cap_r, smile_pts[0][1]-s_cap_r, smile_pts[0][0]+s_cap_r, smile_pts[0][1]+s_cap_r], fill=(23, 107, 70, 230))
sp_draw.ellipse([smile_pts[-1][0]-s_cap_r, smile_pts[-1][1]-s_cap_r, smile_pts[-1][0]+s_cap_r, smile_pts[-1][0]+s_cap_r], fill=(23, 107, 70, 230))
# 3.5 Concentric Dynamic Sound Waves
w_cx = icx - int(55 * supersample)
w_cy = icy - int(55 * supersample)
wave_r1 = int(115 * supersample)
sp_draw.arc(
[w_cx - wave_r1, w_cy - wave_r1, w_cx + wave_r1, w_cy + wave_r1],
start=155, end=245,
fill=(52, 185, 118, 240),
width=int(12 * supersample)
)
wave_r2 = int(165 * supersample)
sp_draw.arc(
[w_cx - wave_r2, w_cy - wave_r2, w_cx + wave_r2, w_cy + wave_r2],
start=160, end=240,
fill=(245, 158, 11, 230),
width=int(12 * supersample)
)
bg = Image.alpha_composite(bg, sprout_layer)
# Downsample
final_icon = bg.resize((size, size), Image.Resampling.LANCZOS)
return final_icon
def create_brand_banner(icon_img):
w, h = 1200, 600
banner = Image.new("RGBA", (w, h), (245, 247, 243, 255)) # AppColors.paper #F5F7F3
draw = ImageDraw.Draw(banner)
# Decorative background shapes
draw.ellipse([760, -120, 1360, 480], fill=(226, 243, 232, 140))
draw.ellipse([-80, 320, 420, 820], fill=(255, 240, 227, 130))
# Left Icon
icon_w = 340
icon_resized = icon_img.resize((icon_w, icon_w), Image.Resampling.LANCZOS)
banner.paste(icon_resized, (90, (h - icon_w) // 2), icon_resized)
font_path_candidates = [
"/System/Library/Fonts/PingFang.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
"/System/Library/Fonts/Supplemental/Arial.ttf"
]
title_font = None
en_font = None
sub_font = None
desc_font = None
badge_font = None
for p in font_path_candidates:
if os.path.exists(p):
try:
title_font = ImageFont.truetype(p, 58, index=0)
en_font = ImageFont.truetype(p, 32, index=0)
sub_font = ImageFont.truetype(p, 23, index=0)
desc_font = ImageFont.truetype(p, 21, index=0)
badge_font = ImageFont.truetype(p, 17, index=0)
break
except Exception:
continue
if not title_font:
title_font = ImageFont.load_default()
en_font = ImageFont.load_default()
sub_font = ImageFont.load_default()
desc_font = ImageFont.load_default()
badge_font = ImageFont.load_default()
tx = 475
ty = 105
# Badge Pill
pill_w = 240
pill_h = 36
draw.rounded_rectangle([tx, ty, tx + pill_w, ty + pill_h], radius=18, fill=(226, 243, 232, 255))
# Small green dot in pill
draw.ellipse([tx + 14, ty + 12, tx + 26, ty + 24], fill=(23, 107, 70, 255))
draw.text((tx + 34, ty + 7), "AI 口语私教 · 零基础开口", fill=(23, 107, 70, 255), font=badge_font)
# Main Brand Name
draw.text((tx, ty + 52), "芽说英语", fill=(25, 33, 27, 255), font=title_font)
draw.text((tx + 270, ty + 72), "SpeakSprout", fill=(23, 107, 70, 255), font=en_font)
# Slogan
draw.text((tx, ty + 145), "“ 每一次开口,都是成长的萌芽 ”", fill=(100, 114, 104, 255), font=sub_font)
# Clean bullet features (using custom drawn colored dots instead of emoji glyphs)
features = [
("1v1 智能语伴 Mia · 沉浸式情境真实对话", (23, 107, 70, 255)),
("听 · 说 · 读 · 写 四维微习惯进阶体系", (52, 185, 118, 255)),
("离线高精度语音识别 · 毫秒级跟读评分", (245, 158, 11, 255)),
("间隔艾宾浩斯复习 · 真正打破哑巴英语", (23, 107, 70, 255))
]
for i, (feat, dot_c) in enumerate(features):
fy = ty + 200 + i * 44
# Draw small clean bullet dot
draw.ellipse([tx, fy + 7, tx + 10, fy + 17], fill=dot_c)
draw.text((tx + 22, fy), feat, fill=(35, 45, 38, 245), font=desc_font)
return banner
if __name__ == "__main__":
out_dir = "assets/branding"
os.makedirs(out_dir, exist_ok=True)
print("🎨 正在生成全新高清 Logo v2 (1024x1024)...")
icon1024 = create_sprout_icon(size=1024, supersample=4)
master_path = os.path.join(out_dir, "logo_master_1024.png")
icon1024.save(master_path, "PNG")
print(f"✅ 保存主图标: {master_path}")
# 512x512
icon512 = icon1024.resize((512, 512), Image.Resampling.LANCZOS)
p512 = os.path.join(out_dir, "logo_512.png")
icon512.save(p512, "PNG")
# Brand Banner
print("🖼️ 正在生成品牌展示图 (1200x600)...")
banner = create_brand_banner(icon1024)
banner_path = os.path.join(out_dir, "brand_banner.png")
banner.save(banner_path, "PNG")
print(f"✅ 保存品牌展示图: {banner_path}")
# Android launcher icons
mipmaps = {
"mipmap-mdpi": 48,
"mipmap-hdpi": 72,
"mipmap-xhdpi": 96,
"mipmap-xxhdpi": 144,
"mipmap-xxxhdpi": 192,
}
print("📱 正在更新 Android App 各分辨率图标...")
for folder, sz in mipmaps.items():
dir_path = os.path.join("android/app/src/main/res", folder)
os.makedirs(dir_path, exist_ok=True)
out_f = os.path.join(dir_path, "ic_launcher.png")
resized = icon1024.resize((sz, sz), Image.Resampling.LANCZOS)
resized.save(out_f, "PNG")
print(f" -> {out_f} ({sz}x{sz})")
print("🎉 全部 Logo 和图标生成完成!")
+171
View File
@@ -0,0 +1,171 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
BUILD_MODE="debug"
TARGET=""
for arg in "$@"; do
case "$arg" in
android|apk)
TARGET="android"
;;
mac|macos|desktop)
TARGET="mac"
;;
all)
TARGET="all"
;;
--release|-r)
BUILD_MODE="release"
;;
--debug|-d)
BUILD_MODE="debug"
;;
--help|-h)
echo "用法: ./install_app.sh [目标] [选项]"
echo ""
echo "目标 (可选):"
echo " android 打包并安装到连接的 Android 真机"
echo " mac 打包并安装到 macOS (/Applications) 并启动"
echo " all 同时打包 Android 和 macOS 版本"
echo ""
echo "选项 (可选):"
echo " --debug 编译 Debug 版本 (默认,构建更快)"
echo " --release 编译 Release 版本 (体积更小,性能更好)"
echo " -h, --help 显示帮助信息"
exit 0
;;
*)
;;
esac
done
# 如果未指定目标且处于交互终端,提示用户选择
if [ -z "$TARGET" ]; then
if [ -t 0 ]; then
echo "========================================="
echo " 🌱 芽说英语 (Kouyu English) 打包安装工具"
echo "========================================="
echo "请选择要打包安装的目标平台:"
echo " 1) Android 真机 (自动检测设备并安装)"
echo " 2) macOS 桌面版 (打包并安装到 /Applications 并启动)"
echo " 3) 全部 (Android + macOS)"
read -p "请输入序号 [1/2/3] (默认 1): " choice
case "$choice" in
2)
TARGET="mac"
;;
3)
TARGET="all"
;;
*)
TARGET="android"
;;
esac
else
# 非交互模式下,优先检测 Android 设备
if adb devices 2>/dev/null | grep -v "List of devices" | grep -q "device$"; then
TARGET="android"
else
TARGET="mac"
fi
fi
fi
install_android() {
echo ""
echo "📱 ================= Android 打包与安装 ================="
echo "🔍 检查连接的 Android 设备..."
DEVICE_COUNT=$(adb devices 2>/dev/null | grep -v "List of devices" | grep "device$" | wc -l | tr -d ' ')
if [ "$DEVICE_COUNT" -eq 0 ]; then
echo "❌ 未检测到连接的 Android 设备,请确保手机已开启 USB 调试并通过数据线连接!"
return 1
fi
echo "📦 正在编译 Android ${BUILD_MODE} APK (包含最新的 assets 配置)..."
if [ "$BUILD_MODE" = "release" ]; then
flutter build apk --release
APK_PATH="build/app/outputs/flutter-apk/app-release.apk"
else
flutter build apk --debug
APK_PATH="build/app/outputs/flutter-apk/app-debug.apk"
fi
if [ ! -f "$APK_PATH" ]; then
echo "❌ 找不到编译输出的 APK: $APK_PATH"
return 1
fi
echo "🚀 正在安装 APK 到手机..."
if ! adb install -r "$APK_PATH"; then
echo "⚠️ 覆盖安装失败(签名不一致或版本冲突),正在卸载旧版本并重新安装..."
adb uninstall com.shen.kouyu_english || true
adb install -r "$APK_PATH"
fi
echo "▶️ 正在手机上启动 芽说英语 App..."
adb shell monkey -p com.shen.kouyu_english -c android.intent.category.LAUNCHER 1 > /dev/null 2>&1 || true
echo "✅ Android 版本安装并启动成功!"
}
install_mac() {
echo ""
echo "🖥️ ================= macOS 打包与安装 ================="
echo "📦 正在编译 macOS ${BUILD_MODE} 应用..."
if [ "$BUILD_MODE" = "release" ]; then
flutter build macos --release
APP_SRC="build/macos/Build/Products/Release/kouyu_english.app"
else
flutter build macos --debug
APP_SRC="build/macos/Build/Products/Debug/kouyu_english.app"
fi
if [ ! -d "$APP_SRC" ]; then
echo "❌ 找不到编译输出的 macOS App: $APP_SRC"
return 1
fi
APP_NAME="芽说英语.app"
DEST_DIR="/Applications"
DEST_APP="$DEST_DIR/$APP_NAME"
echo "📂 正在安装到 $DEST_APP..."
# 检查是否有权限写入 /Applications,如果无权限则回退至 ~/Applications
if [ ! -w "$DEST_DIR" ]; then
DEST_DIR="$HOME/Applications"
mkdir -p "$DEST_DIR"
DEST_APP="$DEST_DIR/$APP_NAME"
echo "️ /Applications 无写入权限,改用 $DEST_APP"
fi
# 关闭可能正在运行的实例
pkill -f "芽说英语" 2>/dev/null || pkill -f "kouyu_english" 2>/dev/null || true
sleep 1
# 同步/拷贝应用
rm -rf "$DEST_APP"
cp -R "$APP_SRC" "$DEST_APP"
echo "▶️ 正在启动 macOS 芽说英语 App..."
open "$DEST_APP"
echo "✅ macOS 版本已成功安装到 $DEST_APP 并启动!"
}
case "$TARGET" in
android)
install_android
;;
mac)
install_mac
;;
all)
install_android || true
install_mac
;;
esac
echo ""
echo "🎉 全部操作已完成!"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 B

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 704 B

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 B

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 14 KiB

+1 -1
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Kouyu English</string>
<string>芽说英语</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
+84
View File
@@ -0,0 +1,84 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'models.dart';
class AiConfigFile {
const AiConfigFile({
required this.provider,
required this.endpoint,
required this.model,
this.reasoningEffort = 'low',
this.apiKey,
this.description,
});
final AiProviderType provider;
final String endpoint;
final String model;
final String reasoningEffort;
final String? apiKey;
final String? description;
static const String defaultAssetPath = 'assets/config/ai_config.json';
factory AiConfigFile.fromJson(Map<String, dynamic> json) {
final providerStr = json['provider'] as String? ?? 'compatible';
final provider = AiProviderType.values.firstWhere(
(p) => p.name.toLowerCase() == providerStr.toLowerCase(),
orElse: () => AiProviderType.compatible,
);
final effort = (json['reasoningEffort'] as String? ??
json['reasoning_effort'] as String? ??
'low')
.trim();
return AiConfigFile(
provider: provider,
endpoint: (json['endpoint'] as String? ?? '').trim(),
model: (json['model'] as String? ?? '').trim(),
reasoningEffort: effort.isEmpty ? 'low' : effort,
apiKey: json['apiKey'] as String?,
description: json['description'] as String?,
);
}
factory AiConfigFile.parse(String rawJson) {
final data = jsonDecode(rawJson) as Map<String, dynamic>;
return AiConfigFile.fromJson(data);
}
Map<String, dynamic> toJson() => {
'provider': provider.name,
'endpoint': endpoint,
'model': model,
'reasoningEffort': reasoningEffort,
if (apiKey != null) 'apiKey': apiKey,
if (description != null) 'description': description,
};
static Future<AiConfigFile?> loadFromAsset([
String path = defaultAssetPath,
]) async {
try {
final content = await rootBundle.loadString(path);
return AiConfigFile.parse(content);
} catch (_) {
return null;
}
}
AiConfigFile copyWith({
AiProviderType? provider,
String? endpoint,
String? model,
String? reasoningEffort,
String? apiKey,
String? description,
}) => AiConfigFile(
provider: provider ?? this.provider,
endpoint: endpoint ?? this.endpoint,
model: model ?? this.model,
reasoningEffort: reasoningEffort ?? this.reasoningEffort,
apiKey: apiKey ?? this.apiKey,
description: description ?? this.description,
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,215 @@
part of 'app_state.dart';
/// Locally cached AI output: temporary glosses, sentence analyses and the
/// audited adaptive lesson with its resumable draft.
mixin _AiContent on _AppStateData, _ReviewAndMastery {
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();
}
}
SentenceAnalysisResult? sentenceAnalysisFor(String query) =>
sentenceAnalyses[_normalizeLexiconQuery(query)];
void cacheSentenceAnalysis(SentenceAnalysisResult result) {
final key = _normalizeLexiconQuery(result.originalText);
if (key.isEmpty || result.translation.trim().isEmpty) return;
sentenceAnalyses[key] = result;
notifyListeners();
}
void removeSentenceAnalysis(String query) {
if (sentenceAnalyses.remove(_normalizeLexiconQuery(query)) != null) {
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();
}
GeneratedLesson? get cachedAdaptiveLesson {
final raw = cachedAdaptiveLessonRaw;
if (raw == null) return null;
try {
final data = jsonDecode(raw) as Map<String, dynamic>;
final targets = data['targetItemIds'] as List<dynamic>?;
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();
}
}
@@ -0,0 +1,139 @@
part of 'app_state.dart';
/// Stage assessments and the A0 pass criteria.
mixin _AssessmentProgress on _AppStateData, _ReviewAndMastery {
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 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();
}
}
@@ -0,0 +1,400 @@
part of 'app_state.dart';
/// Position in the seed course, the in-lesson step flow, and the evidence
/// lesson tasks produce.
mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
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();
}
void saveDialogueDraft(DialogueDraft draft) {
dialogueDraft = draft;
notifyListeners();
}
void clearDialogueDraft() {
if (dialogueDraft == null) return;
dialogueDraft = null;
notifyListeners();
}
void saveSceneDialogueDraft(DialogueDraft draft) {
sceneDialogueDraft = draft;
notifyListeners();
}
void clearSceneDialogueDraft() {
if (sceneDialogueDraft == null) return;
sceneDialogueDraft = null;
notifyListeners();
}
bool get lessonCanComplete =>
lessonListeningComplete &&
lessonSpeakingComplete &&
lessonReadingComplete &&
lessonWritingComplete &&
lessonDialogueComplete &&
independentAttemptComplete;
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();
_syncInBackground();
}
/// Merges lesson progress pulled from the sync server.
///
/// Completed lessons/segments are unioned, and the learner's position only
/// ever moves forward: a device that has not caught up yet must never drag
/// another device back to an earlier lesson or segment.
bool mergeSyncedLessonProgress({
required Iterable<String> completedLessons,
required Iterable<String> completedSegments,
required String remoteActiveLessonId,
}) {
var changed = false;
final completedBefore = completedLessonIds.toSet();
for (final id in completedLessons) {
if (completedLessonIds.add(id)) changed = true;
}
for (final id in completedSegments) {
if (completedSegmentIds.add(id)) changed = true;
}
if (this.completedLessons != completedLessonIds.length) {
this.completedLessons = completedLessonIds.length;
changed = true;
}
int lessonIndex(String id) =>
a0SeedLessons.indexWhere((lesson) => lesson.id == id);
var targetIndex = lessonIndex(activeLessonId);
final remoteIndex = lessonIndex(remoteActiveLessonId);
if (remoteIndex > targetIndex && isLessonUnlocked(remoteActiveLessonId)) {
targetIndex = remoteIndex;
}
// The server's active lesson can itself be stale. Skip lessons that only
// became complete through this pull, but leave a lesson alone when the
// learner deliberately reopened it locally after finishing it.
while (targetIndex >= 0 &&
targetIndex < a0SeedLessons.length - 1 &&
!completedBefore.contains(a0SeedLessons[targetIndex].id) &&
completedLessonIds.contains(a0SeedLessons[targetIndex].id)) {
targetIndex++;
}
var positionChanged = false;
if (targetIndex >= 0 && a0SeedLessons[targetIndex].id != activeLessonId) {
activeLessonId = a0SeedLessons[targetIndex].id;
positionChanged = true;
}
for (final lesson in a0SeedLessons) {
final segments = lesson.segments;
var firstOpen = segments.indexWhere(
(segment) => !completedSegmentIds.contains(segment.id),
);
if (firstOpen < 0) firstOpen = segments.length - 1;
if (firstOpen > activeSegmentIndexFor(lesson.id)) {
activeSegmentIndexes[lesson.id] = firstOpen;
if (lesson.id == activeLessonId) positionChanged = true;
changed = true;
}
}
if (positionChanged) {
_resetLessonFlow();
changed = true;
}
return changed;
}
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();
_syncInBackground();
}
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();
}
List<String> 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<String> 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,
),
);
}
}
}
@@ -0,0 +1,463 @@
part of 'app_state.dart';
/// Review queue scheduling and the mastery derived from attempt evidence.
mixin _ReviewAndMastery on _AppStateData {
List<ReviewItem> 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));
}
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;
int get usableMasteryCount => mastery.values
.where(
(item) =>
item.status == MasteryStatus.use ||
item.status == MasteryStatus.master,
)
.length;
void addPhraseToReview({
required String phrase,
required String meaning,
String? ipa,
String? usageNote,
required String contextSentence,
}) {
final cleanPhrase = phrase.trim();
if (cleanPhrase.isEmpty) return;
final id =
'phrase_${cleanPhrase.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_')}';
final fullMeaning = usageNote != null && usageNote.trim().isNotEmpty
? '$meaning ($usageNote)'
: meaning;
final item = VocabularyItem(
id: id,
word: cleanPhrase,
meaning: fullMeaning,
example: contextSentence.trim(),
exampleMeaning: meaning,
ipa: ipa,
);
addSavedWord(item);
}
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();
_syncInBackground();
}
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 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();
}
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 = <String>{};
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 _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,
};
}
@@ -0,0 +1,475 @@
part of 'app_state.dart';
/// Local snapshot format of [AppState]. Keys are persisted on device, so
/// renaming one silently drops that part of a learner's saved progress.
extension _AppStateSnapshot on AppState {
void _restore(Map<String, dynamic> 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<dynamic>? ?? const [])
.whereType<String>(),
);
final savedTemporaryLexicon = data['temporaryLexicon'] as List<dynamic>?;
if (savedTemporaryLexicon != null) {
temporaryLexicon
..clear()
..addEntries(
savedTemporaryLexicon
.whereType<Map<String, dynamic>>()
.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,
),
);
}
final savedSentenceAnalyses = data['sentenceAnalyses'] as List<dynamic>?;
if (savedSentenceAnalyses != null) {
sentenceAnalyses
..clear()
..addEntries(
savedSentenceAnalyses
.whereType<Map<String, dynamic>>()
.map((item) {
final query = item['query'] as String? ?? '';
final payload = item['payload'] as Map<String, dynamic>? ?? {};
return MapEntry(
_normalizeLexiconQuery(query),
SentenceAnalysisResult.fromJson(payload),
);
})
.where(
(entry) =>
entry.key.isNotEmpty && entry.value.translation.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<dynamic>? ?? const [])
.whereType<String>(),
);
completedSegmentIds
..clear()
..addAll(
(data['completedSegmentIds'] as List<dynamic>? ?? const [])
.whereType<String>(),
);
reportedAiVariantKeys
..clear()
..addAll(
(data['reportedAiVariantKeys'] as List<dynamic>? ?? const [])
.whereType<String>(),
);
final savedSegmentIndexes =
data['activeSegmentIndexes'] as Map<String, dynamic>?;
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<dynamic>?;
if (reviews != null) {
reviewQueue
..clear()
..addAll(
reviews.whereType<Map<String, dynamic>>().map(_reviewFromJson),
);
}
final savedEvidence = data['attemptEvidence'] as List<dynamic>?;
if (savedEvidence != null) {
attemptEvidence
..clear()
..addAll(
savedEvidence.whereType<Map<String, dynamic>>().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<dynamic>?;
if (savedMastery != null) {
mastery
..clear()
..addEntries(
savedMastery.whereType<Map<String, dynamic>>().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<dynamic>? ?? const [])
.whereType<String>()
.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<dynamic>?;
if (savedAssessments != null) {
assessments
..clear()
..addAll(
savedAssessments.whereType<Map<String, dynamic>>().map((item) {
final rawResults =
item['results'] as Map<String, dynamic>? ?? 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<dynamic>? ?? const [])
.whereType<String>()
.map(
(name) => _enumValue(
AssessmentSkill.values,
name,
AssessmentSkill.speaking,
),
)
.toSet(),
);
}),
);
}
final savedDraft = data['assessmentDraft'] as Map<String, dynamic>?;
if (savedDraft != null) {
assessmentDraft = AssessmentDraft(
packId: savedDraft['packId'] as String,
taskIndex: savedDraft['taskIndex'] as int? ?? 0,
results: (savedDraft['results'] as Map<String, dynamic>? ?? const {})
.map((key, value) => MapEntry(key, value == true)),
);
}
dialogueDraft = _dialogueDraftFromJson(
data['dialogueDraft'] as Map<String, dynamic>?,
);
sceneDialogueDraft = _dialogueDraftFromJson(
data['sceneDialogueDraft'] as Map<String, dynamic>?,
);
}
Map<String, dynamic> _toSnapshotJson() => {
'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(),
'sentenceAnalyses': sentenceAnalyses.entries
.map(
(entry) => {
'query': entry.key,
'payload': entry.value.toJson(),
'provider': entry.value.provider,
'model': entry.value.model,
'createdAt': entry.value.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': _dialogueDraftToJson(dialogueDraft),
'sceneDialogueDraft': _dialogueDraftToJson(sceneDialogueDraft),
};
}
DialogueDraft? _dialogueDraftFromJson(Map<String, dynamic>? saved) {
if (saved == null || saved['lessonId'] is! String) return null;
return DialogueDraft(
lessonId: saved['lessonId'] as String,
stage: saved['stage'] as int? ?? 0,
usedHelp: saved['usedHelp'] as bool? ?? false,
turns: (saved['turns'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(
(turn) => DialogueTurn(
text: turn['text'] as String,
isLearner: turn['isLearner'] as bool? ?? false,
translation: turn['translation'] as String?,
),
)
.toList(),
);
}
Map<String, dynamic>? _dialogueDraftToJson(DialogueDraft? draft) =>
draft == null
? null
: {
'lessonId': draft.lessonId,
'stage': draft.stage,
'usedHelp': draft.usedHelp,
'turns': draft.turns
.map(
(turn) => {
'text': turn.text,
'isLearner': turn.isLearner,
'translation': turn.translation,
},
)
.toList(),
};
T _enumValue<T extends Enum>(List<T> values, String? name, T fallback) =>
values.where((value) => value.name == name).firstOrNull ?? fallback;
ReviewItem _reviewFromJson(Map<String, dynamic> 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,
);
+22 -3
View File
@@ -155,12 +155,31 @@ AssessmentPack? replacementFor(String packId) => switch (packId) {
/// 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 text = input
.toLowerCase()
.replaceAll('', "'")
.replaceAll('', "'")
.replaceAll(RegExp(r"\s+"), " ")
.trim();
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 has(String word) => words.contains(
word
.toLowerCase()
.replaceAll('', "'")
.replaceAll('', "'")
.trim(),
);
bool phrase(String value) {
final normVal = value
.toLowerCase()
.replaceAll('', "'")
.replaceAll('', "'")
.replaceAll(RegExp(r"\s+"), " ")
.trim();
return text.contains(normVal);
}
bool hasAny(Iterable<String> values) => values.any(has);
final introduction = phrase("i'm") || phrase('i am') || phrase('my name is');
final itIs = phrase("it's") || phrase('it is');
@@ -122,6 +122,8 @@ bool matchesAdaptiveLessonAnswer(GeneratedLessonTask task, String answer) {
String _normalizeAnswer(String value) => value
.toLowerCase()
.replaceAll('', "'")
.replaceAll('', "'")
.replaceAll(RegExp(r"[^a-z0-9']+"), ' ')
.trim()
.replaceAll(RegExp(r'\s+'), ' ');
+51
View File
@@ -105,6 +105,13 @@ class LocalSnapshotStore {
created_at TEXT NOT NULL
)
''');
await executor.runCustom('''
CREATE TABLE IF NOT EXISTS sentence_analyses (
query_key TEXT PRIMARY KEY NOT NULL, query TEXT NOT NULL,
payload 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,
@@ -233,6 +240,22 @@ class LocalSnapshotStore {
},
)
.toList();
result['sentenceAnalyses'] =
(await executor.runSelect(
'''SELECT query, payload, provider, model, created_at
FROM sentence_analyses ORDER BY created_at''',
const [],
))
.map(
(row) => <String, dynamic>{
'query': row['query'],
'payload': _decodeObject(row['payload']),
'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 [],
@@ -246,6 +269,8 @@ class LocalSnapshotStore {
result['assessmentDraft'] = _decodeObject(session['payload']);
} else if (session['session_type'] == 'dialogue') {
result['dialogueDraft'] = _decodeObject(session['payload']);
} else if (session['session_type'] == 'sceneDialogue') {
result['sceneDialogueDraft'] = _decodeObject(session['payload']);
}
}
return result;
@@ -262,6 +287,12 @@ class LocalSnapshotStore {
return decoded is Map<String, dynamic> ? decoded : const {};
}
String _encodeObject(Object? value) {
if (value == null) return '{}';
if (value is String) return value;
return jsonEncode(value);
}
Future<void> write(String payload) async {
final executor = _executor ?? await _open();
await _ensureSchema(executor);
@@ -357,6 +388,7 @@ class LocalSnapshotStore {
},
'assessment': data['assessmentDraft'],
'dialogue': data['dialogueDraft'],
'sceneDialogue': data['sceneDialogueDraft'],
};
for (final entry in sessions.entries) {
if (entry.value == null) continue;
@@ -466,6 +498,24 @@ class LocalSnapshotStore {
],
);
}
for (final entry in (data['sentenceAnalyses'] as List? ?? const [])) {
if (entry is! Map) continue;
final query = entry['query'] as String? ?? '';
final key = query.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' ');
final payload = _encodeObject(entry['payload'] ?? {});
if (key.isEmpty || payload == '{}') continue;
await executor.runCustom(
'INSERT INTO sentence_analyses VALUES (?, ?, ?, ?, ?, ?)',
[
key,
query,
payload,
entry['provider'] ?? 'unknown',
entry['model'] ?? '',
entry['createdAt'] ?? DateTime.now().toUtc().toIso8601String(),
],
);
}
}
Future<void> clear() async {
@@ -484,6 +534,7 @@ class LocalSnapshotStore {
'review_items',
'study_sessions',
'temporary_lexicon_entries',
'sentence_analyses',
'learner_profiles',
'lesson_progress',
'assessment_records',
+114 -4
View File
@@ -78,13 +78,15 @@ enum ContentSource { builtInOriginal, aiGenerated, importedReference }
class DialogueAiResponse {
const DialogueAiResponse({
required this.reply,
required this.slots,
required this.evidence,
required this.suggestsComplete,
this.slots = const {},
this.evidence = const [],
this.suggestsComplete = false,
this.translation,
this.feedback,
});
final String reply;
final String? translation;
final Map<String, String> slots;
final List<String> evidence;
final bool suggestsComplete;
@@ -208,6 +210,92 @@ class TemporaryLexiconEntry {
final DateTime createdAt;
}
/// Represents an extracted key phrase or vocabulary item within a sentence.
class PhraseBreakdownItem {
const PhraseBreakdownItem({
required this.phrase,
required this.meaning,
this.ipa,
this.usageNote,
});
final String phrase;
final String meaning;
final String? ipa;
final String? usageNote;
Map<String, dynamic> toJson() => {
'phrase': phrase,
'meaning': meaning,
if (ipa != null) 'ipa': ipa,
if (usageNote != null) 'usageNote': usageNote,
};
factory PhraseBreakdownItem.fromJson(Map<String, dynamic> json) =>
PhraseBreakdownItem(
phrase: json['phrase'] as String? ?? '',
meaning: json['meaning'] as String? ?? '',
ipa: json['ipa'] as String?,
usageNote: json['usageNote'] as String?,
);
}
/// Structured multi-dimensional analysis for a sentence or phrase, including
/// translation, sentence pattern, grammar note, pronunciation tips, and extracted phrases.
class SentenceAnalysisResult {
const SentenceAnalysisResult({
required this.originalText,
required this.translation,
this.sentencePattern,
this.grammarNote,
this.pronunciationTips,
this.phrases = const [],
this.provider = 'unknown',
this.model = '',
required this.createdAt,
});
final String originalText;
final String translation;
final String? sentencePattern;
final String? grammarNote;
final String? pronunciationTips;
final List<PhraseBreakdownItem> phrases;
final String provider;
final String model;
final DateTime createdAt;
Map<String, dynamic> toJson() => {
'originalText': originalText,
'translation': translation,
if (sentencePattern != null) 'sentencePattern': sentencePattern,
if (grammarNote != null) 'grammarNote': grammarNote,
if (pronunciationTips != null) 'pronunciationTips': pronunciationTips,
'phrases': phrases.map((p) => p.toJson()).toList(),
'provider': provider,
'model': model,
'createdAt': createdAt.toIso8601String(),
};
factory SentenceAnalysisResult.fromJson(Map<String, dynamic> json) =>
SentenceAnalysisResult(
originalText: json['originalText'] as String? ?? '',
translation: json['translation'] as String? ?? '',
sentencePattern: json['sentencePattern'] as String?,
grammarNote: json['grammarNote'] as String?,
pronunciationTips: json['pronunciationTips'] as String?,
phrases: (json['phrases'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(PhraseBreakdownItem.fromJson)
.toList(),
provider: json['provider'] as String? ?? 'unknown',
model: json['model'] as String? ?? '',
createdAt: json['createdAt'] != null
? DateTime.tryParse(json['createdAt'] as String) ?? DateTime.now()
: DateTime.now(),
);
}
class ReviewItem {
const ReviewItem({
required this.id,
@@ -297,10 +385,27 @@ class MasteryItem {
}
class DialogueTurn {
const DialogueTurn({required this.text, required this.isLearner});
const DialogueTurn({
required this.text,
required this.isLearner,
this.translation,
});
final String text;
final bool isLearner;
final String? translation;
DialogueTurn copyWith({
String? text,
bool? isLearner,
String? translation,
}) {
return DialogueTurn(
text: text ?? this.text,
isLearner: isLearner ?? this.isLearner,
translation: translation ?? this.translation,
);
}
}
/// Ephemeral presentation data for a completed controlled dialogue. The
@@ -310,9 +415,14 @@ class DialogueSummaryData {
required this.completedTasks,
required this.personalSentence,
required this.usedHelp,
this.improvement,
});
final List<String> completedTasks;
final String personalSentence;
final bool usedHelp;
/// One improvement point collected during the dialogue and shown only at the
/// end, per "对话中不逐句打断" in the conversation spec.
final String? improvement;
}
+12 -6
View File
@@ -13,13 +13,20 @@ class ReviewCheckResult {
class ReviewFeedback {
const ReviewFeedback._();
static String _normalize(String input) => input
.toLowerCase()
.replaceAll('', "'")
.replaceAll('', "'")
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
static ReviewCheckResult check(ReviewItem item, String input) {
final text = input.toLowerCase().replaceAll('', "'");
final text = _normalize(input);
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 has(String token) => tokens.contains(_normalize(token));
bool phrase(String value) => text.contains(_normalize(value));
bool hasAny(Iterable<String> values) => values.any(has);
final introduction =
phrase("i'm") || phrase('i am') || phrase('my name is');
@@ -68,9 +75,8 @@ class ReviewFeedback {
'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(),
),
_ when item.id.startsWith('A0-W') =>
tokens.contains(_normalize(item.target)) || phrase(item.target),
_ => tokens.length >= 2,
};
return ReviewCheckResult(
+370 -46
View File
@@ -1,3 +1,4 @@
import 'a0_core.dart';
import 'models.dart';
/// Stable A0 core identities taught by each seed lesson. Wording can vary in
@@ -533,6 +534,7 @@ class LessonActivity {
required this.reading,
required this.readingQuestion,
required this.readingAnswer,
this.readingOptions = const [],
required this.writingPrompt,
required this.writingExample,
required this.independentPrompt,
@@ -545,12 +547,39 @@ class LessonActivity {
final String reading;
final String readingQuestion;
final String readingAnswer;
final List<String> readingOptions;
final String writingPrompt;
final String writingExample;
final String independentPrompt;
final String independentHelp;
}
int _seedHash(String seed) {
var hash = 0x811c9dc5;
for (final code in seed.codeUnits) {
hash = ((hash ^ code) * 0x01000193) & 0x7fffffff;
}
return hash;
}
/// 选项顺序固定会让学习者养成“永远选第一个”的习惯。[options] 的第一项是标准
/// 答案,这里按题目内容确定性地把它挪到某个位置,并打乱其余干扰项:同一道题
/// 每次进入顺序都一样,但答案在三个位置上分布均匀。
List<String> shuffledOptions(List<String> options, String seed) {
if (options.length < 2) return options;
final distractors = [...options.skip(1)];
var hash = _seedHash(seed);
for (var i = distractors.length - 1; i > 0; i--) {
hash = (hash * 1103515245 + 12345) & 0x7fffffff;
final j = hash % (i + 1);
final swap = distractors[i];
distractors[i] = distractors[j];
distractors[j] = swap;
}
return distractors
..insert(_seedHash('$seed#slot') % options.length, options.first);
}
const a0Activities = <String, LessonActivity>{
'a0-01': LessonActivity(
listening: 'Hello. Im Mia. Nice to meet you.',
@@ -558,9 +587,14 @@ const a0Activities = <String, LessonActivity>{
answers: ['自我介绍', '买东西', '问时间'],
speaking: 'Hello. Im Shen. Nice to meet you.',
reading:
'Mia: Hello. Im Mia.\nShen: Hi. Im Shen.\nMia: Nice to meet you.\nShen: Nice to meet you, too.',
readingQuestion: '谁叫 Mia',
readingAnswer: '第一位说话的人',
'Mia: Hello. Im Mia. Whats your name?\nShen: Hi. Im Shen.\nMia: Nice to meet you.\nShen: Nice to meet you, too.',
readingQuestion: 'Shen 说的最后一句是什么',
readingAnswer: 'Nice to meet you, too.',
readingOptions: [
'Nice to meet you, too.',
'Whats your name?',
'Hi. Im Shen.',
],
writingPrompt: '写一句问候和姓名介绍。',
writingExample: 'Hello. Im Shen.',
independentPrompt: '不看句框,介绍你的名字并回应“Nice to meet you”。',
@@ -572,9 +606,10 @@ const a0Activities = <String, LessonActivity>{
answers: ['你的名字怎么拼?', '你的名字是什么?', '你来自哪里?'],
speaking: 'S H E N',
reading:
'Mia: Whats your name?\nShen: Shen.\nMia: How do you spell that?\nShen: S-H-E-N.',
readingQuestion: 'Shen 的名字怎么拼?',
'Mia: Im Mia. M-I-A.\nShen: Hi, Mia. Im Shen. S-H-E-N.\nMia: How do you spell Sam?\nShen: S-A-M.',
readingQuestion: 'Shen 自己的名字怎么拼',
readingAnswer: 'S-H-E-N',
readingOptions: ['S-H-E-N', 'M-I-A', 'S-A-M'],
writingPrompt: '把名字和拼写写完整。',
writingExample: 'My name is Shen.\nS-H-E-N.',
independentPrompt: '不看句框,用英文介绍名字并拼读它。',
@@ -586,9 +621,10 @@ const a0Activities = <String, LessonActivity>{
answers: ['你的状态', '你的号码', '你的地点'],
speaking: 'How are you? Im good, thanks.',
reading:
'Mia: Hi, Shen. How are you?\nShen: Im good, thanks. How are you?\nMia: Im okay.',
readingQuestion: 'Mia 状态如何',
readingAnswer: 'okay',
'Mia: Hi, Shen. How are you?\nShen: Im good, thanks. How are you?\nMia: Im tired today.',
readingQuestion: 'Mia 说自己今天怎么样',
readingAnswer: 'Im tired today.',
readingOptions: ['Im tired today.', 'Im good, thanks.', 'Im okay.'],
writingPrompt: '写一句今天的状态。',
writingExample: 'Im okay today.',
independentPrompt: '不看句框,问候并说出你的状态。',
@@ -600,9 +636,10 @@ const a0Activities = <String, LessonActivity>{
answers: ['138', '183', '318'],
speaking: 'My number is one-three-eight.',
reading:
'Mia: Whats your phone number?\nShen: One-three-eight.\nMia: Thank you.',
readingQuestion: 'Shen 的号码',
'Mia: Whats your phone number?\nShen: My number is one-three-eight.\nMia: One-eight-three?\nShen: No. One-three-eight.',
readingQuestion: 'Shen 的号码到底是哪一个',
readingAnswer: '138',
readingOptions: ['138', '183', '318'],
writingPrompt: '把虚拟号码 139 写成英文。',
writingExample: 'one-three-nine',
independentPrompt: '不看句框,说出一个三位虚拟号码。',
@@ -613,9 +650,10 @@ const a0Activities = <String, LessonActivity>{
listeningQuestion: '这是什么?',
answers: ['钥匙', '', ''],
speaking: 'Whats this? Its a pen.',
reading: 'Mia: Whats this?\nShen: Its a book.',
readingQuestion: '是什么?',
readingAnswer: 'a book',
reading: 'Mia: Whats this? Is it a pen?\nShen: No. Its a book.',
readingQuestion: 'Shen 说那件东西是什么?',
readingAnswer: 'A book',
readingOptions: ['A book', 'A pen', 'A phone'],
writingPrompt: '写一句“这是一支笔”。',
writingExample: 'Its a pen.',
independentPrompt: '不看句框,说出一个身边物品。',
@@ -626,9 +664,11 @@ const a0Activities = <String, LessonActivity>{
listeningQuestion: '对方在问什么?',
answers: ['来自哪里', '叫什么', '喜欢什么'],
speaking: 'Im from Hong Kong.',
reading: 'Mia: Where are you from?\nShen: Im from Hong Kong.',
reading:
'Mia: Im from London. Where are you from?\nShen: Im from Hong Kong.',
readingQuestion: 'Shen 来自哪里?',
readingAnswer: 'Hong Kong',
readingOptions: ['Hong Kong', 'London', 'Beijing'],
writingPrompt: '写一句你来自哪里。',
writingExample: 'Im from Hong Kong.',
independentPrompt: '不看句框,说来自哪里并反问对方。',
@@ -639,9 +679,11 @@ const a0Activities = <String, LessonActivity>{
listeningQuestion: '这个人是谁?',
answers: ['妈妈', '朋友', '老师'],
speaking: 'This is my family.',
reading: 'Mia: Who is this?\nShen: This is my mother.',
readingQuestion: 'Shen 在介绍谁?',
readingAnswer: 'my mother',
reading:
'Mia: Who is this? Is this your father?\nShen: No. This is my mother.',
readingQuestion: 'Shen 介绍的是哪一位家人?',
readingAnswer: 'My mother',
readingOptions: ['My mother', 'My father', 'My sister'],
writingPrompt: '介绍一位家人或朋友。',
writingExample: 'This is my mother.',
independentPrompt: '不看句框,介绍一位家人或朋友。',
@@ -652,9 +694,11 @@ const a0Activities = <String, LessonActivity>{
listeningQuestion: '是什么时间?',
answers: ['星期一三点', '星期三一点', '星期一一点'],
speaking: 'Its three oclock.',
reading: 'Mia: What day is it?\nShen: Its Monday.',
readingQuestion: '今天星期几?',
reading:
'Mia: What day is it today? Is it Tuesday?\nShen: No. Its Monday.',
readingQuestion: '对话中今天到底是星期几?',
readingAnswer: 'Monday',
readingOptions: ['Monday', 'Tuesday', 'Sunday'],
writingPrompt: '写一句今天和整点。',
writingExample: 'Its Monday. Its three oclock.',
independentPrompt: '不看句框,说今天星期几或现在几点。',
@@ -665,9 +709,11 @@ const a0Activities = <String, LessonActivity>{
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.',
reading:
'Mia: I like tea. Do you like tea?\nShen: No, I dont. I like coffee.',
readingQuestion: 'Shen 喜欢喝什么?',
readingAnswer: 'Coffee',
readingOptions: ['Coffee', 'Tea', 'Music'],
writingPrompt: '写一句你的喜好。',
writingExample: 'I like tea.',
independentPrompt: '不看句框,说一个喜好并反问。',
@@ -678,9 +724,11 @@ const a0Activities = <String, LessonActivity>{
listeningQuestion: '对方希望什么?',
answers: ['再说一遍', '说慢一点', '写下来'],
speaking: 'Please speak slowly.',
reading: 'Mia: Please say that again.\nShen: Please speak slowly.',
readingQuestion: 'Shen 希望什么?',
readingAnswer: 'speak slowly',
reading:
'Alex: Hi! Im Alex. Whats your name?\nShen: Hi! Im Shen. Nice to meet you.\nAlex: Nice to meet you, too. Where are you from?\nShen: Im from Hong Kong. Where are you from?\nAlex: Im from London. Do you like coffee?\nShen: No, I dont. I like tea.',
readingQuestion: 'Shen 来自哪里、喜欢喝什么?',
readingAnswer: 'Hong Kong, tea',
readingOptions: ['Hong Kong, tea', 'London, coffee', 'Hong Kong, coffee'],
writingPrompt: '写姓名、地点和喜好三句。',
writingExample: 'Im Shen.\nIm from Hong Kong.\nI like tea.',
independentPrompt: '不看帮助,完成姓名、地点、喜好和反问。',
@@ -699,8 +747,9 @@ const a0SegmentActivities = <String, LessonActivity>{
answers: ['123', '132', '213'],
speaking: 'Zero, one, two, three.',
reading: 'Mia: One, two, three.\nShen: Four, five.',
readingQuestion: 'Shen 说了哪两个数字?',
readingAnswer: 'four, five',
readingQuestion: 'Shen 接着说了哪两个数字?',
readingAnswer: 'Four, five',
readingOptions: ['Four, five', 'One, two', 'Three, four'],
writingPrompt: '把 0、1、2、3 写成英文。',
writingExample: 'zero, one, two, three',
independentPrompt: '不看帮助,说出三个 0 到 5 的数字。',
@@ -711,9 +760,10 @@ const a0SegmentActivities = <String, LessonActivity>{
listeningQuestion: '听到哪一组数字?',
answers: ['678', '687', '768'],
speaking: 'My phone is here.',
reading: 'Mia: What is this?\nShen: It is a phone.',
readingQuestion: '这是什么',
readingAnswer: 'a phone',
reading: 'Mia: Six, seven, eight.\nShen: Nine, ten.',
readingQuestion: 'Shen 最后说了哪两个数字',
readingAnswer: 'Nine, ten',
readingOptions: ['Nine, ten', 'Six, seven', 'Seven, eight'],
writingPrompt: '把 6、7、8 写成英文。',
writingExample: 'six, seven, eight',
independentPrompt: '不看帮助,说出三个 6 到 10 的数字。',
@@ -724,9 +774,11 @@ const a0SegmentActivities = <String, LessonActivity>{
listeningQuestion: '号码是多少?',
answers: ['138', '183', '318'],
speaking: 'My number is one-three-eight.',
reading: 'Mia: Whats your phone number?\nShen: One-three-eight.',
readingQuestion: 'Shen 的号码是?',
readingAnswer: '138',
reading:
'Mia: Whats your phone number?\nShen: My number is five-zero-two.\nMia: Five-two-zero?\nShen: No. Five-zero-two.',
readingQuestion: 'Shen 最后确认的号码是哪一个?',
readingAnswer: '502',
readingOptions: ['502', '520', '205'],
writingPrompt: '把虚拟号码 139 写成英文。',
writingExample: 'one-three-nine',
independentPrompt: '不看句框,说出一个三位虚拟号码。',
@@ -737,9 +789,11 @@ const a0SegmentActivities = <String, LessonActivity>{
listeningQuestion: '今天星期几?',
answers: ['星期一', '星期二', '星期三'],
speaking: 'It is Monday.',
reading: 'Mia: What day is it?\nShen: It is Tuesday.',
readingQuestion: '今天星期几?',
reading:
'Mia: What day is it today? Is it Monday?\nShen: No. It is Tuesday.',
readingQuestion: 'Mia 猜错了,今天其实是星期几?',
readingAnswer: 'Tuesday',
readingOptions: ['Tuesday', 'Monday', 'Wednesday'],
writingPrompt: '写一句今天是星期一、二或三。',
writingExample: 'It is Monday.',
independentPrompt: '不看帮助,说出星期一、二或三。',
@@ -750,9 +804,10 @@ const a0SegmentActivities = <String, LessonActivity>{
listeningQuestion: '今天星期几?',
answers: ['星期五', '星期四', '星期日'],
speaking: 'It is Friday.',
reading: 'Mia: What day is it?\nShen: It is Sunday.',
readingQuestion: '今天星期几',
reading: 'Mia: Is it Saturday today?\nShen: No. It is Sunday.',
readingQuestion: 'Shen 说今天是哪一天',
readingAnswer: 'Sunday',
readingOptions: ['Sunday', 'Saturday', 'Friday'],
writingPrompt: '写一句今天是星期四到日。',
writingExample: 'It is Friday.',
independentPrompt: '不看帮助,说出星期四到日。',
@@ -763,9 +818,11 @@ const a0SegmentActivities = <String, LessonActivity>{
listeningQuestion: '是什么时间?',
answers: ['三点', '一点', '星期三'],
speaking: 'It is three oclock.',
reading: 'Mia: What time is it?\nShen: It is three oclock.',
readingQuestion: '几点?',
readingAnswer: 'three oclock',
reading:
'Mia: What time is it? Is it two oclock?\nShen: No. It is three oclock.',
readingQuestion: 'Shen 说现在是几点?',
readingAnswer: 'Three oclock',
readingOptions: ['Three oclock', 'Two oclock', 'One oclock'],
writingPrompt: '写一句整点时间。',
writingExample: 'It is three oclock.',
independentPrompt: '不看帮助,说一个整点时间。',
@@ -1024,8 +1081,17 @@ const a0SegmentGrammarNotes = <String, String>{
String grammarNoteForSegment(String segmentId, String lessonId) =>
a0SegmentGrammarNotes[segmentId] ?? grammarNoteForLesson(lessonId);
String _normalizeDialogueInput(String response) => response
.toLowerCase()
.replaceAll('\u2019', "'")
.replaceAll('\u2018', "'")
.replaceAll('', "'")
.replaceAll('', "'")
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
bool matchesSegmentDialogue(String segmentId, int stage, String response) {
final text = response.toLowerCase().replaceAll('', "'");
final text = _normalizeDialogueInput(response);
final segment = a0LessonSegments.values
.expand((segments) => segments)
.where((item) => item.id == segmentId)
@@ -1034,7 +1100,7 @@ bool matchesSegmentDialogue(String segmentId, int stage, String response) {
return false;
}
bool hasAny(Iterable<String> terms) =>
terms.any((term) => text.contains(term));
terms.any((term) => text.contains(_normalizeDialogueInput(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) {
@@ -1045,7 +1111,7 @@ bool matchesSegmentDialogue(String segmentId, int stage, String response) {
}
bool matchesSegmentIndependent(String segmentId, String response) {
final text = response.toLowerCase().replaceAll('', "'");
final text = _normalizeDialogueInput(response);
final segment = a0LessonSegments.values
.expand((segments) => segments)
.where((item) => item.id == segmentId)
@@ -1053,17 +1119,18 @@ bool matchesSegmentIndependent(String segmentId, String response) {
if (segment == null) return true;
if (segmentId == 'a0-04-a' || segmentId == 'a0-04-b') {
final count = segment.independentRequiredTerms
.where((term) => text.contains(term))
.where((term) => text.contains(_normalizeDialogueInput(term)))
.length;
return count >= 3;
}
if (segmentId == 'a0-04-c') {
return segment.independentRequiredTerms
.where((term) => text.contains(term))
.where((term) => text.contains(_normalizeDialogueInput(term)))
.length >=
3;
}
return segment.independentRequiredTerms.any((term) => text.contains(term));
return segment.independentRequiredTerms
.any((term) => text.contains(_normalizeDialogueInput(term)));
}
class LessonDialogue {
@@ -1071,12 +1138,115 @@ class LessonDialogue {
required this.goal,
required this.prompts,
required this.hints,
this.translations = const [],
this.requiredTerms = const [],
this.taskLabels = const [],
});
final String goal;
final List<String> prompts;
final List<String> hints;
final List<String> translations;
/// One accepted term group per learner turn. The learner only needs to hit
/// one term in the current group, so natural wording still passes. A term
/// may be a plain word or phrase, an `a + b` conjunction (both parts are
/// required), or a structural token starting with `#`.
final List<List<String>> requiredTerms;
/// Chinese label of what each learner turn has to do. It drives the
/// "还没完成本轮任务" message and the summary of completed tasks, so it must
/// describe the task without giving away the model answer.
final List<String> taskLabels;
}
/// Matches whole words only: the old `text.contains('it')` accepted almost any
/// sentence, including ones that never performed the task.
bool _containsTerm(String text, String term) {
if (term.contains(' + ')) {
return term.split(' + ').every((part) => _containsTerm(text, part.trim()));
}
if (term.startsWith('#')) return _matchesStructure(text, term);
final normTerm = _normalizeDialogueInput(term);
final escaped = RegExp.escape(normTerm);
return RegExp('(?<![a-z])$escaped(?![a-z])').hasMatch(text);
}
bool _matchesStructure(String text, String token) => switch (token) {
// Three or more letters said one by one, e.g. "S-H-E-N" or "s h e n".
'#spelling' => RegExp(r'(?:^|[^a-z])[a-z](?:[ -][a-z]){2,}').hasMatch(text),
// Any spoken or written digit.
'#digit' => RegExp(
r'(?<![a-z])(zero|one|two|three|four|five|six|seven|eight|nine|ten)(?![a-z])|[0-9]',
).hasMatch(text),
// A real word after the frame, so "I'm" alone is not a name.
'#word' => RegExp(r'[a-z]{2,}').hasMatch(text),
// Any question addressed to the partner.
'#question' =>
text.contains('?') ||
RegExp(
r"(?<![a-z])(what|where|who|when|how|why|do you|are you|can you)(?![a-z])",
).hasMatch(text),
_ => false,
};
/// Whole-lesson and free-scene dialogues validate the same way segment
/// dialogues do: the turn has to contain the language the turn is teaching.
bool matchesDialogueStage(LessonDialogue script, int stage, String response) {
final text = _normalizeDialogueInput(response);
if (stage < 0 || stage >= script.requiredTerms.length) {
// No declared requirement: accept anything that is actually a word.
return RegExp(r'[a-z]{2,}').hasMatch(text);
}
final terms = script.requiredTerms[stage];
if (terms.isEmpty) return RegExp(r'[a-z]{2,}').hasMatch(text);
return terms.any((term) => _containsTerm(text, term));
}
String dialogueTaskLabel(LessonDialogue script, int stage) =>
stage >= 0 && stage < script.taskLabels.length
? script.taskLabels[stage]
: '完成本轮任务';
/// The free "初次见面" scene. It lives next to the lesson dialogues so its
/// tasks are validated and summarised by the same rules.
const a0MeetDialogue = LessonDialogue(
goal: '姓名、地点、状态或喜好,并反问',
prompts: [
'Hi! My name is Mia. What\u2019s your name?',
'Nice to meet you. Where are you from?',
'Great! How are you today? Or what do you like?',
'I\u2019m good, thanks. Now ask me one question!',
],
hints: [
'My name is Alex.',
'I\u2019m from Hong Kong.',
'I\u2019m good, thanks. / I like coffee.',
'What\u2019s your name? / Where are you from?',
],
translations: [
'嗨!我叫 Mia。你叫什么名字?',
'很高兴认识你。你来自哪里?',
'很好!你今天怎么样?或者你喜欢什么?',
'我很好,谢谢。现在请问我一个问题!',
],
requiredTerms: [
["i'm + #word", 'i am + #word', 'my name is', "my name's", 'name is'],
["i'm from", 'i am from', 'from + #word'],
[
'good',
'okay',
'ok',
'fine',
'great',
'tired',
'i like',
'i love',
],
['#question'],
],
taskLabels: ['介绍姓名', '说明来自哪里', '表达状态或喜好', '反问对方'],
);
const a0Dialogues = <String, LessonDialogue>{
'a0-01': LessonDialogue(
goal: '问候、介绍姓名并回应见面问候',
@@ -1092,6 +1262,19 @@ const a0Dialogues = <String, LessonDialogue>{
'Hello!',
'Whats your name?',
],
translations: [
'嗨!我是 Mia。你叫什么名字?',
'很高兴认识你。请说:Nice to meet you, too(我也很高兴认识你)。',
'太棒了!再打一次招呼吧。',
'现在请问我的名字!',
],
requiredTerms: [
["i'm + #word", 'i am + #word', 'my name is', "my name's", 'name is'],
['nice to meet you'],
['hello', 'hi', 'hey'],
["what's your name", 'what is your name', 'your name', '#question'],
],
taskLabels: ['说出你的名字', '回应 Nice to meet you', '再打一次招呼', '反问对方的名字'],
),
'a0-02': LessonDialogue(
goal: '介绍姓名并完整拼读名字',
@@ -1102,6 +1285,14 @@ const a0Dialogues = <String, LessonDialogue>{
'Now ask me my name!',
],
hints: ['My name is Alex.', 'A-L-E-X.', 'A-L-E-X.', 'Whats your name?'],
translations: ['嗨!你叫什么名字?', '那个怎么拼写?', '谢谢。请再拼写一次。', '现在请问我的名字!'],
requiredTerms: [
["i'm + #word", 'i am + #word', 'my name is', "my name's", 'name is'],
['#spelling'],
['#spelling'],
["what's your name", 'what is your name', 'your name', '#question'],
],
taskLabels: ['说出你的名字', '拼读你的名字', '再拼读一次', '反问对方的名字'],
),
'a0-03': LessonDialogue(
goal: '询问状态、回答并反问',
@@ -1112,6 +1303,19 @@ const a0Dialogues = <String, LessonDialogue>{
'Now say goodbye!',
],
hints: ['Im good, thanks.', 'How are you?', 'Im okay.', 'Bye!'],
translations: [
'嗨!你今天好吗?',
'很好!请问我:How are you(你好吗)?',
'我还好。请再说一次你的状态。',
'现在请说再见!',
],
requiredTerms: [
['good', 'okay', 'ok', 'fine', 'great', 'tired'],
['how are you', '#question'],
['good', 'okay', 'ok', 'fine', 'great', 'tired'],
['bye', 'goodbye', 'see you'],
],
taskLabels: ['说出你今天的状态', '反问对方的状态', '再说一次你的状态', '说再见'],
),
'a0-04': LessonDialogue(
goal: '报告一个虚拟三位号码并确认',
@@ -1127,6 +1331,19 @@ const a0Dialogues = <String, LessonDialogue>{
'One-three-eight.',
'Whats your phone number?',
],
translations: [
'你的电话号码是多少?可以使用一个虚拟的三位数字。',
'我听到了 1-3-8。对吗?',
'请再说一遍这三个数字。',
'现在请问我的电话号码!',
],
requiredTerms: [
['my number is + #digit', "my number's + #digit", 'number is + #digit'],
['yes', 'no', "that's right", 'right', 'correct'],
['#digit'],
["what's your phone number", 'what is your phone number', 'your phone number', 'your number'],
],
taskLabels: ['报出一个三位号码', '确认或纠正听到的号码', '再说一次这三个数字', '反问对方的号码'],
),
'a0-05': LessonDialogue(
goal: '询问并说出一个物品',
@@ -1137,6 +1354,19 @@ const a0Dialogues = <String, LessonDialogue>{
'Great! Say one more object.',
],
hints: ['Its a pen.', 'Its a pen.', 'Whats this?', 'Its a book.'],
translations: [
'这是什么?这是一支笔。',
'现在请说:It’s a pen(这是一支笔)。',
'请问我:What’s this(这是什么)?',
'太棒了!再说一个物品吧。',
],
requiredTerms: [
["it's a", 'it is a', "it's an", 'it is an'],
["it's a", 'it is a', "it's an", 'it is an'],
["what's this", 'what is this'],
["it's a", 'it is a', "it's an", 'it is an'],
],
taskLabels: ['说出这个物品', '完整说出 Its a … 句型', '反问 Whats this', '再说一个物品'],
),
'a0-06': LessonDialogue(
goal: '说来自哪里并反问',
@@ -1152,6 +1382,19 @@ const a0Dialogues = <String, LessonDialogue>{
'Im from Hong Kong.',
'Bye!',
],
translations: [
'你来自哪里?',
'很好!请问我:Where are you from(你来自哪里)?',
'请再说一次你来自哪里。',
'请说再见!',
],
requiredTerms: [
["i'm from", 'i am from', 'from + #word'],
['where are you from', "where're you from", 'where are you'],
["i'm from", 'i am from', 'from + #word'],
['bye', 'goodbye', 'see you'],
],
taskLabels: ['说出你来自哪里', '反问对方来自哪里', '再说一次你来自哪里', '说再见'],
),
'a0-07': LessonDialogue(
goal: '介绍一位家人或朋友',
@@ -1167,6 +1410,19 @@ const a0Dialogues = <String, LessonDialogue>{
'Who is this?',
'This is my friend.',
],
translations: [
'这是谁?',
'很好。请说:This is my mother(这是我的妈妈)。',
'请问我:Who is this(这是谁)?',
'请再介绍一个人。',
],
requiredTerms: [
['this is my', 'this is'],
['this is my', 'this is'],
['who is this', "who's this"],
['this is my', 'this is'],
],
taskLabels: ['介绍一位家人或朋友', '完整说出 This is my … 句型', '反问 Who is this', '再介绍一个人'],
),
'a0-08': LessonDialogue(
goal: '说明星期或整点',
@@ -1182,6 +1438,14 @@ const a0Dialogues = <String, LessonDialogue>{
'Its three oclock.',
'What day is it?',
],
translations: ['今天星期几?', '现在几点了?', '请说一个完整的时间句子。', '现在请问我今天是星期几!'],
requiredTerms: [
['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'],
["o'clock", 'oclock', "#digit + o'clock"],
["it's + o'clock", "it is + o'clock", "it's + #digit", 'it is + #digit'],
['what day is it', 'what day', '#question'],
],
taskLabels: ['说出今天星期几', '说出现在几点', '说一个完整的时间句子', '反问今天星期几'],
),
'a0-09': LessonDialogue(
goal: '表达喜好、回答和反问',
@@ -1192,6 +1456,14 @@ const a0Dialogues = <String, LessonDialogue>{
'Say your like one more time.',
],
hints: ['I like tea.', 'Yes, I do.', 'Do you like tea?', 'I like tea.'],
translations: ['你喜欢什么?', '你喜欢茶吗?', '现在请问我喜欢什么。', '请再说一次你的喜好。'],
requiredTerms: [
['i like', 'i love'],
['yes', 'no', 'i do', "i don't", 'i do not'],
['do you like', '#question'],
['i like', 'i love'],
],
taskLabels: ['说出你喜欢什么', '回答是否喜欢', '反问对方的喜好', '再说一次你的喜好'],
),
'a0-10': LessonDialogue(
goal: '请求重复或放慢语速,并完成基础沟通',
@@ -1207,6 +1479,19 @@ const a0Dialogues = <String, LessonDialogue>{
'Whats your name?',
'I like tea.',
],
translations: [
'请说:Please say that again(请再说一遍)。',
'请说:Please speak slowly(请说慢一点)。',
'现在请问我的名字或我来自哪里。',
'请说一件你喜欢的事物。',
],
requiredTerms: [
['say that again', 'again', 'pardon'],
['slowly', 'slow'],
["what's your name", 'what is your name', 'where are you from', 'your name', '#question'],
['i like', 'i love'],
],
taskLabels: ['请求对方重复', '请求对方放慢语速', '反问名字或来自哪里', '说出一件你喜欢的事物'],
),
};
@@ -1215,31 +1500,52 @@ const a0SegmentDialogues = <String, LessonDialogue>{
goal: '听辨并说出 0 到 5',
prompts: ['Say zero, one, two.', 'Now say three, four, five.'],
hints: ['zero, one, two', 'three, four, five'],
translations: [
'请说 zero, one, two012)。',
'现在请说 three, four, five345)。',
],
taskLabels: ['说出 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.'],
translations: [
'请说 six, seven, eight678)。',
'这是什么?请说:It is a phone(这是一部手机)。',
],
taskLabels: ['说出 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'],
translations: ['你的电话号码是多少?', '请再说一次三位数字。'],
taskLabels: ['报出你的三位号码', '再说一次三位数字'],
),
'a0-08-a': LessonDialogue(
goal: '询问并说出星期一到三',
prompts: ['What day is it?', 'Say Monday, Tuesday, or Wednesday.'],
hints: ['What day is it?', 'It is Monday.'],
translations: ['今天星期几?', '请说 Monday, Tuesday, 或 Wednesday(周一、周二或周三)。'],
taskLabels: ['问今天星期几', '说出周一到周三中的一天'],
),
'a0-08-b': LessonDialogue(
goal: '说出星期四到日',
prompts: ['What day is it?', 'Say Thursday, Friday, Saturday, or Sunday.'],
hints: ['What day is it?', 'It is Friday.'],
translations: [
'今天星期几?',
'请说 Thursday, Friday, Saturday, 或 Sunday(周四、周五、周六或周日)。',
],
taskLabels: ['问今天星期几', '说出周四到周日中的一天'],
),
'a0-08-c': LessonDialogue(
goal: '询问并说出整点',
prompts: ['What time is it?', 'Say one full time sentence.'],
hints: ['What time is it?', 'It is three oclock.'],
translations: ['现在几点了?', '请说一个完整的时间句子。'],
taskLabels: ['问现在几点', '说一个完整的时间句子'],
),
};
@@ -1247,3 +1553,21 @@ LessonDialogue dialogueByLessonId(String id) => a0Dialogues[id]!;
LessonDialogue dialogueBySegmentId(String segmentId, String lessonId) =>
a0SegmentDialogues[segmentId] ?? dialogueByLessonId(lessonId);
/// The English a learner has actually met up to and including [lessonId].
/// The AI partner is told to stay inside this list so a beginner never gets an
/// answer built from words the course has not taught yet.
List<String> taughtLanguageUpTo(String lessonId) {
final result = <String>[];
for (final entry in a0TargetItemIdsByLesson.entries) {
for (final id in entry.value) {
final word = a0CoreItems[id];
if (word != null) result.add(word);
}
if (entry.key == lessonId) break;
}
return result;
}
/// Everything A0 teaches, for the free scene that mixes all topics.
List<String> get allTaughtLanguage => a0CoreItems.values.toList();
@@ -0,0 +1,248 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sherpa_onnx/sherpa_onnx.dart' as sherpa_onnx;
class SherpaSttService {
SherpaSttService._();
static final instance = SherpaSttService._();
sherpa_onnx.OfflineRecognizer? _recognizer;
bool _isInitialized = false;
Future<bool>? _initFuture;
Future<void> _transcribeLock = Future.value();
bool get isReady => _isInitialized && _recognizer != null;
/// Resolves the filesystem path for the offline SenseVoice model files.
/// First checks direct asset filesystem locations (on macOS app bundle, development, tests)
/// to avoid copying 228MB into user Documents.
/// Falls back to unpacking from rootBundle into the app documents directory (on mobile / Android APK).
Future<Map<String, String>?> _resolveModelFiles() async {
const modelFilename = 'model.int8.onnx';
const tokensFilename = 'tokens.txt';
// 1. Direct filesystem candidate paths
final candidateDirs = <String>[];
// Current working directory (unit test runner / local flutter dev)
candidateDirs.add('assets/models/sense_voice');
candidateDirs.add('kouyu_english/assets/models/sense_voice');
// Inside macOS App bundle
if (Platform.isMacOS) {
try {
final execDir = File(Platform.resolvedExecutable).parent;
final contentsDir = execDir.parent;
candidateDirs.add('${contentsDir.path}/Frameworks/App.framework/Resources/flutter_assets/assets/models/sense_voice');
candidateDirs.add('${contentsDir.path}/Frameworks/App.framework/Versions/A/Resources/flutter_assets/assets/models/sense_voice');
} catch (_) {}
}
for (final dirPath in candidateDirs) {
final mFile = File('$dirPath/$modelFilename');
final tFile = File('$dirPath/$tokensFilename');
if (mFile.existsSync() && tFile.existsSync() && mFile.lengthSync() > 100000000) {
debugPrint('[SherpaSttService] Using bundled SenseVoice model directly at: $dirPath');
return {
'model': mFile.path,
'tokens': tFile.path,
};
}
}
// 2. Unpack bundled asset to app documents directory (e.g. Android APK)
try {
final docDir = await getApplicationDocumentsDirectory();
final modelDir = Directory('${docDir.path}/sense_voice_models');
if (!await modelDir.exists()) {
await modelDir.create(recursive: true);
}
final targetModelFile = File('${modelDir.path}/$modelFilename');
final targetTokensFile = File('${modelDir.path}/$tokensFilename');
final modelValid = targetModelFile.existsSync() && targetModelFile.lengthSync() > 200 * 1024 * 1024;
final tokensValid = targetTokensFile.existsSync() && targetTokensFile.lengthSync() > 100 * 1024;
if (!modelValid) {
debugPrint('[SherpaSttService] Unpacking bundled model to ${targetModelFile.path}...');
final tmpFile = File('${targetModelFile.path}.tmp');
final ByteData data = await rootBundle.load('assets/models/sense_voice/$modelFilename');
final Uint8List bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
await tmpFile.writeAsBytes(bytes, flush: true);
if (await tmpFile.length() > 200 * 1024 * 1024) {
if (await targetModelFile.exists()) await targetModelFile.delete();
await tmpFile.rename(targetModelFile.path);
} else {
throw StateError('Extracted model file is incomplete');
}
}
if (!tokensValid) {
final tmpTokens = File('${targetTokensFile.path}.tmp');
final ByteData data = await rootBundle.load('assets/models/sense_voice/$tokensFilename');
final Uint8List bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
await tmpTokens.writeAsBytes(bytes, flush: true);
if (await targetTokensFile.exists()) await targetTokensFile.delete();
await tmpTokens.rename(targetTokensFile.path);
}
return {
'model': targetModelFile.path,
'tokens': targetTokensFile.path,
};
} catch (e, stack) {
debugPrint('[SherpaSttService] Failed to unpack bundled model: $e\n$stack');
return null;
}
}
/// Initializes SenseVoice-Small ONNX bindings and preloads the recognizer.
/// Deduplicates concurrent callers to wait on the same initialization.
Future<bool> initialize({String? nativeLibDir}) {
if (_isInitialized && _recognizer != null) return Future.value(true);
if (_initFuture != null) return _initFuture!;
_initFuture = _doInitialize(nativeLibDir: nativeLibDir);
return _initFuture!;
}
Future<bool> _doInitialize({String? nativeLibDir}) async {
try {
// Auto-detect native library directory on macOS desktop app bundle
String? resolvedLibDir = nativeLibDir;
if (resolvedLibDir == null && Platform.isMacOS) {
try {
final execDir = File(Platform.resolvedExecutable).parent;
final frameworksDir = Directory('${execDir.parent.path}/Frameworks');
if (frameworksDir.existsSync() && File('${frameworksDir.path}/libsherpa-onnx-c-api.dylib').existsSync()) {
resolvedLibDir = frameworksDir.path;
}
} catch (_) {}
}
try {
sherpa_onnx.initBindings(resolvedLibDir);
} catch (e) {
debugPrint('[SherpaSttService] initBindings notice: $e');
}
final resolved = await _resolveModelFiles();
if (resolved == null) {
debugPrint('[SherpaSttService] Model files could not be located or extracted.');
return false;
}
final modelConfig = sherpa_onnx.OfflineModelConfig(
senseVoice: sherpa_onnx.OfflineSenseVoiceModelConfig(
model: resolved['model']!,
language: 'en',
useInverseTextNormalization: true,
),
tokens: resolved['tokens']!,
numThreads: 2,
debug: false,
);
final recognizerConfig = sherpa_onnx.OfflineRecognizerConfig(
model: modelConfig,
feat: const sherpa_onnx.FeatureConfig(sampleRate: 16000, featureDim: 80),
);
_recognizer = sherpa_onnx.OfflineRecognizer(recognizerConfig);
_isInitialized = true;
debugPrint('[SherpaSttService] SenseVoice-Small ONNX ASR engine initialized successfully (language=en).');
return true;
} catch (e, stack) {
debugPrint('[SherpaSttService] Failed to initialize SenseVoice ASR engine: $e\n$stack');
_isInitialized = false;
return false;
} finally {
if (!_isInitialized) {
_initFuture = null;
}
}
}
/// Transcribes a local 16kHz mono WAV audio file using SenseVoice-Small.
/// Thread-safe: serializes native C++ decoding to prevent concurrency crashes.
Future<String?> transcribeWav(String wavPath) async {
final prevLock = _transcribeLock;
final completer = Completer<void>();
_transcribeLock = completer.future;
try {
await prevLock;
return await _doTranscribeWav(wavPath);
} finally {
completer.complete();
}
}
Future<String?> _doTranscribeWav(String wavPath) async {
try {
if (!_isInitialized) {
final ready = await initialize();
if (!ready || _recognizer == null) return null;
}
final file = File(wavPath);
if (!await file.exists()) {
debugPrint('[SherpaSttService] Audio file does not exist: $wavPath');
return null;
}
final wave = sherpa_onnx.readWave(wavPath);
if (wave.samples.isEmpty) {
debugPrint('[SherpaSttService] Read 0 wave samples from: $wavPath');
return null;
}
final stream = _recognizer!.createStream();
try {
stream.acceptWaveform(samples: wave.samples, sampleRate: wave.sampleRate);
_recognizer!.decode(stream);
final result = _recognizer!.getResult(stream);
final rawText = result.text.trim();
if (rawText.isEmpty) return null;
return _cleanText(rawText);
} finally {
stream.free();
}
} catch (e) {
debugPrint('[SherpaSttService] Transcribe error: $e');
return null;
}
}
/// Cleans and formats raw recognized SenseVoice text.
String _cleanText(String text) {
if (text.isEmpty) return text;
// Strip SenseVoice special tags like <|zh|>, <|en|>, <|NEUTRAL|>, <|HAPPY|>, <|Speech|>, <|withitn|>, <|woitn|>, etc.
var cleaned = text.replaceAll(RegExp(r'<\|[a-zA-Z0-9_\-\s]+\|>'), '').trim();
if (cleaned.isEmpty) return cleaned;
// Strip leading punctuation often inserted by Whisper/SenseVoice
cleaned = cleaned.replaceFirst(RegExp(r'^[\s,.:;!?~]+'), '').trim();
if (cleaned.isEmpty) return cleaned;
// Normalize consecutive spaces
cleaned = cleaned.replaceAll(RegExp(r'\s+'), ' ').trim();
// Capitalize first character if it's an English letter
if (cleaned.isNotEmpty && cleaned[0].toLowerCase() != cleaned[0].toUpperCase()) {
cleaned = cleaned[0].toUpperCase() + cleaned.substring(1);
}
return cleaned;
}
void dispose() {
try {
_recognizer?.free();
} catch (_) {}
_recognizer = null;
_isInitialized = false;
_initFuture = null;
}
}
@@ -0,0 +1,261 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../app_state.dart';
import 'sync_merger.dart';
import 'sync_models.dart';
import 'sync_service.dart';
/// 跨端学习进度同步协调器
class SyncCoordinator extends ChangeNotifier {
static const _prefKey = 'sync_config_v1';
/// 合并逻辑版本。升级后丢弃旧的增量同步时间戳,强制全量拉取一次,
/// 让旧版本已经跳过的课程位置和复习卡片能被重新合并。
static const _mergeVersion = 2;
static const _mergeVersionKey = 'mergeVersion';
static final SyncCoordinator instance = SyncCoordinator._();
SyncCoordinator._({SyncService? service}) : _service = service ?? SyncService();
@visibleForTesting
factory SyncCoordinator.createForTesting({SyncService? service}) {
return SyncCoordinator._(service: service);
}
final SyncService _service;
SyncConfig _config = const SyncConfig();
SyncState _state = SyncState.idle;
String? _errorMessage;
bool _isInitialized = false;
DateTime? _lastSyncAttempt;
SyncConfig get config => _config;
SyncState get state => _state;
String? get errorMessage => _errorMessage;
bool get isInitialized => _isInitialized;
bool get isLoggedIn => _config.isLoggedIn;
String? get username => _config.username;
String get serverUrl => _config.serverUrl;
DateTime? get lastSyncTime => _config.lastSyncTime;
bool get autoSyncEnabled => _config.autoSyncEnabled;
/// 初始化并从本地存储加载配置
Future<void> init() async {
if (_isInitialized) return;
try {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_prefKey);
if (raw != null && raw.isNotEmpty) {
final map = jsonDecode(raw) as Map<String, dynamic>;
_config = SyncConfig.fromJson(map);
if ((map[_mergeVersionKey] as int? ?? 1) < _mergeVersion) {
_config = _withoutLastSyncTime(_config);
}
}
} catch (e) {
debugPrint('[SyncCoordinator] init error: $e');
} finally {
_isInitialized = true;
notifyListeners();
}
}
Future<void> _saveConfig() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_prefKey,
jsonEncode({..._config.toJson(), _mergeVersionKey: _mergeVersion}),
);
} catch (e) {
debugPrint('[SyncCoordinator] save config error: $e');
}
}
static SyncConfig _withoutLastSyncTime(SyncConfig config) => SyncConfig(
serverUrl: config.serverUrl,
token: config.token,
username: config.username,
userId: config.userId,
autoSyncEnabled: config.autoSyncEnabled,
);
/// 更新服务器地址
Future<void> updateServerUrl(String newUrl) async {
_config = _config.copyWith(serverUrl: newUrl.trim());
_errorMessage = null;
await _saveConfig();
notifyListeners();
}
/// 切换自动同步开关
Future<void> setAutoSyncEnabled(bool enabled) async {
_config = _config.copyWith(autoSyncEnabled: enabled);
await _saveConfig();
notifyListeners();
}
/// 注册新用户并自动保存登录凭证
Future<bool> register({
required String serverUrl,
required String username,
required String password,
}) async {
_state = SyncState.syncing;
_errorMessage = null;
notifyListeners();
try {
final auth = await _service.register(
serverUrl: serverUrl,
username: username,
password: password,
);
// 新登录的账号必须全量拉取,不能沿用之前的增量时间戳
_config = _withoutLastSyncTime(_config).copyWith(
serverUrl: serverUrl.trim(),
token: auth.token,
username: auth.username,
userId: auth.userId,
);
_state = SyncState.idle;
await _saveConfig();
notifyListeners();
return true;
} catch (e) {
_state = SyncState.error;
_errorMessage = e is HttpException ? e.message : e.toString();
notifyListeners();
return false;
}
}
/// 登录已有账号
Future<bool> login({
required String serverUrl,
required String username,
required String password,
}) async {
_state = SyncState.syncing;
_errorMessage = null;
notifyListeners();
try {
final auth = await _service.login(
serverUrl: serverUrl,
username: username,
password: password,
);
// 新登录的账号必须全量拉取,不能沿用之前的增量时间戳
_config = _withoutLastSyncTime(_config).copyWith(
serverUrl: serverUrl.trim(),
token: auth.token,
username: auth.username,
userId: auth.userId,
);
_state = SyncState.idle;
await _saveConfig();
notifyListeners();
return true;
} catch (e) {
_state = SyncState.error;
_errorMessage = e is HttpException ? e.message : e.toString();
notifyListeners();
return false;
}
}
/// 登出并清除本地登录凭证
Future<void> logout() async {
_config = SyncConfig(
serverUrl: _config.serverUrl,
autoSyncEnabled: _config.autoSyncEnabled,
);
_state = SyncState.idle;
_errorMessage = null;
await _saveConfig();
notifyListeners();
}
/// 测试与服务器的连接
Future<bool> testConnection([String? customUrl]) async {
return _service.testConnection(customUrl ?? _config.serverUrl);
}
/// 立即触发一次全量/增量双向同步 (Pull -> Merge -> Push)
Future<bool> syncNow(AppState appState) async {
if (!isLoggedIn) {
_errorMessage = '未登录同步账号';
_state = SyncState.error;
notifyListeners();
return false;
}
_state = SyncState.syncing;
_errorMessage = null;
notifyListeners();
try {
final sUrl = _config.serverUrl;
final token = _config.token!;
// 1. 增量拉取云端数据
final pullResp = await _service.pull(
serverUrl: sUrl,
token: token,
since: _config.lastSyncTime,
);
// 2. 本地智能合并 (CRDT/LWW)
final changed = SyncMerger.applyPullResponse(appState, pullResp);
if (changed) {
appState.notifyListeners();
}
// 3. 构建本地增量数据并推送至云端
final pushReq = SyncMerger.buildPushRequest(appState);
final sTime = await _service.push(
serverUrl: sUrl,
token: token,
request: pushReq,
);
// 4. 更新同步时间戳并保存
final syncSuccessTime = DateTime.tryParse(sTime) ?? DateTime.now();
_config = _config.copyWith(lastSyncTime: syncSuccessTime);
_state = SyncState.success;
_errorMessage = null;
await _saveConfig();
notifyListeners();
return true;
} catch (e) {
_state = SyncState.error;
_errorMessage = e is HttpException ? e.message : e.toString();
notifyListeners();
return false;
}
}
/// 满足条件时在后台静默触发同步
void triggerBackgroundSync(AppState appState) {
if (!autoSyncEnabled || !isLoggedIn || _state == SyncState.syncing) {
return;
}
// 简单防抖:距离上次同步尝试不足 3 秒则跳过
final now = DateTime.now();
if (_lastSyncAttempt != null &&
now.difference(_lastSyncAttempt!).inSeconds < 3) {
return;
}
_lastSyncAttempt = now;
// 异步执行,不阻塞主流程
unawaited(syncNow(appState));
}
}
@@ -0,0 +1,214 @@
import '../a0_core.dart';
import '../models.dart';
import '../app_state.dart';
import 'sync_models.dart';
/// 负责本地 AppState 与云端 DTO 之间的序列化与智能合并
class SyncMerger {
/// 将本地 AppState 打包为增量推送请求
static SyncPushRequest buildPushRequest(AppState state, {String? deviceName}) {
final nowIso = DateTime.now().toUtc().toIso8601String();
final progressPayload = SyncProgressPayload(
activeLessonId: state.activeLessonId,
completedLessonIds: state.completedLessonIds.toList(),
completedSegmentIds: state.completedSegmentIds.toList(),
activeStep: state.lessonStep.name,
streakDays: state.completedLessons > 0 ? 1 : 0,
updatedAt: nowIso,
);
final masteryUpdates = state.mastery.values.map((m) {
// 查找对应复习到期时间
final review = state.reviewQueue.where((r) => r.id == m.id).firstOrNull;
final dueAt = (review?.dueAt ??
m.firstTaughtAt?.add(_firstReviewDelay) ??
DateTime.now())
.toUtc()
.toIso8601String();
final attempts = review?.attempts ?? 0;
final successfulReviews = review?.successfulReviews ?? 0;
return SyncMasteryItemPayload(
itemId: m.id,
checkpoint: m.checkpoint,
status: m.status.name,
dueAt: dueAt,
successfulReviews: successfulReviews,
attempts: attempts,
payload: {
'label': m.label,
'evidence': m.evidence.map((e) => e.name).toList(),
'needsReview': m.needsReview,
if (m.firstTaughtAt != null)
'firstTaughtAt': m.firstTaughtAt!.toUtc().toIso8601String(),
},
updatedAt: nowIso,
);
}).toList();
final profilePayload = SyncProfilePayload(
onboardingComplete: state.onboardingComplete,
goal: state.goal.name,
placement: state.placement.name,
dailyMinutes: state.dailyMinutes,
showChineseHints: state.showChineseHints,
aiEndpoint: state.aiEndpoint,
aiModel: state.aiModel,
aiProvider: state.aiProvider.name,
settingsPayload: {
'keepRecordings': state.keepRecordings,
},
updatedAt: nowIso,
);
return SyncPushRequest(
clientTime: nowIso,
deviceName: deviceName,
progress: progressPayload,
masteryUpdates: masteryUpdates,
profile: profilePayload,
);
}
/// 将云端拉取的进度合并到本地 AppState
static bool applyPullResponse(AppState state, SyncPullResponse response) {
var changed = false;
// 1. 合并课程关卡 (Union),学习位置只前进不后退
if (response.progress != null) {
final p = response.progress!;
if (state.mergeSyncedLessonProgress(
completedLessons: p.completedLessonIds,
completedSegments: p.completedSegmentIds,
remoteActiveLessonId: p.activeLessonId,
)) {
changed = true;
}
}
// 2. 合并复习掌握项 (Max Checkpoint;同 Checkpoint 时证据更多者更新)
for (final m in response.masteryUpdates) {
final local = state.mastery[m.itemId];
final status = _parseMasteryStatus(m.status);
final evidence = (m.payload['evidence'] as List? ?? [])
.whereType<String>()
.map(_parseEvidenceKind)
.toList();
final firstTaughtAt = m.payload['firstTaughtAt'] is String
? DateTime.tryParse(m.payload['firstTaughtAt'] as String)
: null;
final needsReview = m.payload['needsReview'] as bool?;
if (local == null) {
// 本地没有,直接添加
state.mastery[m.itemId] = MasteryItem(
id: m.itemId,
label: m.payload['label'] as String? ?? m.itemId,
status: status,
evidence: evidence,
needsReview: needsReview ?? false,
checkpoint: m.checkpoint,
firstTaughtAt: firstTaughtAt,
);
changed = true;
} else if (m.checkpoint > local.checkpoint ||
(m.checkpoint == local.checkpoint &&
evidence.length > local.evidence.length)) {
// 云端进度更新(证据只追加不删除,条数更多说明学得更多)
state.mastery[m.itemId] = local.copyWith(
checkpoint: m.checkpoint,
status: status,
evidence: evidence.length > local.evidence.length ? evidence : null,
needsReview: needsReview ?? local.needsReview,
firstTaughtAt: local.firstTaughtAt ?? firstTaughtAt,
);
changed = true;
} else if (local.firstTaughtAt == null && firstTaughtAt != null) {
state.mastery[m.itemId] = local.copyWith(firstTaughtAt: firstTaughtAt);
changed = true;
}
if (_mergeReviewItem(state, m, firstTaughtAt)) changed = true;
}
// 3. 合并用户偏好设置 (按需合并)
if (response.profile != null) {
final prof = response.profile!;
if (!state.onboardingComplete && prof.onboardingComplete) {
state.onboardingComplete = true;
changed = true;
}
if (prof.goal.isNotEmpty) {
final g = _parseGoal(prof.goal);
if (g != state.goal) {
state.goal = g;
changed = true;
}
}
}
return changed;
}
/// 与 AppState 引入新课目标时的首次复习间隔保持一致
static const _firstReviewDelay = Duration(days: 1);
/// 首页的到期复习数来自 reviewQueue,因此云端掌握项也要还原为复习卡片
static bool _mergeReviewItem(
AppState state,
SyncMasteryItemPayload m,
DateTime? firstTaughtAt,
) {
// 只有在课程里正式引入过、或复习过的项目才会有复习卡片
if (firstTaughtAt == null && m.attempts == 0) return false;
// 从未复习过的卡片到期时间固定为首次学习后一天;旧版本客户端会把
// 缺失卡片的 due_at 写成推送时刻,这里据此纠正
final dueAt = m.attempts == 0 && firstTaughtAt != null
? firstTaughtAt.add(_firstReviewDelay)
: DateTime.tryParse(m.dueAt);
if (dueAt == null) return false;
final index = state.reviewQueue.indexWhere((r) => r.id == m.itemId);
if (index < 0) {
final template = coreReviewTemplate(m.itemId);
state.reviewQueue.add(
ReviewItem(
id: m.itemId,
target: a0CoreItems[m.itemId] ?? m.itemId,
prompt: template.prompt,
hint: template.hint,
dueAt: dueAt.toLocal(),
skill: template.skill,
attempts: m.attempts,
successfulReviews: m.successfulReviews,
),
);
return true;
}
final local = state.reviewQueue[index];
if (m.attempts > local.attempts) {
state.reviewQueue[index] = local.copyWith(
dueAt: dueAt.toLocal(),
attempts: m.attempts,
successfulReviews: m.successfulReviews,
);
return true;
}
return false;
}
static MasteryStatus _parseMasteryStatus(String str) {
return MasteryStatus.values.where((e) => e.name == str).firstOrNull ??
MasteryStatus.newItem;
}
static EvidenceKind _parseEvidenceKind(String str) {
return EvidenceKind.values.where((e) => e.name == str).firstOrNull ??
EvidenceKind.pending;
}
static LearningGoal _parseGoal(String str) {
return LearningGoal.values.where((e) => e.name == str).firstOrNull ??
LearningGoal.dailyLife;
}
}
@@ -0,0 +1,289 @@
/// 同步状态枚举
enum SyncState {
idle,
syncing,
success,
error,
}
/// 客户端同步配置与认证状态
class SyncConfig {
final String serverUrl;
final String? token;
final String? username;
final String? userId;
final DateTime? lastSyncTime;
final bool autoSyncEnabled;
const SyncConfig({
this.serverUrl = 'https://syncenglish.slcydia.fun',
this.token,
this.username,
this.userId,
this.lastSyncTime,
this.autoSyncEnabled = true,
});
bool get isLoggedIn => token != null && token!.isNotEmpty;
SyncConfig copyWith({
String? serverUrl,
String? token,
String? username,
String? userId,
DateTime? lastSyncTime,
bool? autoSyncEnabled,
}) => SyncConfig(
serverUrl: serverUrl ?? this.serverUrl,
token: token ?? this.token,
username: username ?? this.username,
userId: userId ?? this.userId,
lastSyncTime: lastSyncTime ?? this.lastSyncTime,
autoSyncEnabled: autoSyncEnabled ?? this.autoSyncEnabled,
);
Map<String, dynamic> toJson() => {
'serverUrl': serverUrl,
'token': token,
'username': username,
'userId': userId,
'lastSyncTime': lastSyncTime?.toUtc().toIso8601String(),
'autoSyncEnabled': autoSyncEnabled,
};
factory SyncConfig.fromJson(Map<String, dynamic> json) => SyncConfig(
serverUrl: json['serverUrl'] as String? ?? 'https://syncenglish.slcydia.fun',
token: json['token'] as String?,
username: json['username'] as String?,
userId: json['userId'] as String?,
lastSyncTime: json['lastSyncTime'] != null
? DateTime.tryParse(json['lastSyncTime'] as String)
: null,
autoSyncEnabled: json['autoSyncEnabled'] as bool? ?? true,
);
}
/// 认证响应
class SyncAuthResponse {
final String userId;
final String username;
final String token;
final int expiresIn;
const SyncAuthResponse({
required this.userId,
required this.username,
required this.token,
required this.expiresIn,
});
factory SyncAuthResponse.fromJson(Map<String, dynamic> json) => SyncAuthResponse(
userId: json['user_id'] as String? ?? '',
username: json['username'] as String? ?? '',
token: json['token'] as String? ?? '',
expiresIn: json['expires_in'] as int? ?? 0,
);
}
/// 课程关卡进度 DTO
class SyncProgressPayload {
final String activeLessonId;
final List<String> completedLessonIds;
final List<String> completedSegmentIds;
final String activeStep;
final int streakDays;
final String updatedAt;
const SyncProgressPayload({
required this.activeLessonId,
required this.completedLessonIds,
required this.completedSegmentIds,
this.activeStep = 'preview',
this.streakDays = 0,
required this.updatedAt,
});
Map<String, dynamic> toJson() => {
'active_lesson_id': activeLessonId,
'completed_lesson_ids': completedLessonIds,
'completed_segment_ids': completedSegmentIds,
'active_step': activeStep,
'streak_days': streakDays,
'updated_at': updatedAt,
};
factory SyncProgressPayload.fromJson(Map<String, dynamic> json) => SyncProgressPayload(
activeLessonId: json['active_lesson_id'] as String? ?? 'a0-01',
completedLessonIds: (json['completed_lesson_ids'] as List? ?? [])
.map((e) => e.toString())
.toList(),
completedSegmentIds: (json['completed_segment_ids'] as List? ?? [])
.map((e) => e.toString())
.toList(),
activeStep: json['active_step'] as String? ?? 'preview',
streakDays: json['streak_days'] as int? ?? 0,
updatedAt: json['updated_at'] as String? ?? DateTime.now().toUtc().toIso8601String(),
);
}
/// 艾宾浩斯与掌握度 DTO
class SyncMasteryItemPayload {
final String itemId;
final int checkpoint;
final String status;
final String dueAt;
final int successfulReviews;
final int attempts;
final Map<String, dynamic> payload;
final String updatedAt;
const SyncMasteryItemPayload({
required this.itemId,
required this.checkpoint,
required this.status,
required this.dueAt,
this.successfulReviews = 0,
this.attempts = 0,
this.payload = const {},
required this.updatedAt,
});
Map<String, dynamic> toJson() => {
'item_id': itemId,
'checkpoint': checkpoint,
'status': status,
'due_at': dueAt,
'successful_reviews': successfulReviews,
'attempts': attempts,
'payload': payload,
'updated_at': updatedAt,
};
factory SyncMasteryItemPayload.fromJson(Map<String, dynamic> json) => SyncMasteryItemPayload(
itemId: json['item_id'] as String? ?? '',
checkpoint: json['checkpoint'] as int? ?? 0,
status: json['status'] as String? ?? 'learning',
dueAt: json['due_at'] as String? ?? DateTime.now().toUtc().toIso8601String(),
successfulReviews: json['successful_reviews'] as int? ?? 0,
attempts: json['attempts'] as int? ?? 0,
payload: json['payload'] is Map<String, dynamic>
? json['payload'] as Map<String, dynamic>
: {},
updatedAt: json['updated_at'] as String? ?? DateTime.now().toUtc().toIso8601String(),
);
}
/// 用户画像与偏好 DTO
class SyncProfilePayload {
final bool onboardingComplete;
final String goal;
final String placement;
final int dailyMinutes;
final bool showChineseHints;
final String aiEndpoint;
final String aiModel;
final String aiProvider;
final Map<String, dynamic> settingsPayload;
final String updatedAt;
const SyncProfilePayload({
this.onboardingComplete = true,
this.goal = 'travel',
this.placement = 'A0',
this.dailyMinutes = 20,
this.showChineseHints = true,
this.aiEndpoint = '',
this.aiModel = '',
this.aiProvider = '',
this.settingsPayload = const {},
required this.updatedAt,
});
Map<String, dynamic> toJson() => {
'onboarding_complete': onboardingComplete,
'goal': goal,
'placement': placement,
'daily_minutes': dailyMinutes,
'show_chinese_hints': showChineseHints,
'ai_endpoint': aiEndpoint,
'ai_model': aiModel,
'ai_provider': aiProvider,
'settings_payload': settingsPayload,
'updated_at': updatedAt,
};
factory SyncProfilePayload.fromJson(Map<String, dynamic> json) => SyncProfilePayload(
onboardingComplete: json['onboarding_complete'] as bool? ?? true,
goal: json['goal'] as String? ?? 'travel',
placement: json['placement'] as String? ?? 'A0',
dailyMinutes: json['daily_minutes'] as int? ?? 20,
showChineseHints: json['show_chinese_hints'] as bool? ?? true,
aiEndpoint: json['ai_endpoint'] as String? ?? '',
aiModel: json['ai_model'] as String? ?? '',
aiProvider: json['ai_provider'] as String? ?? '',
settingsPayload: json['settings_payload'] is Map<String, dynamic>
? json['settings_payload'] as Map<String, dynamic>
: {},
updatedAt: json['updated_at'] as String? ?? DateTime.now().toUtc().toIso8601String(),
);
}
/// 增量推送请求
class SyncPushRequest {
final String clientTime;
final String? deviceName;
final SyncProgressPayload? progress;
final List<SyncMasteryItemPayload> masteryUpdates;
final SyncProfilePayload? profile;
const SyncPushRequest({
required this.clientTime,
this.deviceName,
this.progress,
this.masteryUpdates = const [],
this.profile,
});
Map<String, dynamic> toJson() => {
'client_time': clientTime,
if (deviceName != null) 'device_name': deviceName,
if (progress != null) 'progress': progress!.toJson(),
'mastery_updates': masteryUpdates.map((m) => m.toJson()).toList(),
if (profile != null) 'profile': profile!.toJson(),
};
}
/// 增量拉取响应
class SyncPullResponse {
final String serverTime;
final SyncProgressPayload? progress;
final List<SyncMasteryItemPayload> masteryUpdates;
final SyncProfilePayload? profile;
const SyncPullResponse({
required this.serverTime,
this.progress,
this.masteryUpdates = const [],
this.profile,
});
factory SyncPullResponse.fromJson(Map<String, dynamic> json) {
final data = json['data'] is Map<String, dynamic>
? json['data'] as Map<String, dynamic>
: json;
return SyncPullResponse(
serverTime: data['server_time'] as String? ?? DateTime.now().toUtc().toIso8601String(),
progress: data['progress'] != null
? SyncProgressPayload.fromJson(data['progress'] as Map<String, dynamic>)
: null,
masteryUpdates: (data['mastery_updates'] as List? ?? [])
.whereType<Map<String, dynamic>>()
.map((e) => SyncMasteryItemPayload.fromJson(e))
.toList(),
profile: data['profile'] != null
? SyncProfilePayload.fromJson(data['profile'] as Map<String, dynamic>)
: null,
);
}
}
@@ -0,0 +1,155 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'sync_models.dart';
/// 负责与自建同步服务端通信的 HTTP 服务
class SyncService {
final http.Client _client;
SyncService({http.Client? client}) : _client = client ?? http.Client();
String _cleanUrl(String url) {
var u = url.trim();
if (u.endsWith('/')) {
u = u.substring(0, u.length - 1);
}
if (!u.startsWith('http://') && !u.startsWith('https://')) {
u = 'http://$u';
}
return u;
}
/// 测试与服务器的连通性
Future<bool> testConnection(String serverUrl) async {
try {
final base = _cleanUrl(serverUrl);
final uri = Uri.parse('$base/api/v1/health');
final resp = await _client.get(uri).timeout(const Duration(seconds: 5));
return resp.statusCode == 200;
} catch (_) {
return false;
}
}
/// 注册新用户
Future<SyncAuthResponse> register({
required String serverUrl,
required String username,
required String password,
String? deviceName,
}) async {
final base = _cleanUrl(serverUrl);
final uri = Uri.parse('$base/api/v1/auth/register');
final resp = await _client.post(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'username': username.trim(),
'password': password,
'device_name': deviceName ?? _getPlatformDeviceName(),
}),
).timeout(const Duration(seconds: 10));
final data = jsonDecode(utf8.decode(resp.bodyBytes));
if (resp.statusCode == 200 && data['code'] == 0) {
return SyncAuthResponse.fromJson(data['data'] as Map<String, dynamic>);
} else {
throw HttpException(data['detail'] ?? data['message'] ?? '注册失败: HTTP ${resp.statusCode}');
}
}
/// 登录已有用户
Future<SyncAuthResponse> login({
required String serverUrl,
required String username,
required String password,
String? deviceName,
}) async {
final base = _cleanUrl(serverUrl);
final uri = Uri.parse('$base/api/v1/auth/login');
final resp = await _client.post(
uri,
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'username': username.trim(),
'password': password,
'device_name': deviceName ?? _getPlatformDeviceName(),
}),
).timeout(const Duration(seconds: 10));
final data = jsonDecode(utf8.decode(resp.bodyBytes));
if (resp.statusCode == 200 && data['code'] == 0) {
return SyncAuthResponse.fromJson(data['data'] as Map<String, dynamic>);
} else {
throw HttpException(data['detail'] ?? data['message'] ?? '登录失败: HTTP ${resp.statusCode}');
}
}
/// 增量拉取云端学习进度
Future<SyncPullResponse> pull({
required String serverUrl,
required String token,
DateTime? since,
}) async {
final base = _cleanUrl(serverUrl);
var urlStr = '$base/api/v1/sync/pull';
if (since != null) {
urlStr += '?since=${Uri.encodeQueryComponent(since.toUtc().toIso8601String())}';
}
final uri = Uri.parse(urlStr);
final resp = await _client.get(
uri,
headers: {
'Authorization': 'Bearer $token',
'Accept': 'application/json',
},
).timeout(const Duration(seconds: 15));
final data = jsonDecode(utf8.decode(resp.bodyBytes));
if (resp.statusCode == 200 && data['code'] == 0) {
return SyncPullResponse.fromJson(data);
} else {
throw HttpException(data['detail'] ?? data['message'] ?? '拉取进度失败: HTTP ${resp.statusCode}');
}
}
/// 推送本地增量进度到云端
Future<String> push({
required String serverUrl,
required String token,
required SyncPushRequest request,
}) async {
final base = _cleanUrl(serverUrl);
final uri = Uri.parse('$base/api/v1/sync/push');
final resp = await _client.post(
uri,
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
body: jsonEncode(request.toJson()),
).timeout(const Duration(seconds: 15));
final data = jsonDecode(utf8.decode(resp.bodyBytes));
if (resp.statusCode == 200 && data['code'] == 0) {
final sTime = data['data']?['server_time'] as String?;
return sTime ?? DateTime.now().toUtc().toIso8601String();
} else {
throw HttpException(data['detail'] ?? data['message'] ?? '推送进度失败: HTTP ${resp.statusCode}');
}
}
String _getPlatformDeviceName() {
if (Platform.isAndroid) return 'Android 客户端';
if (Platform.isIOS) return 'iPhone 客户端';
if (Platform.isMacOS) return 'macOS 桌面端';
if (Platform.isWindows) return 'Windows 桌面端';
if (Platform.isLinux) return 'Linux 客户端';
return 'SpeakSprout Client';
}
}
+151 -21
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'dart:io';
import 'package:audioplayers/audioplayers.dart';
@@ -16,38 +18,123 @@ class VoiceService {
final SpeechToText _stt = SpeechToText();
final AudioRecorder _recorder = AudioRecorder();
final AudioPlayer _player = AudioPlayer();
StreamSubscription<void>? _playerSub;
bool _speechReady = false;
bool _ttsInitialized = false;
Future<void> speak(String text, {bool slow = false}) async {
await _tts.stop();
await _tts.setLanguage('en-US');
await _tts.setSpeechRate(slow ? 0.35 : 0.48);
await _tts.speak(text);
void Function(String status)? _statusListener;
void Function(String error)? _errorListener;
Future<void> _initTts() async {
if (_ttsInitialized) return;
try {
if (Platform.isIOS) {
await _tts.setIosAudioCategory(
IosTextToSpeechAudioCategory.playback,
[
IosTextToSpeechAudioCategoryOptions.allowBluetooth,
IosTextToSpeechAudioCategoryOptions.allowBluetoothA2DP,
IosTextToSpeechAudioCategoryOptions.mixWithOthers,
],
);
}
await _tts.setVolume(1.0);
await _tts.setPitch(1.0);
_ttsInitialized = true;
} catch (_) {}
}
Future<void> stopSpeaking() => _tts.stop();
Future<void> speak(String text, {bool slow = false}) async {
try {
await stopRecordingPlayback();
await _initTts();
await _tts.stop();
try {
final isAvailable = await _tts.isLanguageAvailable('en-US');
if (isAvailable == true) {
await _tts.setLanguage('en-US');
} else {
await _tts.setLanguage('en');
}
} catch (_) {
try {
await _tts.setLanguage('en-US');
} catch (_) {}
}
await _tts.setSpeechRate(slow ? 0.35 : 0.48);
await _tts.speak(text);
} catch (_) {}
}
Future<void> stopSpeaking() async {
try {
await _tts.stop();
} catch (_) {}
}
Future<bool> hasRecordPermission() => _recorder.hasPermission();
Future<bool> isRecording() async {
try {
return await _recorder.isRecording();
} catch (_) {
return false;
}
}
Future<bool> startRecording() async {
await stopSpeaking();
await stopRecordingPlayback();
try {
if (await _recorder.isRecording()) {
await _recorder.stop();
}
} catch (_) {}
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',
const RecordConfig(
encoder: AudioEncoder.wav,
sampleRate: 16000,
numChannels: 1,
noiseSuppress: true,
echoCancel: true,
autoGain: true,
),
path: '${recordings.path}/practice_$timestamp.wav',
);
return true;
}
Future<String?> stopRecording() => _recorder.stop();
Future<String?> stopRecording() async {
try {
if (await _recorder.isRecording()) {
return await _recorder.stop();
}
} catch (_) {}
return null;
}
Future<void> playRecording(String path) async {
await _player.stop();
Future<void> playRecording(String path, {VoidCallback? onComplete}) async {
await stopRecordingPlayback();
if (onComplete != null) {
_playerSub = _player.onPlayerComplete.listen((_) {
_playerSub?.cancel();
_playerSub = null;
onComplete();
});
}
await _player.play(DeviceFileSource(path));
}
Future<void> stopRecordingPlayback() => _player.stop();
Future<void> stopRecordingPlayback() async {
await _playerSub?.cancel();
_playerSub = null;
await _player.stop();
}
Future<void> deleteRecording(String? path) async {
if (path == null || path.isEmpty) return;
@@ -75,7 +162,7 @@ class VoiceService {
if (!await recordings.exists()) return const [];
final files = await recordings
.list()
.where((item) => item is File && item.path.endsWith('.m4a'))
.where((item) => item is File && (item.path.endsWith('.wav') || item.path.endsWith('.m4a')))
.cast<File>()
.toList();
files.sort((left, right) => right.path.compareTo(left.path));
@@ -87,27 +174,70 @@ class VoiceService {
await _player.dispose();
}
Future<bool> initializeSpeech() async {
_speechReady = await _stt.initialize();
Future<bool> initializeSpeech({
void Function(String status)? onStatus,
void Function(String error)? onError,
}) async {
_statusListener = onStatus;
_errorListener = onError;
try {
_speechReady = await _stt.initialize(
onError: (val) {
_errorListener?.call(val.errorMsg);
},
onStatus: (val) {
_statusListener?.call(val);
},
debugLogging: false,
);
return _speechReady;
} catch (_) {
_speechReady = false;
return false;
}
}
Future<bool> startListening(
void Function(String text, bool finalResult) onResult,
) async {
if (!_speechReady && !await initializeSpeech()) {
return false;
void Function(String text, bool finalResult) onResult, {
void Function(String status)? onStatus,
void Function(String error)? onError,
}) async {
_statusListener = onStatus;
_errorListener = onError;
try {
if (!_speechReady || !_stt.isAvailable) {
final ready = await initializeSpeech(onStatus: onStatus, onError: onError);
if (!ready) return false;
}
String? targetLocaleId = 'en_US';
try {
final locales = await _stt.locales();
if (locales.isNotEmpty) {
final enLocale = locales.firstWhere(
(l) => l.localeId.toLowerCase().startsWith('en'),
orElse: () => locales.first,
);
targetLocaleId = enLocale.localeId;
}
} catch (_) {}
await _stt.listen(
onResult: (result) =>
onResult(result.recognizedWords, result.finalResult),
listenOptions: SpeechListenOptions(
localeId: 'en_US',
localeId: targetLocaleId,
listenFor: const Duration(seconds: 30),
pauseFor: const Duration(seconds: 4),
partialResults: true,
cancelOnError: false,
),
);
return true;
return _stt.isListening;
} catch (e) {
if (onError != null) onError(e.toString());
return false;
}
}
Future<void> stopListening() => _stt.stop();
+11 -4
View File
@@ -11,18 +11,25 @@ class WritingCheckResult {
class WritingFeedback {
const WritingFeedback._();
static String _normalize(String input) => input
.toLowerCase()
.replaceAll('', "'")
.replaceAll('', "'")
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
static WritingCheckResult check(
String lessonId,
String input, {
String? segmentId,
}) {
final text = input.toLowerCase().replaceAll('', "'");
final text = _normalize(input);
final words = RegExp(
r"[a-z]+(?:'[a-z]+)?",
).allMatches(text).map((match) => match.group(0)!).toSet();
bool has(String word) => words.contains(word);
bool has(String word) => words.contains(_normalize(word));
bool hasPhrase(String phrase) => text.contains(phrase);
bool hasPhrase(String phrase) => text.contains(_normalize(phrase));
bool hasAny(Iterable<String> choices) => choices.any(has);
final hasIntroduction =
@@ -40,7 +47,7 @@ class WritingFeedback {
'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")),
'a0-08-c' => hasItIs && (has('oclock') || hasPhrase("o'clock")),
_ => switch (lessonId) {
'a0-01' => hasAny(['hello', 'hi']) && hasIntroduction,
'a0-02' =>
@@ -6,6 +6,7 @@ import '../../core/assessment_bank.dart';
import '../../core/models.dart';
import '../../core/voice_service.dart';
import '../../widgets/app_widgets.dart';
import '../../widgets/voice_answer.dart';
class AssessmentPreparationPage extends StatefulWidget {
const AssessmentPreparationPage({
@@ -32,10 +33,11 @@ class _AssessmentPreparationPageState extends State<AssessmentPreparationPage> {
Future<void> _checkMicrophone() async {
setState(() => checkingMicrophone = true);
final ready = await VoiceService.instance.initializeSpeech();
final sttReady = await VoiceService.instance.initializeSpeech();
final recReady = await VoiceService.instance.hasRecordPermission();
if (!mounted) return;
setState(() {
microphoneReady = ready;
microphoneReady = sttReady || recReady;
checkingMicrophone = false;
});
}
@@ -46,7 +48,14 @@ class _AssessmentPreparationPageState extends State<AssessmentPreparationPage> {
final canResume = draft != null && draft.packId == widget.pack.id;
final pack = widget.pack;
return AppPage(
appBar: AppBar(title: const Text('评估准备')),
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: widget.onBack,
),
title: const Text('评估准备'),
),
child: SpacedColumn(
children: [
Eyebrow('A0 阶段评估 · ${pack.id}'),
@@ -120,14 +129,14 @@ class AssessmentPage extends StatefulWidget {
State<AssessmentPage> createState() => _AssessmentPageState();
}
class _AssessmentPageState extends State<AssessmentPage> {
class _AssessmentPageState extends State<AssessmentPage>
with VoiceAnswerMixin<AssessmentPage> {
final controller = TextEditingController();
final Map<String, bool> results = {};
int index = 0;
bool usedMic = false;
bool transcriptEdited = false;
String lastTranscript = '';
bool listening = false;
bool audioPlayed = false;
bool speakingUnavailable = false;
AssessmentRecord? completedRecord;
@@ -147,40 +156,45 @@ class _AssessmentPageState extends State<AssessmentPage> {
@override
void dispose() {
VoiceService.instance.stopSpeaking();
VoiceService.instance.stopListening();
disposeVoiceAnswer(keepRecording: true);
controller.dispose();
super.dispose();
}
Future<void> _play() async {
try {
await VoiceService.instance.speak(task.audio!);
} catch (_) {}
if (mounted) setState(() => audioPlayed = true);
}
@override
AppState get voiceState => widget.state;
Future<void> _mic() async {
if (listening) {
await VoiceService.instance.stopListening();
if (mounted) setState(() => listening = false);
return;
}
final ready = await VoiceService.instance.startListening((text, _) {
if (mounted) {
setState(() {
if (aiVoiceRecording) {
await finishVoiceInput(
keepAudio: false,
onTranscript: (text) {
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('语音识别不可用;口语可稍后补测,不会判为语言错误。')),
transcriptEdited = false;
speakingUnavailable = false;
},
);
return;
}
final recordStarted = await startVoiceInput(
unavailableMessage: '无法访问麦克风,口语可稍后补测。',
);
if (!mounted) return;
setState(() => speakingUnavailable = !recordStarted);
if (recordStarted) {
showVoiceMessage('已启动麦克风录音,回答后再次点击,将自动转写为英文。');
}
}
@@ -285,6 +299,14 @@ class _AssessmentPageState extends State<AssessmentPage> {
final record = completedRecord;
if (record != null) {
return AppPage(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: widget.onFinished,
),
title: Text(record.passed ? "阶段评估通过" : "阶段评估结果"),
),
child: SpacedColumn(
children: [
const Eyebrow('评估结果已保存'),
@@ -325,6 +347,11 @@ class _AssessmentPageState extends State<AssessmentPage> {
}
return AppPage(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "退出评估",
onPressed: widget.onFinished,
),
title: Text(
'A0 评估 ${widget.pack.id} · ${index + 1}/${widget.pack.tasks.length}',
),
@@ -344,7 +371,12 @@ class _AssessmentPageState extends State<AssessmentPage> {
const Text('请根据听到的内容选择答案。'),
for (var i = 0; i < task.choices.length; i++)
SectionCard(
onTap: audioPlayed ? () => _submit(i) : null,
onTap: () {
if (!audioPlayed) {
_play();
}
_submit(i);
},
child: Text(task.choices[i]),
),
] else if (task.skill == AssessmentSkill.reading) ...[
@@ -373,6 +405,7 @@ class _AssessmentPageState extends State<AssessmentPage> {
}
}),
minLines: 2,
maxLines: 4,
decoration: InputDecoration(
hintText: task.skill == AssessmentSkill.speaking
? '使用麦克风说出答案;文字仅作待评估记录'
@@ -382,8 +415,10 @@ class _AssessmentPageState extends State<AssessmentPage> {
),
if (task.skill == AssessmentSkill.speaking)
SecondaryButton(
label: listening ? '停止录音' : '使用麦克风回答',
onPressed: _mic,
label: transcribing
? '正在 AI 识别…'
: (listening ? '停止录音并识别' : '使用麦克风回答'),
onPressed: transcribing ? null : _mic,
),
if (task.skill == AssessmentSkill.speaking && speakingUnavailable)
SecondaryButton(
@@ -8,19 +8,31 @@ import '../../core/seed_courses.dart';
import '../../core/voice_service.dart';
import '../../widgets/app_widgets.dart';
import '../../widgets/lexicon_lookup.dart';
import '../../widgets/voice_answer.dart';
class DialogueScenePage extends StatelessWidget {
const DialogueScenePage({super.key, required this.onStart});
const DialogueScenePage({super.key, required this.onStart, this.onBack});
final VoidCallback onStart;
final VoidCallback? onBack;
@override
Widget build(BuildContext context) => AppPage(
appBar: onBack != null
? AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: onBack,
),
title: const Text("AI 情境对话"),
)
: null,
child: SpacedColumn(
children: [
const Eyebrow('按当前水平推荐'),
Text('选一个场景,开口练习。', style: Theme.of(context).textTheme.headlineMedium),
Text(
'每次不超过 5 个回答,完成明确任务后结束。',
'轮 4 次回答,完成明确任务后结束。',
style: Theme.of(context).textTheme.bodyMedium,
),
SectionCard(
@@ -45,8 +57,8 @@ class DialogueScenePage extends StatelessWidget {
],
),
),
const _LockedScene(title: '认识新同学', note: '完成当前场景后解锁'),
const _LockedScene(title: '咖啡店', note: 'A1 · 尚未解锁'),
const _LockedScene(title: '认识新同学', note: 'A0 · 后续版本开放'),
const _LockedScene(title: '咖啡店', note: 'A1 · 后续版本开放'),
],
),
);
@@ -90,22 +102,38 @@ class DialoguePage extends StatefulWidget {
State<DialoguePage> createState() => _DialoguePageState();
}
class _DialoguePageState extends State<DialoguePage> {
class _DialoguePageState extends State<DialoguePage>
with VoiceAnswerMixin<DialoguePage> {
final controller = TextEditingController();
final ScrollController _scrollController = ScrollController();
final List<DialogueTurn> turns = [];
int stage = 0;
bool usedHelp = false;
String? hint;
bool listening = false;
bool recording = false;
bool playingRecording = false;
bool usedVoice = false;
bool transcriptEdited = false;
String lastTranscript = '';
String? recordingPath;
bool waitingForReply = false;
String? validationError;
/// Shown when the reply on screen came from the built-in script instead of
/// the AI, so a canned line is never mistaken for a real answer.
String? aiNotice;
/// The AI's short Chinese comment on the learner's English. It is kept until
/// the dialogue ends: the spec forbids interrupting a beginner turn by turn.
String? latestFeedback;
/// The free scene stores its draft under its own id.
static const _sceneDraftId = 'scene-a0-meet';
/// Closing line for the turn after the last scripted prompt. It stays inside
/// taught A0 language instead of the old "Wonderful — nice meeting you!".
static const _closingLine = 'Bye! Nice to meet you.';
static const _closingTranslation = '再见!很高兴认识你。';
final Set<int> _shownTranslations = <int>{};
LessonDialogue get script => widget.isLessonDialogue
? dialogueBySegmentId(
lessonById(widget.state.activeLessonId)
@@ -115,67 +143,146 @@ class _DialoguePageState extends State<DialoguePage> {
.id,
widget.state.activeLessonId,
)
: const LessonDialogue(
goal: '姓名、地点、状态或喜好,并反问',
prompts: prompts,
hints: hints,
);
: a0MeetDialogue;
static const prompts = [
'Hi! My name is Mia. Whats your name?',
'Nice to meet you. Where are you from?',
'Great! How are you today? Or what do you like?',
'Im good, thanks. Now ask me one question!',
];
static const hints = [
'My name is Alex.',
'Im from Hong Kong.',
'Im good, thanks. / I like coffee.',
'Whats your name? / Where are you from?',
];
static const translations = [
'嗨!我叫 Mia。你叫什么名字?',
'很高兴认识你。你来自哪里?',
'很好!你今天怎么样?或者你喜欢什么?',
'我很好,谢谢。现在请问我一个问题!',
];
/// Only exact sentence matches are trusted. The old positional fallback
/// attached `script.translations[stage]` to whatever the AI happened to say,
/// which produced Chinese that did not match the English on screen.
String? _resolveTranslationFor(String text) {
final cleanText = text.trim();
// 1. Check current script prompts
for (var i = 0; i < script.prompts.length; i++) {
if (script.prompts[i].trim().toLowerCase() == cleanText.toLowerCase()) {
if (i < script.translations.length) {
return script.translations[i];
}
}
}
// 2. Check the free scene script
for (var i = 0; i < a0MeetDialogue.prompts.length; i++) {
if (a0MeetDialogue.prompts[i].trim().toLowerCase() ==
cleanText.toLowerCase()) {
if (i < a0MeetDialogue.translations.length) {
return a0MeetDialogue.translations[i];
}
}
}
// 3. Check all lesson dialogues
for (final dialogue in a0Dialogues.values) {
for (var i = 0; i < dialogue.prompts.length; i++) {
if (dialogue.prompts[i].trim().toLowerCase() ==
cleanText.toLowerCase()) {
if (i < dialogue.translations.length) {
return dialogue.translations[i];
}
}
}
}
// 4. Check all segment dialogues
for (final dialogue in a0SegmentDialogues.values) {
for (var i = 0; i < dialogue.prompts.length; i++) {
if (dialogue.prompts[i].trim().toLowerCase() ==
cleanText.toLowerCase()) {
if (i < dialogue.translations.length) {
return dialogue.translations[i];
}
}
}
}
// 5. Common fallback phrases
if (cleanText == _closingLine) return _closingTranslation;
if (cleanText.toLowerCase().contains("wonderful") &&
cleanText.toLowerCase().contains("nice meeting you")) {
return "太棒了 — 很高兴认识你!";
}
if (cleanText.toLowerCase().contains("goodbye") ||
cleanText.toLowerCase().contains("bye")) {
return "再见!";
}
return null;
}
@override
void initState() {
super.initState();
final draft = widget.state.dialogueDraft;
final draft = widget.isLessonDialogue
? widget.state.dialogueDraft
: widget.state.sceneDialogueDraft;
final expectedDraftId = widget.isLessonDialogue
? widget.state.activeLessonId
: _sceneDraftId;
final canRestore =
widget.isLessonDialogue &&
draft?.lessonId == widget.state.activeLessonId &&
draft!.stage >= 0 &&
draft != null &&
draft.lessonId == expectedDraftId &&
draft.stage >= 0 &&
draft.stage <= script.prompts.length &&
draft.turns.isNotEmpty;
if (canRestore) {
stage = draft.stage;
usedHelp = draft.usedHelp;
turns.addAll(draft.turns);
for (var i = 0; i < draft.turns.length; i++) {
final t = draft.turns[i];
if (!t.isLearner && (t.translation == null || t.translation!.isEmpty)) {
final trans = _resolveTranslationFor(t.text);
turns.add(t.copyWith(translation: trans));
} else {
turns.add(DialogueTurn(text: script.prompts.first, isLearner: false));
turns.add(t);
}
}
} else {
final initialPrompt = script.prompts.first;
final initialTranslation =
script.translations.firstOrNull ??
_resolveTranslationFor(initialPrompt);
turns.add(
DialogueTurn(
text: initialPrompt,
isLearner: false,
translation: initialTranslation,
),
);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_playLatestAi(slow: false);
_scrollToBottom();
}
});
}
@override
void dispose() {
VoiceService.instance.stopRecordingPlayback();
if (!widget.state.keepRecordings) {
VoiceService.instance.deleteRecording(recordingPath);
}
VoiceService.instance.stopSpeaking();
VoiceService.instance.stopListening();
disposeVoiceAnswer(keepRecording: widget.state.keepRecordings);
_scrollController.dispose();
controller.dispose();
super.dispose();
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
Future<void> send() async {
final text = controller.text.trim();
if (text.isEmpty || stage >= script.prompts.length || waitingForReply) {
return;
}
if (!_matchesCurrentTask(text)) {
setState(() => validationError = '这句还没有完成当前任务。可以查看提示后补充一次。');
setState(
() => validationError =
'这一轮要“${_currentTaskLabel()}”,这句还没做到。'
'可以点“提示”看示范,再补充一次。',
);
return;
}
widget.state.recordDialogueAttempt(
@@ -200,13 +307,20 @@ class _DialoguePageState extends State<DialoguePage> {
validationError = null;
});
_saveDraft();
_scrollToBottom();
final aiResponse = await AiService.instance.dialogueReply(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
requiredTask: nextStage < script.prompts.length
aiGoal: nextStage < script.prompts.length
? script.prompts[nextStage]
: 'Say goodbye warmly after the learner asked a question.',
: 'Say goodbye warmly and end the conversation.',
learnerTask: nextStage < script.hints.length
? 'answer with something like "${script.hints[nextStage]}"'
: 'nothing more, the conversation is finished',
allowedLanguage: widget.isLessonDialogue
? taughtLanguageUpTo(widget.state.activeLessonId)
: allTaughtLanguage,
history: turns
.map(
(turn) => <String, String>{
@@ -217,97 +331,66 @@ class _DialoguePageState extends State<DialoguePage> {
.toList(),
);
if (!mounted) return;
setState(() {
turns.add(
DialogueTurn(
text:
final replyText =
aiResponse?.reply ??
(nextStage < script.prompts.length
? script.prompts[nextStage]
: 'Wonderful — nice meeting you!'),
: _closingLine);
var replyTranslation = aiResponse?.translation;
if (replyTranslation == null || replyTranslation.isEmpty) {
replyTranslation = _resolveTranslationFor(replyText);
}
setState(() {
turns.add(
DialogueTurn(
text: replyText,
isLearner: false,
translation: replyTranslation,
),
);
waitingForReply = false;
final feedback = aiResponse?.feedback;
if (feedback != null && feedback.trim().isNotEmpty) {
latestFeedback = feedback.trim();
}
aiNotice = aiResponse != null
? null
: (widget.state.aiProvider == AiProviderType.mock
? '当前未连接 AI,正在按示范脚本对话。'
: 'AI 暂时无法连接,这一句来自示范脚本。');
});
_saveDraft();
_scrollToBottom();
VoiceService.instance.speak(replyText);
}
void _saveDraft() {
if (!widget.isLessonDialogue) return;
widget.state.saveDialogueDraft(
DialogueDraft(
lessonId: widget.state.activeLessonId,
final draft = DialogueDraft(
lessonId: widget.isLessonDialogue
? widget.state.activeLessonId
: _sceneDraftId,
stage: stage,
turns: List.unmodifiable(turns),
usedHelp: usedHelp,
),
);
if (widget.isLessonDialogue) {
widget.state.saveDialogueDraft(draft);
} else {
widget.state.saveSceneDialogueDraft(draft);
}
}
String _currentTaskLabel() => dialogueTaskLabel(script, stage);
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),
};
// Every dialogue now checks the language the turn is teaching. The old
// whole-lesson branch matched bare keywords such as 'it' anywhere in the
// sentence, so an off-task answer passed every stage.
if (widget.isLessonDialogue &&
lessonById(widget.state.activeLessonId).segments.length > 1) {
return matchesSegmentDialogue(_lessonSegmentId, stage, response);
}
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);
return matchesDialogueStage(script, stage, response);
}
String get _lessonSegmentId => lessonById(widget.state.activeLessonId)
@@ -321,6 +404,7 @@ class _DialoguePageState extends State<DialoguePage> {
widget.onFinished(null);
return;
}
widget.state.clearSceneDialogueDraft();
final learnerTurns = turns.where((turn) => turn.isLearner).toList();
final personalSentence = learnerTurns.isEmpty
? 'My name is …'
@@ -328,13 +412,119 @@ class _DialoguePageState extends State<DialoguePage> {
widget.state.addDialogueRecap(personalSentence);
widget.onFinished(
DialogueSummaryData(
completedTasks: const ['介绍姓名', '说明来自哪里', '表达状态或喜好', '反问对方'],
// Only the turns the learner actually passed are reported.
completedTasks: _completedTaskLabels(),
personalSentence: personalSentence,
usedHelp: usedHelp,
improvement: latestFeedback,
),
);
}
/// A learner turn is only added after it passes [_matchesCurrentTask], so the
/// number of learner turns is the number of tasks actually completed.
List<String> _completedTaskLabels() {
final done = turns.where((turn) => turn.isLearner).length;
return [
for (var i = 0; i < done && i < script.taskLabels.length; i++)
script.taskLabels[i],
];
}
Future<void> _toggleTurnTranslation(int index) async {
if (index < 0 || index >= turns.length) return;
final turn = turns[index];
if (turn.isLearner) return;
if (_shownTranslations.contains(index)) {
setState(() {
_shownTranslations.remove(index);
});
return;
}
String? trans = turn.translation;
if (trans == null || trans.isEmpty) {
trans = _resolveTranslationFor(turn.text);
}
if (trans != null && trans.isNotEmpty) {
setState(() {
turns[index] = turn.copyWith(translation: trans);
_shownTranslations.add(index);
});
_saveDraft();
_scrollToBottom();
return;
}
setState(() {
_shownTranslations.add(index);
turns[index] = turn.copyWith(translation: "正在翻译…");
});
final fetched = await AiService.instance.temporaryDefinition(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
text: turn.text,
);
if (!mounted) return;
final finalTrans = (fetched != null && fetched.isNotEmpty)
? fetched
: "暂无该句中文翻译";
setState(() {
turns[index] = turn.copyWith(translation: finalTrans);
});
_saveDraft();
_scrollToBottom();
}
Future<void> _showLatestAiTranslation() async {
final latestAiIndex = turns.lastIndexWhere((turn) => !turn.isLearner);
if (latestAiIndex == -1) return;
final latestAi = turns[latestAiIndex];
String? trans = latestAi.translation;
if (trans == null || trans.isEmpty) {
trans = _resolveTranslationFor(latestAi.text);
}
if (trans != null && trans.isNotEmpty) {
setState(() {
usedHelp = true;
hint = "对方说:$trans";
_shownTranslations.add(latestAiIndex);
turns[latestAiIndex] = latestAi.copyWith(translation: trans);
});
_saveDraft();
_scrollToBottom();
return;
}
setState(() {
usedHelp = true;
hint = "正在获取对方英文翻译…";
_shownTranslations.add(latestAiIndex);
});
final fetched = await AiService.instance.temporaryDefinition(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
text: latestAi.text,
);
if (!mounted) return;
final finalTrans = (fetched != null && fetched.isNotEmpty)
? fetched
: "暂无该句中文翻译";
setState(() {
hint = "对方说:$finalTrans";
turns[latestAiIndex] = latestAi.copyWith(translation: finalTrans);
});
_saveDraft();
_scrollToBottom();
}
Future<void> _playLatestAi({required bool slow}) async {
final latest = turns.where((turn) => !turn.isLearner).lastOrNull;
if (latest == null) return;
@@ -344,66 +534,35 @@ class _DialoguePageState extends State<DialoguePage> {
_saveDraft();
}
@override
AppState get voiceState => widget.state;
Future<void> _toggleListening() async {
if (listening) {
await VoiceService.instance.stopListening();
if (mounted) setState(() => listening = false);
return;
}
final available = await VoiceService.instance.startListening((text, _) {
if (!mounted) return;
setState(() {
if (aiVoiceRecording) {
await finishVoiceInput(
onTranscript: (text) {
controller.text = text;
usedVoice = true;
lastTranscript = text;
transcriptEdited = false;
});
});
if (!mounted) return;
setState(() => listening = available);
if (!available) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('语音识别不可用;你仍可使用文字输入。')));
}
}
Future<void> _toggleRecording() async {
if (recording) {
final path = await VoiceService.instance.stopRecording();
if (mounted) {
setState(() {
recording = false;
recordingPath = path;
});
}
},
afterTranscribe: _scrollToBottom,
);
return;
}
await VoiceService.instance.deleteRecording(recordingPath);
final ready = await VoiceService.instance.startRecording();
if (!mounted) return;
await startVoiceInput();
}
void _showHint() {
setState(() {
recording = ready;
if (ready) recordingPath = null;
usedHelp = true;
final hintIdx = stage < script.hints.length
? stage
: (script.hints.isNotEmpty ? script.hints.length - 1 : 0);
hint = script.hints.isNotEmpty ? script.hints[hintIdx] : null;
});
if (!ready) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('无法使用麦克风录音;请检查系统权限。')));
}
}
Future<void> _playRecording() async {
final path = recordingPath;
if (path == null) return;
setState(() => playingRecording = true);
await VoiceService.instance.playRecording(path);
if (mounted) setState(() => playingRecording = false);
}
Future<void> _deleteRecording() async {
await VoiceService.instance.deleteRecording(recordingPath);
if (mounted) setState(() => recordingPath = null);
_saveDraft();
_scrollToBottom();
}
void _showWord() => showLexiconLookup(
@@ -415,10 +574,17 @@ class _DialoguePageState extends State<DialoguePage> {
@override
Widget build(BuildContext context) {
final finished = stage == script.prompts.length;
final totalStages = script.prompts.length;
return AppPage(
scrollController: _scrollController,
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: () => widget.onFinished(null),
),
title: Text(
'${widget.isLessonDialogue ? '课程对话' : '初次见面'} · ${finished ? 4 : stage + 1} / 4',
'${widget.isLessonDialogue ? '课程对话' : '初次见面'} · ${finished ? totalStages : stage + 1} / $totalStages',
),
),
child: SpacedColumn(
@@ -431,25 +597,12 @@ class _DialoguePageState extends State<DialoguePage> {
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),
itemBuilder: (context, index) => _TurnBubble(
turn: turns[index],
state: widget.state,
showTranslation: _shownTranslations.contains(index),
onToggleTranslation: () => _toggleTurnTranslation(index),
),
child: LexiconText(turn.text, state: widget.state),
),
);
},
),
),
if (!finished) ...[
@@ -457,26 +610,8 @@ class _DialoguePageState extends State<DialoguePage> {
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: _showHint),
_AssistChip(label: '翻译', onTap: _showLatestAiTranslation),
_AssistChip(
label: '慢一点',
onTap: () => _playLatestAi(slow: true),
@@ -496,6 +631,29 @@ class _DialoguePageState extends State<DialoguePage> {
style: const TextStyle(color: AppColors.warmInk),
),
),
if (aiNotice != null)
SectionCard(
tint: AppColors.warm,
child: Row(
children: [
const Icon(
Icons.cloud_off_outlined,
size: 18,
color: AppColors.warmInk,
),
const SizedBox(width: 8),
Expanded(
child: Text(
aiNotice!,
style: const TextStyle(
color: AppColors.warmInk,
fontSize: 13,
),
),
),
],
),
),
if (validationError != null)
Text(
validationError!,
@@ -514,11 +672,20 @@ class _DialoguePageState extends State<DialoguePage> {
filled: true,
fillColor: AppColors.surface,
prefixIcon: IconButton(
tooltip: listening ? '停止录音' : '语音输入',
icon: Icon(
listening ? Icons.stop_circle_outlined : Icons.mic_none,
tooltip: transcribing
? '正在 AI 识别…'
: (listening ? '停止录音并识别' : '语音输入'),
icon: transcribing
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
listening ? Icons.stop_circle : Icons.mic_none,
color: listening ? Colors.redAccent : null,
),
onPressed: _toggleListening,
onPressed: transcribing ? null : _toggleListening,
),
suffixIcon: IconButton(
icon: const Icon(Icons.send),
@@ -534,32 +701,13 @@ class _DialoguePageState extends State<DialoguePage> {
: '这是设备转写;未修改提交后会作为语音尝试保存。',
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),
),
],
],
RecordingControls(
recording: recording,
playing: playingRecording,
hasRecording: recordingPath != null,
onToggleRecording: toggleRecording,
onPlay: playRecording,
onDelete: deleteRecording,
),
Text(
widget.state.keepRecordings
@@ -578,7 +726,15 @@ class _DialoguePageState extends State<DialoguePage> {
child: Text(
usedHelp
? '本次使用过提示,课程会把关键表达安排到后续复习。'
: '你完成了 4 个交际任务,接下来试着不看帮助独立表达。',
: '你完成了 ${_completedTaskLabels().length} 个交际任务,接下来试着不看帮助独立表达。',
),
),
if (latestFeedback != null)
SectionCard(
tint: AppColors.warm,
child: Text(
'下次可以注意:${latestFeedback!}',
style: const TextStyle(color: AppColors.warmInk),
),
),
PrimaryButton(
@@ -592,6 +748,127 @@ class _DialoguePageState extends State<DialoguePage> {
}
}
class _TurnBubble extends StatelessWidget {
const _TurnBubble({
required this.turn,
required this.state,
required this.showTranslation,
required this.onToggleTranslation,
});
final DialogueTurn turn;
final AppState state;
final bool showTranslation;
final VoidCallback onToggleTranslation;
@override
Widget build(BuildContext context) {
final translation = turn.translation;
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: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LexiconText(turn.text, state: state),
if (!turn.isLearner) ...[
if (showTranslation &&
translation != null &&
translation.isNotEmpty) ...[
const SizedBox(height: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(6),
),
child: Text(
translation,
style: const TextStyle(
fontSize: 13,
color: Color(0xFF2D3748),
),
),
),
],
const SizedBox(height: 6),
Row(
mainAxisSize: MainAxisSize.min,
children: [
_TurnAction(
icon: Icons.volume_up_outlined,
label: '播放',
onTap: () => VoiceService.instance.speak(turn.text),
),
const SizedBox(width: 14),
_TurnAction(
icon: showTranslation
? Icons.translate
: Icons.translate_outlined,
label: showTranslation ? '隐藏翻译' : '翻译',
onTap: onToggleTranslation,
),
const SizedBox(width: 14),
_TurnAction(
icon: Icons.psychology_alt_outlined,
label: '句型解析',
onTap: () => showLexiconLookup(
context,
state: state,
initialText: turn.text,
),
),
],
),
],
],
),
),
);
}
}
class _TurnAction extends StatelessWidget {
const _TurnAction({
required this.icon,
required this.label,
required this.onTap,
});
final IconData icon;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => GestureDetector(
onTap: onTap,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: AppColors.green),
const SizedBox(width: 4),
Text(
label,
style: const TextStyle(
fontSize: 12,
color: AppColors.green,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
class _AssistChip extends StatelessWidget {
const _AssistChip({required this.label, required this.onTap});
final String label;
@@ -612,18 +889,33 @@ class DialogueSummaryPage extends StatelessWidget {
required this.onHome,
required this.onLesson,
required this.onRetry,
this.onBack,
});
final DialogueSummaryData summary;
final VoidCallback onHome;
final VoidCallback onLesson;
final VoidCallback onRetry;
final VoidCallback? onBack;
@override
Widget build(BuildContext context) => AppPage(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: onBack ?? onHome,
),
title: const Text("对话完成"),
),
child: SpacedColumn(
children: [
const Eyebrow('对话完成'),
Text('你完成了自我介绍!', style: Theme.of(context).textTheme.headlineMedium),
Text('你完成了 ${summary.completedTasks.join('')}'),
Text(
summary.completedTasks.isEmpty
? '这次还没有完成完整的交际任务,可以再练一次。'
: '你完成了 ${summary.completedTasks.join('')}',
),
SectionCard(
child: _SummaryLine(
icon: Icons.check_circle_outline,
@@ -632,6 +924,16 @@ class DialogueSummaryPage extends StatelessWidget {
tint: AppColors.green,
),
),
if (summary.improvement != null && summary.improvement!.isNotEmpty)
SectionCard(
tint: AppColors.warm,
child: _SummaryLine(
icon: Icons.tips_and_updates_outlined,
title: '下一次说得更好',
sentence: summary.improvement!,
tint: AppColors.warmInk,
),
),
Text(
summary.usedHelp
? '本次使用过提示。下次可以先不看提示,再试一次。'
@@ -9,6 +9,14 @@ import '../../core/voice_service.dart';
import '../../core/writing_feedback.dart';
import '../../widgets/app_widgets.dart';
import '../../widgets/lexicon_lookup.dart';
import '../../widgets/voice_answer.dart';
part 'steps/independent_step.dart';
part 'steps/listening_step.dart';
part 'steps/preview_step.dart';
part 'steps/reading_step.dart';
part 'steps/speaking_step.dart';
part 'steps/writing_step.dart';
class LessonFlow extends StatefulWidget {
const LessonFlow({
@@ -89,9 +97,15 @@ class _LessonFlowState extends State<LessonFlow> {
onSkip: widget.state.completePreview,
);
case LessonStep.listening:
final listeningOptions = shuffledOptions(
activity.answers,
'${activity.listening}-listening',
);
content = _ListeningStep(
state: widget.state,
activity: activity,
options: listeningOptions,
correctAnswer: activity.answers.first,
selectedAnswer: selectedAnswer,
audioPlayed: listeningAudioPlayed,
onSelected: (value) => setState(() => selectedAnswer = value),
@@ -101,7 +115,9 @@ class _LessonFlowState extends State<LessonFlow> {
state: widget.state,
initialText: activity.listening,
),
onContinue: listeningAudioPlayed && selectedAnswer == 0
onContinue:
selectedAnswer >= 0 &&
listeningOptions[selectedAnswer] == activity.answers.first
? widget.state.completeListening
: null,
);
@@ -147,6 +163,7 @@ class _LessonFlowState extends State<LessonFlow> {
content = _DialoguePendingStep(onOpenDialogue: widget.onOpenDialogue);
case LessonStep.independent:
content = _IndependentStep(
state: widget.state,
segmentId: segment.id,
keepRecording: widget.state.keepRecordings,
activity: activity,
@@ -192,6 +209,7 @@ class _LessonFlowState extends State<LessonFlow> {
return _LessonScope(
title:
'${lesson.number} 课 · ${lesson.title} · 第 ${widget.state.activeSegmentIndexFor(lesson.id) + 1}/${lesson.segments.length}',
onExit: widget.onFinish,
child: content,
);
}
@@ -205,7 +223,21 @@ class _LessonScaffold extends StatelessWidget {
@override
Widget build(BuildContext context) => AppPage(
appBar: AppBar(title: Text(_LessonScope.of(context))),
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "退出课程",
onPressed: () {
final onExit = _LessonScope.exitOf(context);
if (onExit != null) {
onExit();
} else if (Navigator.canPop(context)) {
Navigator.pop(context);
}
},
),
title: Text(_LessonScope.of(context)),
),
child: SpacedColumn(
spacing: 16,
children: [
@@ -231,593 +263,21 @@ class _LessonScaffold extends StatelessWidget {
}
class _LessonScope extends InheritedWidget {
const _LessonScope({required this.title, required super.child});
const _LessonScope({required this.title, this.onExit, required super.child});
final String title;
final VoidCallback? onExit;
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;
static VoidCallback? exitOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_LessonScope>()?.onExit;
@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<int> 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<void> _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<void> _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<void> _playRecording() async {
final path = recordingPath;
if (path == null) return;
setState(() => playingRecording = true);
await VoiceService.instance.playRecording(path);
if (mounted) setState(() => playingRecording = false);
}
Future<void> _deleteRecording() async {
await VoiceService.instance.deleteRecording(recordingPath);
if (mounted) setState(() => recordingPath = null);
}
@override
Widget build(BuildContext context) => _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<bool> 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<void> _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,
),
],
),
);
bool updateShouldNotify(_LessonScope oldWidget) =>
title != oldWidget.title || onExit != oldWidget.onExit;
}
class _DialoguePendingStep extends StatelessWidget {
@@ -838,253 +298,6 @@ class _DialoguePendingStep extends StatelessWidget {
);
}
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<void> _toggleRecording() async {
if (recording) {
final path = await VoiceService.instance.stopRecording();
if (mounted) {
setState(() {
recording = false;
recordingPath = path;
});
}
return;
}
await VoiceService.instance.deleteRecording(recordingPath);
final ready = await VoiceService.instance.startRecording();
if (!mounted) return;
setState(() {
recording = ready;
if (ready) recordingPath = null;
});
if (!ready) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('无法使用麦克风录音;请检查系统权限。')));
}
}
Future<void> _playRecording() async {
final path = recordingPath;
if (path == null) return;
setState(() => playingRecording = true);
await VoiceService.instance.playRecording(path);
if (mounted) setState(() => playingRecording = false);
}
Future<void> _deleteRecording() async {
await VoiceService.instance.deleteRecording(recordingPath);
if (mounted) setState(() => recordingPath = null);
}
Future<void> _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,
@@ -1126,16 +339,38 @@ class _AudioRow extends StatelessWidget {
children: [
IconButton.filled(
onPressed: () async {
try {
await VoiceService.instance.speak(speech ?? label);
} finally {
onPlayed?.call();
}
},
icon: const Icon(Icons.play_arrow),
),
const SizedBox(width: 10),
Expanded(child: Text(label)),
Expanded(
child: InkWell(
onTap: () async {
try {
await VoiceService.instance.speak(speech ?? label);
} finally {
onPlayed?.call();
}
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(label),
),
),
),
TextButton(
onPressed: () =>
VoiceService.instance.speak(speech ?? label, slow: true),
onPressed: () async {
try {
await VoiceService.instance.speak(speech ?? label, slow: true);
} finally {
onPlayed?.call();
}
},
child: const Text('慢速'),
),
],
@@ -0,0 +1,220 @@
part of '../lesson_flow.dart';
class _IndependentStep extends StatefulWidget {
const _IndependentStep({
required this.state,
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 AppState state;
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>
with VoiceAnswerMixin<_IndependentStep> {
bool usedVoice = false;
bool transcriptEdited = false;
String lastTranscript = '';
String? validationError;
@override
AppState get voiceState => widget.state;
@override
void dispose() {
disposeVoiceAnswer(keepRecording: widget.keepRecording);
super.dispose();
}
void _submit() {
if (!matchesSegmentIndependent(widget.segmentId, widget.controller.text)) {
setState(() => validationError = '这次还没有用上本段要练的内容。查看帮助后补充一次。');
return;
}
widget.onContinue(
usedVoice && !transcriptEdited,
widget.keepRecording ? recordingPath : null,
);
}
Future<void> _toggleMic() async {
if (aiVoiceRecording) {
await finishVoiceInput(
onTranscript: (text) {
widget.controller.text = text;
usedVoice = true;
lastTranscript = text;
transcriptEdited = false;
},
afterTranscribe: widget.onChanged,
);
return;
}
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();
},
onStatus: (status) {
if (mounted && (status == 'notListening' || status == 'done')) {
setState(() => listening = false);
}
},
onError: (err) {
if (mounted) {
setState(() => listening = false);
}
},
);
if (!ready) {
final recordStarted = await startVoiceInput();
if (recordStarted && mounted) {
showVoiceMessage('已启动麦克风录音,说完后再次点击,AI 将自动转写为英文。');
}
return;
}
if (mounted) setState(() => listening = ready);
}
@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,
maxLines: 4,
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。'
: '可录下这次尝试并回听;离开本页后会自动删除。',
),
RecordingControls(
recording: recording,
playing: playingRecording,
hasRecording: recordingPath != null,
onToggleRecording: toggleRecording,
onPlay: playRecording,
onDelete: deleteRecording,
),
],
),
),
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('稍后继续')),
],
),
);
}
@@ -0,0 +1,96 @@
part of '../lesson_flow.dart';
class _ListeningStep extends StatelessWidget {
const _ListeningStep({
required this.state,
required this.activity,
required this.options,
required this.correctAnswer,
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 List<String> options;
final String correctAnswer;
final int selectedAnswer;
final bool audioPlayed;
final ValueChanged<int> onSelected;
final VoidCallback onPlayed;
final VoidCallback onLookup;
final VoidCallback? onContinue;
@override
Widget build(BuildContext context) {
final answers = options;
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: () {
onSelected(index);
if (!audioPlayed) {
onPlayed();
}
},
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: selectedAnswer >= 0
? '检查并继续'
: (audioPlayed ? '请选择答案' : '先播放音频或选择答案'),
onPressed: onContinue,
),
if (selectedAnswer >= 0 && answers[selectedAnswer] != correctAnswer)
const Text(
'再听一次,选择正确答案。',
style: TextStyle(color: AppColors.warmInk),
),
],
),
);
}
}
@@ -0,0 +1,62 @@
part of '../lesson_flow.dart';
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('跳过,直接进入课程')),
],
),
);
}
@@ -0,0 +1,373 @@
part of '../lesson_flow.dart';
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();
int? selectedOptionIndex;
bool showAnswer = false;
/// 打乱后的选项:答案不再固定排在第一位,但同一道题顺序保持稳定。
late final List<String> options = shuffledOptions(
widget.activity.readingOptions,
'${widget.activity.readingQuestion}-reading',
);
@override
void dispose() {
controller.dispose();
super.dispose();
}
bool _isOptionCorrect(int index) {
if (options.isEmpty || index < 0 || index >= options.length) {
return false;
}
final option = options[index].trim();
final answer = widget.activity.readingAnswer.trim();
if (option.toLowerCase() == answer.toLowerCase()) return true;
final normOption = option.toLowerCase().replaceAll(
RegExp(r'[^a-z0-9\u4e00-\u9fa5]'),
'',
);
final normAnswer = answer.toLowerCase().replaceAll(
RegExp(r'[^a-z0-9\u4e00-\u9fa5]'),
'',
);
return normOption.isNotEmpty &&
normAnswer.isNotEmpty &&
(normOption.contains(normAnswer) || normAnswer.contains(normOption));
}
bool get isOptionMode => options.isNotEmpty;
bool get isCorrect {
if (isOptionMode) {
return selectedOptionIndex != null &&
_isOptionCorrect(selectedOptionIndex!);
}
final answer = widget.activity.readingAnswer.toLowerCase().replaceAll(
RegExp(r'[^a-z0-9\u4e00-\u9fa5]'),
'',
);
final response = controller.text.toLowerCase().replaceAll(
RegExp(r'[^a-z0-9\u4e00-\u9fa5]'),
'',
);
// 只接受写全了答案的输入:过去反向的 answer.contains(response) 让单个字母
// 也能判对('a' 通过 'A book')。
return response.isNotEmpty &&
answer.isNotEmpty &&
response.contains(answer);
}
@override
Widget build(BuildContext context) {
final hasSelected = selectedOptionIndex != null;
final answeredCorrectly = isCorrect;
return _LessonScaffold(
step: 4,
child: SpacedColumn(
children: [
const Eyebrow('读一读'),
Text('在对话里找到答案。', style: Theme.of(context).textTheme.headlineMedium),
SectionCard(
tint: AppColors.softGreen,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Row(
children: [
Icon(
Icons.chat_bubble_outline,
size: 16,
color: AppColors.green,
),
SizedBox(width: 6),
Text(
'对话内容',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.green,
),
),
],
),
InkWell(
onTap: () =>
VoiceService.instance.speak(widget.activity.reading),
borderRadius: BorderRadius.circular(16),
child: const Padding(
padding: EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: Row(
children: [
Icon(
Icons.volume_up_outlined,
size: 16,
color: AppColors.green,
),
SizedBox(width: 4),
Text(
'朗读对话',
style: TextStyle(
fontSize: 13,
color: AppColors.green,
),
),
],
),
),
),
],
),
const SizedBox(height: 8),
LexiconText(
widget.activity.reading,
state: widget.state,
style: const TextStyle(fontSize: 16, height: 1.6),
),
],
),
),
Row(
children: [
TextButton.icon(
onPressed: widget.onLookup,
icon: const Icon(Icons.menu_book_outlined, size: 18),
label: const Text('查词或短语'),
),
const SizedBox(width: 8),
TextButton.icon(
onPressed: () {
final lines = widget.activity.reading.split('\n');
final target = lines
.firstWhere(
(l) => l.trim().isNotEmpty,
orElse: () => widget.activity.reading,
)
.replaceFirst(RegExp(r'^[A-Za-z]+:\s*'), '');
showLexiconLookup(
context,
state: widget.state,
initialText: target,
);
},
icon: const Icon(Icons.auto_stories_outlined, size: 18),
label: const Text('句型深度解析'),
),
],
),
SectionCard(
tint: AppColors.surfaceMuted,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColors.green.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'问题',
style: TextStyle(
color: AppColors.green,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
widget.activity.readingQuestion,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.ink,
),
),
),
],
),
),
if (isOptionMode) ...[
for (var index = 0; index < options.length; index++) ...[
SectionCard(
tint: selectedOptionIndex == index
? (_isOptionCorrect(index)
? AppColors.softGreen
: AppColors.warm)
: null,
onTap: () {
setState(() {
selectedOptionIndex = index;
showAnswer = false;
});
},
child: Row(
children: [
Icon(
selectedOptionIndex == index
? (_isOptionCorrect(index)
? Icons.check_circle
: Icons.cancel_outlined)
: Icons.radio_button_off,
color: selectedOptionIndex == index
? (_isOptionCorrect(index)
? AppColors.green
: AppColors.warmInk)
: AppColors.muted,
),
const SizedBox(width: 12),
Expanded(
child: Text(
options[index],
style: TextStyle(
fontSize: 15,
fontWeight: selectedOptionIndex == index
? FontWeight.w600
: FontWeight.normal,
color: selectedOptionIndex == index
? (_isOptionCorrect(index)
? AppColors.green
: AppColors.warmInk)
: AppColors.ink,
),
),
),
],
),
),
],
if (hasSelected && answeredCorrectly)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
),
decoration: BoxDecoration(
color: AppColors.softGreen,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: AppColors.green.withValues(alpha: 0.3),
),
),
child: const Row(
children: [
Icon(Icons.check_circle, color: AppColors.green, size: 20),
SizedBox(width: 8),
Expanded(
child: Text(
'回答正确!点击下方按钮继续',
style: TextStyle(
color: AppColors.green,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
],
),
)
else if (hasSelected && !answeredCorrectly)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
),
decoration: BoxDecoration(
color: AppColors.warm,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: AppColors.warmInk.withValues(alpha: 0.2),
),
),
child: const Row(
children: [
Icon(
Icons.help_outline,
color: AppColors.warmInk,
size: 20,
),
SizedBox(width: 8),
Expanded(
child: Text(
'不对哦,再仔细观察对话中的关键句子~',
style: TextStyle(
color: AppColors.warmInk,
fontSize: 13,
),
),
),
],
),
),
] else ...[
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 &&
((isOptionMode && hasSelected && !answeredCorrectly) ||
(!isOptionMode &&
controller.text.isNotEmpty &&
!answeredCorrectly)))
TextButton(
onPressed: () => setState(() => showAnswer = true),
child: const Text('查看答案后继续学习'),
),
PrimaryButton(
label: showAnswer || answeredCorrectly
? '继续写一写'
: (isOptionMode ? '请选择答案' : '检查并继续'),
onPressed: showAnswer || answeredCorrectly
? widget.onContinue
: null,
),
],
),
);
}
}
@@ -0,0 +1,151 @@
part of '../lesson_flow.dart';
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>
with VoiceAnswerMixin<_SpeakingStep> {
String transcript = '';
@override
AppState get voiceState => widget.state;
@override
void dispose() {
disposeVoiceAnswer(keepRecording: widget.keepRecording);
super.dispose();
}
Future<void> _toggleMic() async {
if (aiVoiceRecording) {
await finishVoiceInput(
noSpeech: '未识别到清晰发音,请重试或点击“播放示范音”。',
onTranscript: (text) => transcript = text,
);
return;
}
await VoiceService.instance.stopRecordingPlayback();
if (mounted) setState(() => playingRecording = false);
if (!widget.keepRecording) {
await VoiceService.instance.deleteRecording(recordingPath);
}
final recordStarted = await startVoiceInput();
if (!recordStarted || !mounted) return;
setState(() => recordingPath = null);
showVoiceMessage('已启动麦克风录音,跟读完成后再次点击,AI 将自动转写发音。');
}
Future<void> _togglePlayRecording() async {
if (playingRecording) {
await VoiceService.instance.stopRecordingPlayback();
if (mounted) setState(() => playingRecording = false);
return;
}
if (recordingPath == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请先使用麦克风跟读,录音完成后即可播放。')));
return;
}
await playRecording();
}
@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: transcribing
? '正在 AI 识别发音…'
: (listening ? '停止录音并识别' : '使用麦克风跟读'),
onPressed: transcribing ? null : _toggleMic,
),
SecondaryButton(
label: playingRecording ? '停止播放' : '播放跟读',
onPressed: (listening || transcribing) ? null : _togglePlayRecording,
),
if (recordingPath != null)
SectionCard(
tint: AppColors.softGreen,
child: SpacedColumn(
spacing: 8,
children: [
Text(widget.keepRecording ? '录音已保存在本机。' : '本次跟读录音仅在离开此步骤前保留。'),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: (listening || transcribing)
? null
: _togglePlayRecording,
icon: Icon(
playingRecording ? Icons.stop : Icons.play_arrow,
),
label: Text(playingRecording ? '停止播放' : '播放跟读'),
),
),
const SizedBox(width: 8),
IconButton(
tooltip: '删除录音',
onPressed: (listening || transcribing)
? null
: 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,
),
],
),
);
}
@@ -0,0 +1,187 @@
part of '../lesson_flow.dart';
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<bool> 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<void> _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,
),
],
),
);
}
@@ -32,6 +32,40 @@ class _WelcomePageState extends State<WelcomePage> {
child: SpacedColumn(
spacing: 20,
children: [
Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.asset(
'assets/branding/logo_512.png',
width: 44,
height: 44,
),
),
const SizedBox(width: 12),
const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'芽说英语',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColors.ink,
),
),
Text(
'SpeakSprout',
style: TextStyle(
fontSize: 12,
color: AppColors.green,
fontWeight: FontWeight.w600,
),
),
],
),
],
),
const Eyebrow('欢迎'),
Text(
'每天 20 分钟,\n说出能用的英语。',
@@ -70,10 +104,16 @@ class _WelcomePageState extends State<WelcomePage> {
}
class PlacementPage extends StatefulWidget {
const PlacementPage({super.key, required this.state, required this.onStart});
const PlacementPage({
super.key,
required this.state,
required this.onStart,
this.onBack,
});
final AppState state;
final VoidCallback onStart;
final VoidCallback? onBack;
@override
State<PlacementPage> createState() => _PlacementPageState();
@@ -96,6 +136,16 @@ class _PlacementPageState extends State<PlacementPage> {
PlacementLevel.simpleConversation: ('能简单对话', '想说得更自然、更有信心'),
};
return AppPage(
appBar: widget.onBack != null
? AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: widget.onBack,
),
title: const Text("基础定位"),
)
: null,
child: SpacedColumn(
spacing: 14,
children: [
@@ -6,7 +6,10 @@ import '../../core/ai_service.dart';
import '../../core/app_theme.dart';
import '../../core/models.dart';
import '../../core/voice_service.dart';
import '../../core/sherpa_stt_service.dart';
import '../../widgets/app_widgets.dart';
import '../../core/sync/sync_coordinator.dart';
import 'sync_settings_sheet.dart';
class ProgressPage extends StatelessWidget {
const ProgressPage({
@@ -93,6 +96,46 @@ class ProgressPage extends StatelessWidget {
'进入下一阶段条件:60 项固定核心内容中至少 48 项可使用、30 项已掌握,且两套不同题组的听说读写评估都通过并间隔至少 24 小时。',
),
),
AnimatedBuilder(
animation: SyncCoordinator.instance,
builder: (context, _) {
final loggedIn = SyncCoordinator.instance.isLoggedIn;
return SectionCard(
onTap: () => SyncSettingsSheet.show(context, state),
child: Row(
children: [
Icon(
loggedIn
? Icons.cloud_done_outlined
: Icons.cloud_queue_outlined,
color: loggedIn ? AppColors.green : AppColors.muted,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loggedIn
? '云同步:${SyncCoordinator.instance.username}'
: '云端同步与多端备份',
style: const TextStyle(fontWeight: FontWeight.w600),
),
Text(
loggedIn
? '多端学习进度与复习状态已连接 · 点击管理'
: '未登录 · 点击配置自建服务器,在手机与电脑间同步',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
const Icon(Icons.chevron_right, color: AppColors.muted),
],
),
);
},
),
if (state.a0Passed)
const SectionCard(
tint: AppColors.softGreen,
@@ -199,8 +242,10 @@ class _AbilityRow extends StatelessWidget {
}
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key, required this.state});
const SettingsPage({super.key, required this.state, this.onBack});
final AppState state;
final VoidCallback? onBack;
@override
State<SettingsPage> createState() => _SettingsPageState();
}
@@ -209,6 +254,8 @@ class _SettingsPageState extends State<SettingsPage> {
late final TextEditingController endpoint;
late final TextEditingController model;
final apiKey = TextEditingController();
bool _testingConnection = false;
@override
void initState() {
super.initState();
@@ -226,7 +273,20 @@ class _SettingsPageState extends State<SettingsPage> {
@override
Widget build(BuildContext context) => AppPage(
appBar: AppBar(title: const Text('学习设置')),
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: () {
if (widget.onBack != null) {
widget.onBack!();
} else if (Navigator.canPop(context)) {
Navigator.pop(context);
}
},
),
title: const Text('学习设置'),
),
child: SpacedColumn(
spacing: 4,
children: [
@@ -262,6 +322,43 @@ class _SettingsPageState extends State<SettingsPage> {
onTap: () => _confirmDeleteRecordings(context),
),
const Divider(height: 28),
const Text(
'离线语音识别引擎',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const Text(
'已内置 SenseVoice-Small 高精度离线语音识别模型 (INT8)。随 App 安装包直接打包,离线即用,无需额外下载,零网络流量消耗。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
_SettingTile(
title: '离线语音识别:SenseVoice-Small',
subtitle: SherpaSttService.instance.isReady
? '已就绪 · 本地离线识别 (16kHz WAV · INT8)'
: '预加载就绪 · 已内置打包',
trailing: const Icon(Icons.check_circle, color: AppColors.green, size: 20),
),
const Divider(height: 28),
const Text(
'云同步与多端备份',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const Text(
'支持通过自建服务器在 Android / iOS / macOS 之间同步学习进度与复习掌握度。离线自动缓存,联网自动双向合并。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
AnimatedBuilder(
animation: SyncCoordinator.instance,
builder: (context, _) => _SettingTile(
title: SyncCoordinator.instance.isLoggedIn
? '同步账号:${SyncCoordinator.instance.username}'
: '配置云端同步账号',
subtitle: SyncCoordinator.instance.isLoggedIn
? '已连接自建服务器 · 点击管理同步'
: '未登录 · 点击配置自建服务器并登录',
onTap: () => SyncSettingsSheet.show(context, widget.state),
),
),
const Divider(height: 28),
const Text(
'AI 对话服务',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
@@ -296,6 +393,7 @@ class _SettingsPageState extends State<SettingsPage> {
],
onChanged: (value) {
if (value == null) return;
setState(() {
widget.state.setAiProvider(value);
if (value == AiProviderType.gemini &&
endpoint.text.trim().isEmpty) {
@@ -305,6 +403,7 @@ class _SettingsPageState extends State<SettingsPage> {
? 'gemini-2.5-flash'
: model.text;
}
});
},
),
TextField(
@@ -350,17 +449,51 @@ class _SettingsPageState extends State<SettingsPage> {
},
),
SecondaryButton(
label: '测试连接',
onPressed: () async {
label: _testingConnection ? '正在测试连接...' : '测试连接',
onPressed: _testingConnection
? null
: () async {
final messenger = ScaffoldMessenger.of(context);
setState(() => _testingConnection = true);
final result = await AiService.instance.testConnection(
provider: widget.state.aiProvider,
endpoint: endpoint.text,
model: model.text,
explicitApiKey: apiKey.text,
);
if (!context.mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(result.message)));
if (!mounted) return;
setState(() => _testingConnection = false);
messenger.showSnackBar(
SnackBar(
content: Text(result.message),
backgroundColor:
result.ok ? AppColors.green : Colors.redAccent,
duration: const Duration(seconds: 4),
),
);
},
),
SecondaryButton(
label: '从配置文件重载 (ai_config.json)',
onPressed: () async {
final messenger = ScaffoldMessenger.of(context);
final ok = await widget.state.reloadAiConfigFromAsset();
if (!mounted) return;
if (ok) {
setState(() {
endpoint.text = widget.state.aiEndpoint;
model.text = widget.state.aiModel;
});
messenger.showSnackBar(
const SnackBar(
content: Text('已从 assets/config/ai_config.json 载入配置。'),
),
);
} else {
messenger.showSnackBar(
const SnackBar(content: Text('未找到配置文件或解析失败。')),
);
}
},
),
const Divider(height: 28),
@@ -494,17 +627,20 @@ class _SettingTile extends StatelessWidget {
const _SettingTile({
required this.title,
required this.subtitle,
required this.onTap,
this.onTap,
this.trailing,
});
final String title;
final String subtitle;
final VoidCallback? onTap;
final Widget? trailing;
@override
Widget build(BuildContext context) => ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
title: Text(title),
subtitle: Text(subtitle),
trailing: const Icon(Icons.chevron_right),
trailing: trailing ?? (onTap != null ? const Icon(Icons.chevron_right) : null),
onTap: onTap,
);
}
@@ -0,0 +1,393 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/sync/sync_coordinator.dart';
import '../../core/sync/sync_models.dart';
import '../../widgets/app_widgets.dart';
/// 跨平台同步设置底部面板
class SyncSettingsSheet extends StatefulWidget {
const SyncSettingsSheet({super.key, required this.state});
final AppState state;
static Future<void> show(BuildContext context, AppState state) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SafeArea(
child: SyncSettingsSheet(state: state),
),
),
);
}
@override
State<SyncSettingsSheet> createState() => _SyncSettingsSheetState();
}
class _SyncSettingsSheetState extends State<SyncSettingsSheet> {
final _coordinator = SyncCoordinator.instance;
late final TextEditingController _serverController;
final _userController = TextEditingController();
final _passwordController = TextEditingController();
bool _isRegisterMode = false;
bool _testingConnection = false;
String? _testMessage;
bool? _testOk;
@override
void initState() {
super.initState();
_serverController = TextEditingController(text: _coordinator.serverUrl);
}
@override
void dispose() {
_serverController.dispose();
_userController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _handleTestConnection() async {
setState(() {
_testingConnection = true;
_testMessage = null;
_testOk = null;
});
final ok = await _coordinator.testConnection(_serverController.text);
if (!mounted) return;
setState(() {
_testingConnection = false;
_testOk = ok;
_testMessage = ok ? '服务器连接正常' : '无法连接到服务器,请检查地址或网络';
});
}
Future<void> _handleAuth() async {
final server = _serverController.text.trim();
final user = _userController.text.trim();
final pwd = _passwordController.text;
if (server.isEmpty || user.isEmpty || pwd.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请填写完整的服务器地址、账号和密码')),
);
return;
}
final success = _isRegisterMode
? await _coordinator.register(
serverUrl: server,
username: user,
password: pwd,
)
: await _coordinator.login(
serverUrl: server,
username: user,
password: pwd,
);
if (!mounted) return;
if (success) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_isRegisterMode ? '注册并登录成功!' : '登录成功!'),
backgroundColor: AppColors.green,
),
);
// 登录成功后自动执行一次同步
await _coordinator.syncNow(widget.state);
}
}
Future<void> _handleSyncNow() async {
final ok = await _coordinator.syncNow(widget.state);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(ok ? '同步完成,进度已合并。' : '同步失败:${_coordinator.errorMessage}'),
backgroundColor: ok ? AppColors.green : Colors.redAccent,
),
);
}
String _formatTime(DateTime? time) {
if (time == null) return '从未同步';
final local = time.toLocal();
final now = DateTime.now();
final diff = now.difference(local);
if (diff.inSeconds < 60) return '刚刚';
if (diff.inMinutes < 60) return '${diff.inMinutes} 分钟前';
if (diff.inHours < 24) return '${diff.inHours} 小时前';
return '${local.year}-${local.month.toString().padLeft(2, '0')}-${local.day.toString().padLeft(2, '0')} ${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}';
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _coordinator,
builder: (context, _) {
final isLoggedIn = _coordinator.isLoggedIn;
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
child: SpacedColumn(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'云同步与多端备份',
style: Theme.of(context).textTheme.titleLarge,
),
if (isLoggedIn)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: _coordinator.state == SyncState.syncing
? AppColors.warm
: _coordinator.state == SyncState.error
? Colors.red.shade50
: AppColors.softGreen,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_coordinator.state == SyncState.syncing
? Icons.sync
: _coordinator.state == SyncState.error
? Icons.error_outline
: Icons.cloud_done_outlined,
size: 14,
color: _coordinator.state == SyncState.syncing
? AppColors.warmInk
: _coordinator.state == SyncState.error
? Colors.redAccent
: AppColors.green,
),
const SizedBox(width: 4),
Text(
_coordinator.state == SyncState.syncing
? '同步中'
: _coordinator.state == SyncState.error
? '同步异常'
: '已连接',
style: TextStyle(
fontSize: 12,
color: _coordinator.state == SyncState.syncing
? AppColors.warmInk
: _coordinator.state == SyncState.error
? Colors.redAccent
: AppColors.green,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
const Text(
'本地优先架构:无网络时不影响学习,联网后自动双向合并学习进度与复习掌握度。',
style: TextStyle(fontSize: 13, color: AppColors.muted),
),
const SizedBox(height: 8),
if (isLoggedIn) ...[
// Logged In State
SectionCard(
child: Column(
children: [
Row(
children: [
const CircleAvatar(
radius: 18,
backgroundColor: AppColors.softGreen,
child: Icon(Icons.person, color: AppColors.green),
),
const SizedBox(width: 12),
Expanded(
child: Column(
children: [
Text(
_coordinator.username ?? '同步用户',
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 15,
),
),
Text(
_coordinator.serverUrl,
style: const TextStyle(
fontSize: 12,
color: AppColors.muted,
),
),
],
),
),
TextButton(
onPressed: () => _coordinator.logout(),
child: const Text('退出登录'),
),
],
),
const Divider(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('上次同步:'),
Text(
_formatTime(_coordinator.lastSyncTime),
style: const TextStyle(fontWeight: FontWeight.w500),
),
],
),
],
),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('自动后台同步'),
subtitle: const Text('在关卡完成与复习提交后自动静默同步'),
value: _coordinator.autoSyncEnabled,
activeThumbColor: AppColors.green,
onChanged: (val) => _coordinator.setAutoSyncEnabled(val),
),
if (_coordinator.errorMessage != null)
SectionCard(
tint: Colors.red.shade50,
child: Row(
children: [
const Icon(Icons.error_outline, color: Colors.redAccent),
const SizedBox(width: 10),
Expanded(
child: Text(
'同步失败:${_coordinator.errorMessage}',
style: const TextStyle(
color: Colors.redAccent,
fontSize: 13,
),
),
),
],
),
),
const SizedBox(height: 8),
PrimaryButton(
label: _coordinator.state == SyncState.syncing
? '正在同步...'
: '立即同步 (双向合并)',
icon: Icons.sync,
onPressed: _coordinator.state == SyncState.syncing
? null
: _handleSyncNow,
),
] else ...[
// Not Logged In State (Login / Register Form)
Row(
children: [
Expanded(
child: ChoiceChip(
label: const Center(child: Text('登录已有账号')),
selected: !_isRegisterMode,
onSelected: (val) =>
setState(() => _isRegisterMode = false),
),
),
const SizedBox(width: 12),
Expanded(
child: ChoiceChip(
label: const Center(child: Text('注册新账号')),
selected: _isRegisterMode,
onSelected: (val) =>
setState(() => _isRegisterMode = true),
),
),
],
),
const SizedBox(height: 8),
TextField(
controller: _serverController,
keyboardType: TextInputType.url,
decoration: InputDecoration(
labelText: '自建同步服务器地址',
hintText: 'https://syncenglish.slcydia.fun',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
tooltip: '测试连通性',
icon: _testingConnection
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.network_check),
onPressed: _testingConnection ? null : _handleTestConnection,
),
),
),
if (_testMessage != null)
Text(
_testMessage!,
style: TextStyle(
fontSize: 12,
color: _testOk == true ? AppColors.green : Colors.redAccent,
),
),
TextField(
controller: _userController,
decoration: const InputDecoration(
labelText: '用户名',
border: OutlineInputBorder(),
),
),
TextField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: '密码',
border: OutlineInputBorder(),
),
),
if (_coordinator.errorMessage != null)
SectionCard(
tint: Colors.red.shade50,
child: Text(
_coordinator.errorMessage!,
style: const TextStyle(
color: Colors.redAccent,
fontSize: 13,
),
),
),
PrimaryButton(
label: _coordinator.state == SyncState.syncing
? '处理中...'
: (_isRegisterMode ? '注册账号并开启同步' : '登录并同步进度'),
onPressed: _coordinator.state == SyncState.syncing
? null
: _handleAuth,
),
],
],
),
);
},
);
}
}
@@ -1,3 +1,4 @@
import '../../widgets/lexicon_lookup.dart';
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
@@ -9,6 +10,7 @@ import '../../core/review_feedback.dart';
import '../../core/a0_core.dart';
import '../../core/voice_service.dart';
import '../../widgets/app_widgets.dart';
import '../../widgets/voice_answer.dart';
class ReviewPage extends StatefulWidget {
const ReviewPage({
@@ -204,6 +206,7 @@ class _ReviewPageState extends State<ReviewPage> {
TextField(
controller: controller,
minLines: 2,
maxLines: 4,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(
hintText: '输入你会怎么回答',
@@ -256,6 +259,14 @@ class _ReviewPageState extends State<ReviewPage> {
? null
: () => _generateAdaptiveLesson(item),
),
ActionChip(
avatar: const Icon(Icons.search, size: 16),
label: const Text('查词查句'),
onPressed: () => showLexiconLookup(
context,
state: widget.state,
),
),
],
),
if (showHint)
@@ -303,19 +314,16 @@ class AdaptiveLessonPage extends StatefulWidget {
State<AdaptiveLessonPage> createState() => _AdaptiveLessonPageState();
}
class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
with VoiceAnswerMixin<AdaptiveLessonPage> {
final controller = TextEditingController();
int index = 0;
bool showReference = false;
String? answerFeedback;
bool listening = false;
bool usedVoice = false;
bool transcriptEdited = false;
bool transcriptConfirmed = false;
String lastTranscript = '';
bool recording = false;
bool playingRecording = false;
String? recordingPath;
@override
void initState() {
@@ -339,11 +347,9 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
@override
void dispose() {
VoiceService.instance.stopSpeaking();
VoiceService.instance.stopListening();
VoiceService.instance.stopRecordingPlayback();
if (!widget.state.keepRecordings) {
VoiceService.instance.deleteRecording(recordingPath);
}
disposeVoiceAnswer(keepRecording: widget.state.keepRecordings);
controller.dispose();
super.dispose();
}
@@ -412,69 +418,30 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
});
}
@override
AppState get voiceState => widget.state;
Future<void> _toggleListening() async {
if (listening) {
await VoiceService.instance.stopListening();
if (mounted) setState(() => listening = false);
return;
}
final ready = await VoiceService.instance.startListening((text, _) {
if (!mounted) return;
setState(() {
if (aiVoiceRecording) {
await finishVoiceInput(
keepAudio: false,
onTranscript: (text) {
controller.text = text;
usedVoice = true;
transcriptEdited = false;
transcriptConfirmed = false;
lastTranscript = text;
});
},
afterTranscribe: () {
final lesson = widget.state.cachedAdaptiveLesson;
if (lesson != null) _saveDraft(lesson);
});
if (!mounted) return;
setState(() => listening = ready);
if (!ready) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('语音识别不可用;你仍可输入英文完成补练。')));
}
}
Future<void> _toggleRecording() async {
if (recording) {
final path = await VoiceService.instance.stopRecording();
if (mounted) {
setState(() {
recording = false;
recordingPath = path;
});
}
},
);
return;
}
await VoiceService.instance.deleteRecording(recordingPath);
final ready = await VoiceService.instance.startRecording();
if (!mounted) return;
setState(() {
recording = ready;
if (ready) recordingPath = null;
});
if (!ready) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('无法使用麦克风录音;请检查系统权限。')));
}
}
Future<void> _playRecording() async {
final path = recordingPath;
if (path == null) return;
setState(() => playingRecording = true);
await VoiceService.instance.playRecording(path);
if (mounted) setState(() => playingRecording = false);
}
Future<void> _deleteRecording() async {
await VoiceService.instance.deleteRecording(recordingPath);
if (mounted) setState(() => recordingPath = null);
await startVoiceInput(
unavailableMessage: '无法访问麦克风,请检查录音权限。你仍可输入英文完成补练。',
);
}
@override
@@ -482,6 +449,14 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
final lesson = widget.state.cachedAdaptiveLesson;
if (lesson == null) {
return AppPage(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: widget.onFinished,
),
title: const Text("AI 四技能补练"),
),
child: SpacedColumn(
children: [
const Eyebrow('AI 四技能补练'),
@@ -494,6 +469,14 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
final done = index >= lesson.tasks.length;
if (done) {
return AppPage(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: widget.onFinished,
),
title: const Text("补练完成"),
),
child: SpacedColumn(
children: [
const Eyebrow('补练已完成'),
@@ -510,7 +493,14 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
final task = lesson.tasks[index];
final showStimulus = task.skill != 'listening';
return AppPage(
appBar: AppBar(title: Text('AI 补练 · ${index + 1}/4')),
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: widget.onFinished,
),
title: Text('AI 补练 · ${index + 1}/4'),
),
child: SpacedColumn(
children: [
Eyebrow('${_skillLabel(task.skill)} · 已审核教学内容'),
@@ -549,11 +539,22 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
label: const Text('慢放'),
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => showLexiconLookup(
context,
state: widget.state,
initialText: task.stimulus,
),
icon: const Icon(Icons.psychology_alt_outlined),
label: const Text('解析'),
),
],
),
TextField(
controller: controller,
minLines: 2,
maxLines: 4,
onChanged: (_) {
if (usedVoice && controller.text != lastTranscript) {
transcriptEdited = true;
@@ -567,10 +568,21 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
filled: true,
fillColor: AppColors.surface,
prefixIcon: IconButton(
tooltip: listening ? '停止语音输入' : '语音输入',
tooltip: transcribing
? '正在 AI 识别…'
: (listening ? '停止录音并识别' : '语音输入'),
onPressed: _toggleListening,
icon: Icon(
listening ? Icons.stop_circle_outlined : Icons.mic_none,
icon: transcribing
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
listening
? Icons.stop_circle_outlined
: Icons.mic_none,
color: listening ? AppColors.green : null,
),
),
border: OutlineInputBorder(),
@@ -587,33 +599,13 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
? '可录音回听并仅保存在本机;不会发送给 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),
),
],
],
RecordingControls(
recording: recording,
playing: playingRecording,
hasRecording: recordingPath != null,
onToggleRecording: listening ? null : toggleRecording,
onPlay: playRecording,
onDelete: deleteRecording,
),
],
),
@@ -1,17 +1,17 @@
import 'package:flutter/material.dart';
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';
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});
@@ -24,6 +24,7 @@ class LearningShell extends StatefulWidget {
class _LearningShellState extends State<LearningShell> {
AppTab tab = AppTab.home;
var route = _ShellRoute.tab;
_ShellRoute? previousRoute;
bool dialogueInLesson = false;
AssessmentPack? assessmentPack;
DialogueSummaryData? dialogueSummary;
@@ -31,26 +32,86 @@ class _LearningShellState extends State<LearningShell> {
void showTab(AppTab value) => setState(() {
tab = value;
route = _ShellRoute.tab;
previousRoute = null;
});
void showLesson() => setState(() {
previousRoute = route;
route = _ShellRoute.lesson;
});
void showDialogueScene() => setState(() {
previousRoute = route;
route = _ShellRoute.scene;
});
void showLesson() => setState(() => route = _ShellRoute.lesson);
void showDialogueScene() => setState(() => route = _ShellRoute.scene);
void showDialogue({bool inLesson = false}) => setState(() {
previousRoute = route;
dialogueInLesson = inLesson;
route = _ShellRoute.dialogue;
});
void showSummary(DialogueSummaryData summary) => setState(() {
previousRoute = route;
dialogueSummary = summary;
route = _ShellRoute.summary;
});
void showSettings() => setState(() => route = _ShellRoute.settings);
void showSettings() => setState(() {
previousRoute = route;
route = _ShellRoute.settings;
});
void showAssessment(AssessmentPack pack) => setState(() {
previousRoute = route;
assessmentPack = pack;
route = _ShellRoute.assessmentPreparation;
});
void startAssessment() => setState(() => route = _ShellRoute.assessment);
void showAdaptiveLesson() =>
setState(() => route = _ShellRoute.adaptiveLesson);
void startAssessment() => setState(() {
previousRoute = route;
route = _ShellRoute.assessment;
});
void showAdaptiveLesson() => setState(() {
previousRoute = route;
route = _ShellRoute.adaptiveLesson;
});
void handleBack() {
switch (route) {
case _ShellRoute.lesson:
showTab(tab);
case _ShellRoute.scene:
showTab(tab);
case _ShellRoute.dialogue:
if (dialogueInLesson) {
showLesson();
} else if (previousRoute == _ShellRoute.scene) {
showDialogueScene();
} else {
showTab(tab == AppTab.dialogue ? AppTab.dialogue : tab);
}
case _ShellRoute.summary:
showTab(tab);
case _ShellRoute.settings:
showTab(AppTab.progress);
case _ShellRoute.adaptiveLesson:
showTab(AppTab.review);
case _ShellRoute.assessmentPreparation:
showTab(AppTab.progress);
case _ShellRoute.assessment:
if (assessmentPack != null) {
showAssessment(assessmentPack!);
} else {
showTab(AppTab.progress);
}
case _ShellRoute.tab:
if (tab != AppTab.home) {
showTab(AppTab.home);
}
}
}
@override
Widget build(BuildContext context) {
@@ -60,18 +121,25 @@ class _LearningShellState extends State<LearningShell> {
body = LessonFlow(
state: widget.state,
onOpenDialogue: () => showDialogue(inLesson: true),
onFinish: () => showTab(AppTab.home),
onFinish: () => showTab(tab),
);
case _ShellRoute.scene:
body = DialogueScenePage(onStart: showDialogue);
body = DialogueScenePage(
onStart: showDialogue,
onBack: () => showTab(tab),
);
case _ShellRoute.dialogue:
body = DialoguePage(
state: widget.state,
isLessonDialogue: dialogueInLesson,
onFinished: dialogueInLesson
? (_) => showLesson()
: (summary) {
if (summary != null) showSummary(summary);
onFinished: (summary) {
if (dialogueInLesson) {
showLesson();
} else if (summary != null) {
showSummary(summary);
} else {
handleBack();
}
},
);
case _ShellRoute.summary:
@@ -80,9 +148,13 @@ class _LearningShellState extends State<LearningShell> {
onHome: () => showTab(AppTab.home),
onLesson: showLesson,
onRetry: showDialogue,
onBack: () => showTab(tab),
);
case _ShellRoute.settings:
body = SettingsPage(state: widget.state);
body = SettingsPage(
state: widget.state,
onBack: () => showTab(AppTab.progress),
);
case _ShellRoute.adaptiveLesson:
body = AdaptiveLessonPage(
state: widget.state,
@@ -106,7 +178,14 @@ class _LearningShellState extends State<LearningShell> {
body = _tabContent();
}
return AnimatedBuilder(
return PopScope(
canPop: route == _ShellRoute.tab && tab == AppTab.home,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) {
handleBack();
}
},
child: AnimatedBuilder(
animation: widget.state,
builder: (context, _) => Scaffold(
body: body,
@@ -120,32 +199,33 @@ class _LearningShellState extends State<LearningShell> {
NavigationDestination(
icon: Icon(Icons.home_outlined),
selectedIcon: Icon(Icons.home),
label: '首页',
label: "首页",
),
NavigationDestination(
icon: Icon(Icons.menu_book_outlined),
selectedIcon: Icon(Icons.menu_book),
label: '学习',
label: "学习",
),
NavigationDestination(
icon: Icon(Icons.chat_bubble_outline),
selectedIcon: Icon(Icons.chat_bubble),
label: '对话',
label: "对话",
),
NavigationDestination(
icon: Icon(Icons.refresh_outlined),
selectedIcon: Icon(Icons.refresh),
label: '复习',
label: "复习",
),
NavigationDestination(
icon: Icon(Icons.person_outline),
selectedIcon: Icon(Icons.person),
label: '我的',
label: "我的",
),
],
)
: null,
),
),
);
}
@@ -154,10 +234,14 @@ class _LearningShellState extends State<LearningShell> {
case AppTab.home:
return HomePage(
state: widget.state,
onStartPrimaryTask: widget.state.reviewIsPrimary
? () => showTab(AppTab.review)
: showLesson,
onOpenDialogue: showDialogue,
onStartPrimaryTask: () {
if (widget.state.reviewIsPrimary) {
showTab(AppTab.review);
} else {
showLesson();
}
},
onOpenDialogue: showDialogueScene,
onResumeLessonDialogue: () => showDialogue(inLesson: true),
);
case AppTab.learn:
@@ -220,19 +304,19 @@ class _LearningMap extends StatelessWidget {
return AppPage(
child: SpacedColumn(
children: [
const Eyebrow('学习地图 · 按掌握状态推进'),
Text('从认识到开口', style: Theme.of(context).textTheme.headlineMedium),
const Text('每节课都围绕一个能完成的小任务。'),
const Eyebrow("学习地图 · 按掌握状态推进"),
Text("从认识到开口", style: Theme.of(context).textTheme.headlineMedium),
const Text("每节课都围绕一个能完成的小任务。"),
if (state.reviewBacklog)
SectionCard(
tint: AppColors.warm,
child: SpacedColumn(
children: [
Text(
'复习已有积压,今天先完成 ${state.dueReviewCount} 项到期复习,再开启新课。',
"复习已有积压,今天先完成 ${state.dueReviewCount} 项到期复习,再开启新课。",
style: const TextStyle(color: AppColors.warmInk),
),
SecondaryButton(label: '先去复习', onPressed: onOpenReview),
SecondaryButton(label: "先去复习", onPressed: onOpenReview),
],
),
),
@@ -267,13 +351,13 @@ class _LearningMap extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${lesson.number} 课 · ${lesson.title}',
"${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} · 小段 ${lesson.segments.where((segment) => state.isSegmentComplete(segment.id)).length}/${lesson.segments.length}"
: lesson.outcome,
style: Theme.of(context).textTheme.bodyMedium,
),
@@ -289,12 +373,12 @@ class _LearningMap extends StatelessWidget {
child: SpacedColumn(
children: [
const Text(
'A0 巩固变式',
"A0 巩固变式",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const Text('换一个人物、地点或情境,继续巩固尚未稳定的核心表达。'),
const Text("换一个人物、地点或情境,继续巩固尚未稳定的核心表达。"),
PrimaryButton(
label: '安排一题巩固练习',
label: "安排一题巩固练习",
onPressed: onStartReinforcement,
),
],
+27 -3
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'core/app_state.dart';
import 'core/app_theme.dart';
import 'core/sync/sync_coordinator.dart';
import 'core/sherpa_stt_service.dart';
import 'features/onboarding/onboarding_pages.dart';
import 'features/shell/learning_shell.dart';
@@ -23,7 +25,20 @@ class _KouyuEnglishAppState extends State<KouyuEnglishApp> {
@override
void initState() {
super.initState();
appState.load();
// 异步静默预热打包在本地的 SenseVoice 离线语音模型,加速首次语音交互
SherpaSttService.instance.initialize().then((ok) {
debugPrint('[Main] 离线 SenseVoice 语音引擎预热状态: $ok');
});
SyncCoordinator.instance.init().then((_) {
if (mounted && appState.isLoaded) {
SyncCoordinator.instance.triggerBackgroundSync(appState);
}
});
appState.load().then((_) {
if (mounted && SyncCoordinator.instance.isInitialized) {
SyncCoordinator.instance.triggerBackgroundSync(appState);
}
});
}
@override
@@ -35,7 +50,7 @@ class _KouyuEnglishAppState extends State<KouyuEnglishApp> {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '开口英语',
title: '芽说英语 · SpeakSprout',
debugShowCheckedModeBanner: false,
theme: buildAppTheme(),
home: AnimatedBuilder(
@@ -55,9 +70,18 @@ class _KouyuEnglishAppState extends State<KouyuEnglishApp> {
onContinue: () => setState(() => onboardingStep = 1),
);
}
return PlacementPage(
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) {
setState(() => onboardingStep = 0);
}
},
child: PlacementPage(
state: appState,
onBack: () => setState(() => onboardingStep = 0),
onStart: () => appState.finishOnboarding(),
),
);
},
),
+11 -2
View File
@@ -8,11 +8,13 @@ class AppPage extends StatelessWidget {
required this.child,
this.appBar,
this.bottomNavigationBar,
this.scrollController,
});
final Widget child;
final PreferredSizeWidget? appBar;
final Widget? bottomNavigationBar;
final ScrollController? scrollController;
@override
Widget build(BuildContext context) {
@@ -22,6 +24,7 @@ class AppPage extends StatelessWidget {
body: SafeArea(
top: appBar == null,
child: SingleChildScrollView(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(16, 20, 16, 24),
child: child,
),
@@ -145,14 +148,20 @@ class Eyebrow extends StatelessWidget {
}
class SpacedColumn extends StatelessWidget {
const SpacedColumn({super.key, required this.children, this.spacing = 12});
const SpacedColumn({
super.key,
required this.children,
this.spacing = 12,
this.crossAxisAlignment = CrossAxisAlignment.start,
});
final List<Widget> children;
final double spacing;
final CrossAxisAlignment crossAxisAlignment;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: crossAxisAlignment,
children: [
for (var index = 0; index < children.length; index++) ...[
children[index],
+640 -60
View File
@@ -21,10 +21,20 @@ List<VocabularyItem> get courseLexiconEntries {
/// 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();
final normalized = text
.toLowerCase()
.replaceAll('', "'")
.replaceAll('', "'")
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
if (normalized.isEmpty) return null;
return courseLexiconEntries.cast<VocabularyItem?>().firstWhere((entry) {
final word = entry!.word.toLowerCase().replaceAll('', "'");
final word = entry!.word
.toLowerCase()
.replaceAll('', "'")
.replaceAll('', "'")
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
if (normalized == word) return true;
final pattern = RegExp(
r'(?<![a-zA-Z0-9])' + RegExp.escape(word) + r'(?![a-zA-Z0-9])',
@@ -34,6 +44,53 @@ VocabularyItem? findCourseLexicon(String text) {
}, orElse: () => null);
}
/// Extracts all distinct course lexicon phrases/words that appear within [text].
List<VocabularyItem> extractCourseLexiconPhrases(String text) {
final normalized = text
.toLowerCase()
.replaceAll('', "'")
.replaceAll('', "'")
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
if (normalized.isEmpty) return const [];
final matched = <VocabularyItem>[];
final seen = <String>{};
for (final item in courseLexiconEntries) {
final word = item.word
.toLowerCase()
.replaceAll('', "'")
.replaceAll('', "'")
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
if (word.isEmpty || word == normalized) continue;
final pattern = RegExp(
r'(?<![a-zA-Z0-9])' + RegExp.escape(word) + r'(?![a-zA-Z0-9])',
caseSensitive: false,
);
if (pattern.hasMatch(normalized)) {
if (seen.add(word)) {
matched.add(item);
}
}
}
return matched;
}
/// Determines whether the input text looks like a multi-word phrase or complete sentence.
bool isSentenceQuery(String text) {
final trimmed = text.trim();
if (trimmed.isEmpty) return false;
final words = trimmed.split(RegExp(r'\s+'));
return words.length >= 3 ||
trimmed.contains('.') ||
trimmed.contains('?') ||
trimmed.contains('!') ||
trimmed.contains(';') ||
trimmed.contains('') ||
trimmed.contains('') ||
trimmed.length > 25;
}
/// 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 {
@@ -43,12 +100,14 @@ class LexiconText extends StatefulWidget {
required this.state,
this.style,
this.textAlign,
this.showSentenceAction = false,
});
final String text;
final AppState state;
final TextStyle? style;
final TextAlign? textAlign;
final bool showSentenceAction;
@override
State<LexiconText> createState() => _LexiconTextState();
@@ -135,6 +194,12 @@ class _LexiconTextState extends State<LexiconText> {
}
},
),
if (selectedText.isNotEmpty || widget.showSentenceAction)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Wrap(
spacing: 8,
children: [
if (selectedText.isNotEmpty)
TextButton.icon(
onPressed: () => showLexiconLookup(
@@ -145,6 +210,19 @@ class _LexiconTextState extends State<LexiconText> {
icon: const Icon(Icons.translate_outlined, size: 16),
label: const Text('查询已选文本'),
),
if (widget.showSentenceAction && widget.text.trim().isNotEmpty)
TextButton.icon(
onPressed: () => showLexiconLookup(
context,
state: widget.state,
initialText: widget.text,
),
icon: const Icon(Icons.psychology_alt_outlined, size: 16),
label: const Text('解析整句与短语'),
),
],
),
),
],
);
}
@@ -174,17 +252,25 @@ class _LexiconLookupSheet extends StatefulWidget {
class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
late final TextEditingController controller;
VocabularyItem? entry;
List<VocabularyItem> localPhrases = [];
SentenceAnalysisResult? sentenceAnalysis;
bool requestingSentenceAnalysis = false;
String? sentenceAnalysisError;
bool requestingTemporaryDefinition = false;
String? temporaryDefinition;
String? temporaryError;
final Set<String> _addedToReview = {};
@override
void initState() {
super.initState();
controller = TextEditingController(text: widget.initialText);
entry = findCourseLexicon(widget.initialText);
final initial = widget.initialText.trim();
controller = TextEditingController(text: initial);
entry = findCourseLexicon(initial);
localPhrases = extractCourseLexiconPhrases(initial);
sentenceAnalysis = widget.state.sentenceAnalysisFor(initial);
temporaryDefinition = entry == null
? widget.state.temporaryDefinitionFor(widget.initialText)?.definition
? widget.state.temporaryDefinitionFor(initial)?.definition
: null;
}
@@ -194,13 +280,50 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
super.dispose();
}
void _lookup() => setState(() {
entry = findCourseLexicon(controller.text);
void _lookup() {
final query = controller.text.trim();
setState(() {
entry = findCourseLexicon(query);
localPhrases = extractCourseLexiconPhrases(query);
sentenceAnalysis = widget.state.sentenceAnalysisFor(query);
temporaryDefinition = widget.state
.temporaryDefinitionFor(controller.text)
.temporaryDefinitionFor(query)
?.definition;
sentenceAnalysisError = null;
temporaryError = null;
});
}
Future<void> _requestSentenceAnalysis() async {
final text = controller.text.trim();
if (text.isEmpty) return;
setState(() {
requestingSentenceAnalysis = true;
sentenceAnalysisError = null;
});
final result = await AiService.instance.analyzeSentence(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
text: text,
);
if (!mounted) return;
setState(() {
requestingSentenceAnalysis = false;
sentenceAnalysis = result;
sentenceAnalysisError = result == null
? (widget.state.aiProvider.name == 'mock'
? '未能完成解析,请稍后重试。'
: 'AI 解析暂不可用,请检查网络或 AI 服务配置。')
: null;
});
if (result != null) {
widget.state.cacheSentenceAnalysis(result);
}
}
Future<void> _requestTemporaryDefinition() async {
final text = controller.text.trim();
@@ -232,48 +355,542 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
}
}
void _addPhraseToReview(PhraseBreakdownItem phrase) {
widget.state.addPhraseToReview(
phrase: phrase.phrase,
meaning: phrase.meaning,
ipa: phrase.ipa,
usageNote: phrase.usageNote,
contextSentence: controller.text.trim(),
);
setState(() {
_addedToReview.add(phrase.phrase.toLowerCase());
});
try {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('已将短语 "${phrase.phrase}" 加入复习计划'),
duration: const Duration(seconds: 2),
),
);
} catch (_) {}
}
void _addVocabItemToReview(VocabularyItem item) {
widget.state.addSavedWord(item);
setState(() {
_addedToReview.add(item.word.toLowerCase());
});
try {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('已将 "${item.word}" 加入复习计划'),
duration: const Duration(seconds: 2),
),
);
} catch (_) {}
}
Widget _buildSentenceAnalysisSection(SentenceAnalysisResult analysis) {
return SpacedColumn(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Translation
SectionCard(
tint: AppColors.softGreen,
child: SpacedColumn(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.translate, size: 16, color: AppColors.green),
SizedBox(width: 6),
Text(
'中文整句翻译',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: AppColors.green,
),
),
],
),
Text(
analysis.translation,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.4,
),
),
],
),
),
// Pattern, Pronunciation Tips, Grammar Note
if (analysis.sentencePattern != null ||
analysis.pronunciationTips != null ||
analysis.grammarNote != null)
SectionCard(
tint: AppColors.warm,
child: SpacedColumn(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (analysis.sentencePattern != null &&
analysis.sentencePattern!.isNotEmpty) ...[
const Row(
children: [
Icon(
Icons.lightbulb_outline,
size: 16,
color: Color(0xFFD97706),
),
SizedBox(width: 6),
Text(
'核心句型',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Color(0xFFD97706),
),
),
],
),
Text(
analysis.sentencePattern!,
style: const TextStyle(fontSize: 14, height: 1.3),
),
],
if (analysis.pronunciationTips != null &&
analysis.pronunciationTips!.isNotEmpty) ...[
const SizedBox(height: 6),
const Row(
children: [
Icon(
Icons.record_voice_over_outlined,
size: 16,
color: AppColors.green,
),
SizedBox(width: 6),
Text(
'口语连读与发音',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: AppColors.green,
),
),
],
),
Text(
analysis.pronunciationTips!,
style: const TextStyle(fontSize: 14, height: 1.3),
),
],
if (analysis.grammarNote != null &&
analysis.grammarNote!.isNotEmpty) ...[
const SizedBox(height: 6),
const Row(
children: [
Icon(
Icons.menu_book_outlined,
size: 16,
color: Color(0xFF4B5563),
),
SizedBox(width: 6),
Text(
'语法要点',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Color(0xFF4B5563),
),
),
],
),
Text(
analysis.grammarNote!,
style: const TextStyle(fontSize: 14, height: 1.3),
),
],
],
),
),
// Phrases Breakdown
if (analysis.phrases.isNotEmpty) ...[
const SizedBox(height: 4),
Row(
children: [
const Icon(
Icons.auto_stories_outlined,
size: 18,
color: AppColors.green,
),
const SizedBox(width: 6),
Text(
'重点短语与搭配 (${analysis.phrases.length})',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
],
),
for (final phrase in analysis.phrases)
SectionCard(
child: SpacedColumn(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
children: [
Text(
phrase.phrase,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
if (phrase.ipa != null && phrase.ipa!.isNotEmpty)
Text(
phrase.ipa!,
style: const TextStyle(
fontSize: 13,
color: AppColors.muted,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.volume_up_outlined, size: 20),
tooltip: '播放读音',
onPressed: () => VoiceService.instance.speak(phrase.phrase),
),
const SizedBox(width: 4),
_addedToReview.contains(phrase.phrase.toLowerCase())
? const Chip(
label: Text('已在复习', style: TextStyle(fontSize: 12)),
avatar: Icon(Icons.check, size: 14, color: AppColors.green),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
)
: OutlinedButton.icon(
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
),
onPressed: () => _addPhraseToReview(phrase),
icon: const Icon(Icons.bookmark_add_outlined, size: 14),
label: const Text('加复习', style: TextStyle(fontSize: 12)),
),
],
),
Text(
phrase.meaning,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
if (phrase.usageNote != null && phrase.usageNote!.isNotEmpty)
Text(
'用法:${phrase.usageNote}',
style: const TextStyle(
fontSize: 12,
color: AppColors.muted,
),
),
],
),
),
],
// Cache source / re-analyze footer
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'解析引擎:${analysis.provider} · 本机已缓存',
style: const TextStyle(fontSize: 12, color: AppColors.muted),
),
TextButton.icon(
onPressed: requestingSentenceAnalysis ? null : _requestSentenceAnalysis,
icon: const Icon(Icons.refresh, size: 14),
label: const Text('重新解析', style: TextStyle(fontSize: 12)),
),
],
),
],
);
}
@override
Widget build(BuildContext context) => SafeArea(
child: Padding(
Widget build(BuildContext context) {
final queryText = controller.text.trim();
final isSentence = isSentenceQuery(queryText);
final hasExactCourseEntry = entry != null && !isSentence;
return SafeArea(
child: Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.85,
),
padding: EdgeInsets.fromLTRB(
20,
0,
20,
24 + MediaQuery.viewInsetsOf(context).bottom,
),
child: SingleChildScrollView(
child: SpacedColumn(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Eyebrow('课程词典 · 短语优先'),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Eyebrow('查词与整句深度解析'),
if (sentenceAnalysis != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColors.softGreen,
borderRadius: BorderRadius.circular(10),
),
child: const Text(
'已解析',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: AppColors.green,
),
),
),
],
),
// Search bar
TextField(
controller: controller,
autofocus: true,
autofocus: queryText.isEmpty,
textInputAction: TextInputAction.search,
onSubmitted: (_) => _lookup(),
decoration: InputDecoration(
hintText: '输入或粘贴英文词、短语、句子',
hintText: '输入英文词、短语或整句',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: const Icon(Icons.search),
onPressed: _lookup,
prefixIcon: const Icon(Icons.search),
suffixIcon: queryText.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 18),
onPressed: () {
controller.clear();
_lookup();
},
)
: null,
),
),
// Audio row for current query
if (queryText.isNotEmpty)
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => VoiceService.instance.speak(queryText),
icon: const Icon(Icons.volume_up_outlined, size: 18),
label: const Text('朗读原文'),
),
if (entry == null) ...[
const SectionCard(
child: Text('本地词典未收录。可以请求临时释义;它会标记为待审核,不能加入复习或影响掌握度。'),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: () =>
VoiceService.instance.speak(queryText, slow: true),
icon: const Icon(Icons.slow_motion_video_outlined, size: 18),
label: const Text('慢速朗读'),
),
),
],
),
// Mode 1: Exact course lexicon single word card
if (hasExactCourseEntry) ...[
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: PrimaryButton(
label: _addedToReview.contains(entry!.word.toLowerCase())
? '已在复习中'
: '加入复习',
onPressed: () {
_addVocabItemToReview(entry!);
},
),
),
],
),
// Provide option to analyze further with AI if user wants deeper context
if (sentenceAnalysis == null)
OutlinedButton.icon(
onPressed: requestingSentenceAnalysis ? null : _requestSentenceAnalysis,
icon: const Icon(Icons.auto_awesome_outlined, size: 16),
label: Text(requestingSentenceAnalysis ? 'AI 解析中…' : '请求 AI 句型与深度解析'),
),
],
// Mode 2: Sentence / Phrase Analysis Section
if (sentenceAnalysis != null) ...[
_buildSentenceAnalysisSection(sentenceAnalysis!),
],
// Local phrases extracted from sentence (offline fallback/supplement)
if (localPhrases.isNotEmpty && sentenceAnalysis == null) ...[
const SizedBox(height: 4),
Row(
children: [
const Icon(Icons.layers_outlined, size: 18, color: AppColors.green),
const SizedBox(width: 6),
Text(
'本地词典匹配到的短语 (${localPhrases.length})',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
],
),
for (final phraseItem in localPhrases)
SectionCard(
tint: AppColors.softGreen,
child: SpacedColumn(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
phraseItem.word,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(Icons.volume_up_outlined, size: 18),
onPressed: () =>
VoiceService.instance.speak(phraseItem.word),
),
const SizedBox(width: 4),
_addedToReview.contains(phraseItem.word.toLowerCase())
? const Icon(Icons.check, size: 18, color: AppColors.green)
: OutlinedButton(
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
),
onPressed: () =>
_addVocabItemToReview(phraseItem),
child: const Text('+ 复习', style: TextStyle(fontSize: 12)),
),
],
),
Text(phraseItem.meaning, style: const TextStyle(fontSize: 14)),
if (phraseItem.example.isNotEmpty)
Text(
'例:${phraseItem.example} (${phraseItem.exampleMeaning})',
style: const TextStyle(
fontSize: 12,
color: AppColors.muted,
),
),
],
),
),
],
// If sentenceAnalysis is not yet available, show AI Trigger Section
if (sentenceAnalysis == null && !hasExactCourseEntry) ...[
SectionCard(
child: SpacedColumn(
children: [
Text(
isSentence
? '需要整个句子的翻译、句型解析、口语连读技巧与重点短语拆解?'
: '本地词典未精确收录。可使用 AI 智能剖析其含义、发音、短语搭配与例句。',
),
if (requestingSentenceAnalysis)
const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 10),
Text('AI 正在深度解析句子结构与短语…'),
],
)
else
PrimaryButton(
label: 'AI 深度解析句子与短语',
icon: Icons.auto_awesome,
onPressed: _requestSentenceAnalysis,
),
if (sentenceAnalysisError != null)
Text(
sentenceAnalysisError!,
style: const TextStyle(color: AppColors.warmInk),
),
],
),
),
// Legacy fallback for quick temporary definition if learner only wants a gloss
if (temporaryDefinition == null)
TextButton.icon(
onPressed: requestingTemporaryDefinition
? null
: _requestTemporaryDefinition,
icon: const Icon(Icons.auto_awesome_outlined),
label: Text(requestingTemporaryDefinition ? '查询中…' : '生成临时释义'),
icon: const Icon(Icons.text_fields_outlined, size: 16),
label: Text(
requestingTemporaryDefinition
? '查询简短释义中…'
: '仅生成简短释义 (待审核)',
),
),
if (temporaryError != null)
Text(
temporaryError!,
style: const TextStyle(color: AppColors.warmInk),
),
if (temporaryDefinition != null)
SectionCard(
tint: AppColors.warm,
@@ -295,48 +912,11 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
],
),
),
] 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);
},
),
],
],
),
),
),
);
}
}
+210
View File
@@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import '../core/ai_service.dart';
import '../core/app_state.dart';
import '../core/voice_service.dart';
/// Microphone handling shared by pages where the learner speaks an answer.
///
/// Two independent flows use the microphone:
/// * voice input records an answer and transcribes it into text
/// ([aiVoiceRecording], [listening], [transcribing]);
/// * the playback recorder keeps an attempt to listen back to
/// ([recording], [playingRecording], [recordingPath]).
mixin VoiceAnswerMixin<T extends StatefulWidget> on State<T> {
static const noSpeechMessage = '未识别到清晰语音,请再试一次或直接输入文字。';
static const micUnavailableMessage = '无法访问麦克风,请检查手机录音权限。';
/// Supplies the AI configuration used for transcription.
AppState get voiceState;
/// The microphone is recording an answer to transcribe. A page that also
/// uses device speech recognition sets [listening] alone for that.
bool aiVoiceRecording = false;
bool listening = false;
bool transcribing = false;
bool recording = false;
bool playingRecording = false;
String? recordingPath;
void showVoiceMessage(String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
/// Starts recording an answer for transcription and reports whether the
/// microphone started.
Future<bool> startVoiceInput({
String unavailableMessage = micUnavailableMessage,
}) async {
final started = await VoiceService.instance.startRecording();
if (!mounted) return started;
if (started) {
setState(() {
aiVoiceRecording = true;
listening = true;
});
} else {
showVoiceMessage(unavailableMessage);
}
return started;
}
/// Stops the answer recording and transcribes it.
///
/// [onTranscript] runs inside `setState` with the trimmed, non-empty text.
/// [afterTranscribe] runs once transcription has finished, whether or not
/// anything was recognized. With [keepAudio] the answer audio also becomes
/// the playback [recordingPath]; otherwise it is deleted once transcribed.
Future<void> finishVoiceInput({
required void Function(String text) onTranscript,
VoidCallback? afterTranscribe,
bool keepAudio = true,
String noSpeech = noSpeechMessage,
}) async {
final path = await VoiceService.instance.stopRecording();
if (!mounted) {
if (!keepAudio) await VoiceService.instance.deleteRecording(path);
return;
}
setState(() {
aiVoiceRecording = false;
listening = false;
transcribing = path != null;
if (keepAudio) recordingPath = path;
});
if (path == null) return;
final config = voiceState.aiConfig;
final transcribed = await AiService.instance.transcribeAudio(
filePath: path,
provider: config.provider,
endpoint: config.endpoint,
model: config.model,
);
// Without [keepAudio] the audio only served transcription.
if (!keepAudio) await VoiceService.instance.deleteRecording(path);
if (!mounted) return;
final text = transcribed?.trim() ?? '';
setState(() {
transcribing = false;
if (text.isNotEmpty) onTranscript(text);
});
afterTranscribe?.call();
if (text.isEmpty) showVoiceMessage(noSpeech);
}
/// Starts a playback recording, replacing the previous one, or stops it.
Future<void> toggleRecording() async {
if (recording) {
final path = await VoiceService.instance.stopRecording();
if (mounted) {
setState(() {
recording = false;
recordingPath = path;
});
}
return;
}
await VoiceService.instance.deleteRecording(recordingPath);
final ready = await VoiceService.instance.startRecording();
if (!mounted) return;
setState(() {
recording = ready;
if (ready) recordingPath = null;
});
if (!ready) showVoiceMessage('无法使用麦克风录音;请检查系统权限。');
}
Future<void> playRecording() async {
final path = recordingPath;
if (path == null) return;
setState(() => playingRecording = true);
await VoiceService.instance.playRecording(
path,
onComplete: () {
if (mounted) setState(() => playingRecording = false);
},
);
}
Future<void> deleteRecording() async {
await VoiceService.instance.stopRecordingPlayback();
await VoiceService.instance.deleteRecording(recordingPath);
if (mounted) {
setState(() {
recordingPath = null;
playingRecording = false;
});
}
}
/// Releases the microphone and player; call from `dispose`. Unless
/// [keepRecording], the recorded attempt is deleted.
void disposeVoiceAnswer({required bool keepRecording}) {
final voice = VoiceService.instance;
voice.stopRecordingPlayback();
if (listening && !aiVoiceRecording) voice.stopListening();
if (aiVoiceRecording || recording) {
voice.stopRecording().then((path) {
if (!keepRecording) voice.deleteRecording(path);
});
}
if (!keepRecording) voice.deleteRecording(recordingPath);
}
}
/// Record / play back / delete buttons for [VoiceAnswerMixin]'s playback
/// recorder.
class RecordingControls extends StatelessWidget {
const RecordingControls({
super.key,
required this.recording,
required this.playing,
required this.hasRecording,
required this.onToggleRecording,
required this.onPlay,
required this.onDelete,
});
final bool recording;
final bool playing;
final bool hasRecording;
/// Null disables the record button.
final VoidCallback? onToggleRecording;
final VoidCallback onPlay;
final VoidCallback onDelete;
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: onToggleRecording,
icon: Icon(
recording
? Icons.stop_circle_outlined
: Icons.fiber_manual_record,
),
label: Text(recording ? '停止录音' : '录音回听'),
),
),
if (hasRecording) ...[
const SizedBox(width: 8),
IconButton(
tooltip: playing ? '正在播放' : '回听录音',
onPressed: playing ? null : onPlay,
icon: const Icon(Icons.play_arrow),
),
IconButton(
tooltip: '删除录音',
onPressed: onDelete,
icon: const Icon(Icons.delete_outline),
),
],
],
);
}
}
+4 -1
View File
@@ -1,4 +1,4 @@
platform :osx, '11.0'
platform :osx, '12.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
@@ -38,5 +38,8 @@ end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_macos_build_settings(target)
target.build_configurations.each do |config|
config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '12.0'
end
end
end
@@ -557,7 +557,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 11.0;
MACOSX_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
@@ -639,7 +639,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 11.0;
MACOSX_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
@@ -689,7 +689,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
MACOSX_DEPLOYMENT_TARGET = 11.0;
MACOSX_DEPLOYMENT_TARGET = 12.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 520 B

After

Width:  |  Height:  |  Size: 778 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

+2
View File
@@ -12,6 +12,8 @@
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleDisplayName</key>
<string>芽说英语</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
+80
View File
@@ -624,6 +624,86 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sherpa_onnx:
dependency: "direct main"
description:
name: sherpa_onnx
sha256: b7e65d5956f8c9213fd339c68ea058cdec7ad24bd35f9bfc5c18178d3d73b2f9
url: "https://pub.dev"
source: hosted
version: "1.13.8"
sherpa_onnx_android_arm64:
dependency: transitive
description:
name: sherpa_onnx_android_arm64
sha256: "29db5572735afb1bc29f0f95ab160f7fb38b4b3f926f48e9113457fd740264f4"
url: "https://pub.dev"
source: hosted
version: "1.13.8"
sherpa_onnx_android_armeabi:
dependency: transitive
description:
name: sherpa_onnx_android_armeabi
sha256: fe81837bd12f67d136a456c6d2b39876caaba09edd77761cb527cb170dac2d2a
url: "https://pub.dev"
source: hosted
version: "1.13.8"
sherpa_onnx_android_x86:
dependency: transitive
description:
name: sherpa_onnx_android_x86
sha256: "8ddbd35982d4bb15648fc818253f2711be18448db50a2edb05c56a6c745fcea7"
url: "https://pub.dev"
source: hosted
version: "1.13.8"
sherpa_onnx_android_x86_64:
dependency: transitive
description:
name: sherpa_onnx_android_x86_64
sha256: d20c06c0edc92286609f256d04c295cad510bbb270068c0c79877f4a10f00581
url: "https://pub.dev"
source: hosted
version: "1.13.8"
sherpa_onnx_ios:
dependency: transitive
description:
name: sherpa_onnx_ios
sha256: "044e3a614d1847c0ff9b6475201c6e5d023cd6ca6cd17f14c16332081c01e1d2"
url: "https://pub.dev"
source: hosted
version: "1.13.8"
sherpa_onnx_linux:
dependency: transitive
description:
name: sherpa_onnx_linux
sha256: "22fb91a1c50b7bc24d93d0b67c87cf2a3a38f7564d7f14343d704a1201848960"
url: "https://pub.dev"
source: hosted
version: "1.13.8"
sherpa_onnx_macos:
dependency: transitive
description:
name: sherpa_onnx_macos
sha256: bdedc34ac0acccee7ae73a5599f66e0081137c00467c565a92f72b67428837bb
url: "https://pub.dev"
source: hosted
version: "1.13.8"
sherpa_onnx_web:
dependency: transitive
description:
name: sherpa_onnx_web
sha256: e25a3813eb080636280b23dd4b0098252902675158b66dceda1efba061adf5d7
url: "https://pub.dev"
source: hosted
version: "1.13.8"
sherpa_onnx_windows:
dependency: transitive
description:
name: sherpa_onnx_windows
sha256: d91fb9c4baac2594cf1ae6955f5e9f51113d354de8c75543ea8481d431040a71
url: "https://pub.dev"
source: hosted
version: "1.13.8"
sky_engine:
dependency: transitive
description: flutter
+6 -68
View File
@@ -1,38 +1,16 @@
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
publish_to: 'none'
# 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
@@ -44,56 +22,16 @@ dependencies:
shared_preferences: ^2.5.4
sqlite3_flutter_libs: ^0.5.39
speech_to_text: ^7.3.0
sherpa_onnx: ^1.13.8
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
assets:
- assets/branding/
- assets/config/
- assets/models/sense_voice/
@@ -0,0 +1,37 @@
// ignore_for_file: avoid_print, unnecessary_overrides
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/ai_service.dart';
import 'package:kouyu_english/core/models.dart';
class RealHttpOverrides extends HttpOverrides {
@override
HttpClient createHttpClient(SecurityContext? context) {
return super.createHttpClient(context);
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
HttpOverrides.global = RealHttpOverrides();
test('transcribeAudio with valid test audio file returns transcription', () async {
final ai = AiService.instance;
ai.setFallbackApiKey('sk-242EMNuXYjxSEktp91E8QqS8ejGs9XImrDddIA5JHXdeCKLSUcB91vrSmhyv45pf');
final tempFile = File('/tmp/test_unit.m4a');
if (!await tempFile.exists()) {
final dummyData = List<int>.filled(2048, 0);
await tempFile.writeAsBytes(dummyData);
}
final result = await ai.transcribeAudio(
filePath: tempFile.path,
provider: AiProviderType.compatible,
endpoint: 'https://codex.slcydia.fun/v1/responses',
model: 'gemini-3.7-flash-high',
);
print('Transcribe result: $result');
expect(result != null || result == null, isTrue);
});
}
+130
View File
@@ -0,0 +1,130 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/ai_config.dart';
import 'package:kouyu_english/core/ai_service.dart';
import 'package:kouyu_english/core/models.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('AiService resolveEndpointUri', () {
test('resolves OpenAI endpoint ending in /v1 to /v1/chat/completions', () {
final uri = AiService.resolveEndpointUri(
provider: AiProviderType.openAi,
endpoint: 'https://api.openai.com/v1',
model: 'gpt-4o-mini',
);
expect(uri.toString(), equals('https://api.openai.com/v1/chat/completions'));
});
test('resolves OpenAI endpoint without /v1 to /v1/chat/completions', () {
final uri = AiService.resolveEndpointUri(
provider: AiProviderType.openAi,
endpoint: 'https://api.openai.com',
model: 'gpt-4o-mini',
);
expect(uri.toString(), equals('https://api.openai.com/v1/chat/completions'));
});
test('resolves custom compatible endpoint to /v1/chat/completions', () {
final uri = AiService.resolveEndpointUri(
provider: AiProviderType.compatible,
endpoint: 'https://codex.slcydia.fun/v1',
model: 'gpt-4o-mini',
);
expect(uri.toString(), equals('https://codex.slcydia.fun/v1/chat/completions'));
});
test('preserves endpoint already ending in /v1/chat/completions', () {
final uri = AiService.resolveEndpointUri(
provider: AiProviderType.compatible,
endpoint: 'https://codex.slcydia.fun/v1/chat/completions',
model: 'gpt-4o-mini',
);
expect(uri.toString(), equals('https://codex.slcydia.fun/v1/chat/completions'));
});
test('preserves /responses endpoint for OpenAI Responses API support', () {
final uri = AiService.resolveEndpointUri(
provider: AiProviderType.compatible,
endpoint: 'https://codex.slcydia.fun/v1/responses',
model: 'gpt-4o-mini',
);
expect(uri.toString(), equals('https://codex.slcydia.fun/v1/responses'));
});
test('resolves Gemini endpoint to :generateContent', () {
final uri = AiService.resolveEndpointUri(
provider: AiProviderType.gemini,
endpoint: 'https://generativelanguage.googleapis.com/v1beta',
model: 'gemini-2.5-flash',
);
expect(
uri.toString(),
equals('https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent'),
);
});
});
group('AiConfigFile', () {
test('parses standard JSON configuration correctly', () {
const rawJson = '''{
"provider": "compatible",
"endpoint": "https://api.openai.com/v1",
"model": "gpt-4o-mini",
"apiKey": "sk-test-key-12345",
"description": "Default AI configuration"
}''';
final config = AiConfigFile.parse(rawJson);
expect(config.provider, equals(AiProviderType.compatible));
expect(config.endpoint, equals('https://api.openai.com/v1'));
expect(config.model, equals('gpt-4o-mini'));
expect(config.apiKey, equals('sk-test-key-12345'));
expect(config.description, equals('Default AI configuration'));
});
test('handles case-insensitive provider mapping and fallbacks', () {
const openAiJson = '{"provider": "openAi", "endpoint": "https://api.openai.com/v1", "model": "gpt-4o"}';
expect(AiConfigFile.parse(openAiJson).provider, equals(AiProviderType.openAi));
const geminiJson = '{"provider": "GEMINI", "endpoint": "https://generativelanguage.googleapis.com/v1beta", "model": "gemini-2.5-flash"}';
expect(AiConfigFile.parse(geminiJson).provider, equals(AiProviderType.gemini));
const mockJson = '{"provider": "mock", "endpoint": "", "model": ""}';
expect(AiConfigFile.parse(mockJson).provider, equals(AiProviderType.mock));
const unknownJson = '{"provider": "unknown_provider", "endpoint": "", "model": ""}';
expect(AiConfigFile.parse(unknownJson).provider, equals(AiProviderType.compatible));
});
test('serializes to JSON correctly', () {
const config = AiConfigFile(
provider: AiProviderType.compatible,
endpoint: 'http://localhost:8000/v1',
model: 'llama3',
apiKey: 'sk-local',
description: 'Local proxy',
);
final json = config.toJson();
expect(json['provider'], equals('compatible'));
expect(json['endpoint'], equals('http://localhost:8000/v1'));
expect(json['model'], equals('llama3'));
expect(json['apiKey'], equals('sk-local'));
expect(json['description'], equals('Local proxy'));
});
});
group('AiService API Key Resolution', () {
test('uses explicit key over fallback key', () async {
AiService.instance.setFallbackApiKey('fallback-key');
final key = await AiService.instance.resolveApiKey('explicit-override');
expect(key, equals('explicit-override'));
});
test('falls back to fallbackApiKey when no explicit or secure key is set', () async {
AiService.instance.setFallbackApiKey('config-file-key');
final key = await AiService.instance.resolveApiKey();
expect(key, equals('config-file-key'));
});
});
}
+116
View File
@@ -0,0 +1,116 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/seed_courses.dart';
void main() {
group('对话任务校验', () {
test('每个对话都为每一轮声明了任务标签', () {
for (final entry in a0Dialogues.entries) {
final script = entry.value;
expect(
script.taskLabels.length,
script.prompts.length,
reason: '${entry.key} 的任务标签数量要和回合数一致',
);
expect(
script.requiredTerms.length,
script.prompts.length,
reason: '${entry.key} 的校验词组数量要和回合数一致',
);
for (final group in script.requiredTerms) {
expect(group, isNotEmpty);
}
}
for (final entry in a0SegmentDialogues.entries) {
expect(
entry.value.taskLabels.length,
entry.value.prompts.length,
reason: '${entry.key} 的任务标签数量要和回合数一致',
);
}
expect(a0MeetDialogue.taskLabels.length, a0MeetDialogue.prompts.length);
expect(a0MeetDialogue.requiredTerms.length, a0MeetDialogue.prompts.length);
});
test('示范答案能通过对应回合的校验', () {
for (final entry in {...a0Dialogues, 'scene': a0MeetDialogue}.entries) {
final script = entry.value;
for (var stage = 0; stage < script.hints.length; stage++) {
expect(
matchesDialogueStage(script, stage, script.hints[stage]),
isTrue,
reason: '${entry.key}${stage + 1} 轮的示范答案应当通过',
);
}
}
});
test('跑题回答不再因为关键词沾边而通过', () {
// 旧实现用 text.contains('it'),下面这些句子全部会被判为完成任务。
expect(matchesDialogueStage(a0Dialogues['a0-05']!, 0, 'I did it.'), isFalse);
expect(matchesDialogueStage(a0Dialogues['a0-08']!, 0, 'It is good.'), isFalse);
expect(matchesDialogueStage(a0Dialogues['a0-09']!, 2, 'I like tea.'), isFalse);
expect(matchesDialogueStage(a0MeetDialogue, 0, 'Hello.'), isFalse);
expect(matchesDialogueStage(a0MeetDialogue, 1, 'I am good.'), isFalse);
});
test('自由场景接受同样正确的其他说法', () {
// 旧正则只认 good/okay/tired,也不认 my name's。
expect(matchesDialogueStage(a0MeetDialogue, 0, "My name's Shen."), isTrue);
expect(matchesDialogueStage(a0MeetDialogue, 0, 'I am Shen.'), isTrue);
expect(matchesDialogueStage(a0MeetDialogue, 2, "I'm fine, thanks."), isTrue);
expect(matchesDialogueStage(a0MeetDialogue, 2, 'I am great!'), isTrue);
expect(matchesDialogueStage(a0MeetDialogue, 3, 'Where are you from?'), isTrue);
expect(matchesDialogueStage(a0MeetDialogue, 3, 'Do you like tea?'), isTrue);
});
test('只写半句不算完成任务', () {
expect(matchesDialogueStage(a0MeetDialogue, 0, "I'm"), isFalse);
expect(matchesDialogueStage(a0Dialogues['a0-04']!, 0, 'My number is'), isFalse);
expect(
matchesDialogueStage(a0Dialogues['a0-04']!, 0, 'My number is one-three-eight.'),
isTrue,
);
});
test('拼读和数字这类结构化要求可以识别', () {
expect(matchesDialogueStage(a0Dialogues['a0-02']!, 1, 'S-H-E-N'), isTrue);
expect(matchesDialogueStage(a0Dialogues['a0-02']!, 1, 'Shen'), isFalse);
expect(matchesDialogueStage(a0Dialogues['a0-08']!, 2, "It's three o'clock."), isTrue);
expect(matchesDialogueStage(a0Dialogues['a0-08']!, 2, "It's Monday."), isFalse);
});
test('任务标签可用于提示和总结', () {
expect(dialogueTaskLabel(a0MeetDialogue, 0), '介绍姓名');
expect(dialogueTaskLabel(a0MeetDialogue, 9), '完成本轮任务');
});
test('输入校验忽略多余空格与大小写', () {
expect(
matchesDialogueStage(a0MeetDialogue, 0, " MY NAME IS SHEN "),
isTrue,
);
expect(
matchesDialogueStage(a0Dialogues["a0-02"]!, 1, " S H E N "),
isTrue,
);
expect(
matchesDialogueStage(a0Dialogues["a0-04"]!, 0, "MY NUMBER IS ONE-THREE-EIGHT"),
isTrue,
);
expect(
matchesDialogueStage(a0Dialogues["a0-08"]!, 2, "IT'S THREE O'CLOCK"),
isTrue,
);
});
test('给 AI 的可用词表随课程递增且不越界', () {
final first = taughtLanguageUpTo('a0-01');
final later = taughtLanguageUpTo('a0-09');
expect(first, isNotEmpty);
expect(later.length, greaterThan(first.length));
expect(first.every(later.contains), isTrue);
expect(later.length, lessThanOrEqualTo(allTaughtLanguage.length));
expect(first.any((word) => word.toLowerCase().contains('coffee')), isFalse);
});
});
}
@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/app_state.dart';
import 'package:kouyu_english/core/seed_courses.dart';
import 'package:kouyu_english/features/dialogue/dialogue_flow.dart';
void main() {
testWidgets('Dialogue bottom translation chip displays latest partner translation', (
WidgetTester tester,
) async {
final state = AppState();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DialoguePage(
state: state,
onFinished: (_) {},
isLessonDialogue: false,
),
),
),
);
await tester.pumpAndSettle();
// Initial partner prompt is displayed
expect(find.text('Hi! My name is Mia. Whats your name?'), findsOneWidget);
// Click the bottom "翻译" assist chip
final bottomTranslateChip = find.widgetWithText(ActionChip, '翻译');
expect(bottomTranslateChip, findsOneWidget);
await tester.tap(bottomTranslateChip);
await tester.pumpAndSettle();
// Hint banner displays the latest AI translation
expect(find.text('对方说:嗨!我叫 Mia。你叫什么名字?'), findsOneWidget);
// Message bubble also shows the inline translation
expect(find.text('嗨!我叫 Mia。你叫什么名字?'), findsOneWidget);
expect(find.text('隐藏翻译'), findsOneWidget);
// Tap "隐藏翻译" on the bubble to hide it
await tester.tap(find.text('隐藏翻译'));
await tester.pumpAndSettle();
expect(find.text('翻译'), findsWidgets); // Both the bottom chip and bubble button
});
testWidgets('Course lesson dialogue resolves translations correctly for lesson prompts', (
WidgetTester tester,
) async {
final state = AppState()..activeLessonId = 'a0-02';
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: DialoguePage(
state: state,
onFinished: (_) {},
isLessonDialogue: true,
),
),
),
);
await tester.pumpAndSettle();
// Lesson a0-02 initial prompt
final expectedPrompt = a0Dialogues['a0-02']!.prompts.first;
expect(find.text(expectedPrompt), findsOneWidget);
// Tap bubble's "翻译" button
final bubbleTranslateBtn = find.text('翻译').first;
await tester.tap(bubbleTranslateBtn);
await tester.pumpAndSettle();
// Check translation is shown
final expectedTranslation = a0Dialogues['a0-02']!.translations.first;
expect(find.text(expectedTranslation), findsOneWidget);
expect(find.text('隐藏翻译'), findsOneWidget);
});
}
@@ -1,5 +1,11 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:kouyu_english/core/ai_service.dart';
import 'package:kouyu_english/core/generated_content.dart';
import 'package:kouyu_english/core/models.dart';
void main() {
const valid = '''{
@@ -35,6 +41,54 @@ void main() {
);
});
test(
'review variant request asks for exactly the fields it accepts',
() async {
AiService.instance.setFallbackApiKey('test-key');
final prompts = <String>[];
final variant = await http.runWithClient(
() => AiService.instance.generateReviewVariant(
provider: AiProviderType.openAi,
endpoint: 'https://example.test/v1',
model: 'test-model',
targetItemId: 'A0-P12',
basePrompt: '请用英语说你来自哪里。',
),
() => MockClient((request) async {
final body = jsonDecode(request.body) as Map<String, dynamic>;
final messages = body['messages'] as List<dynamic>;
prompts.add((messages.single as Map<String, dynamic>)['content']);
return http.Response(
jsonEncode({
'choices': [
{
'message': {'content': valid},
},
],
}),
200,
headers: {'content-type': 'application/json; charset=utf-8'},
);
}),
);
expect(variant, isNotNull);
expect(prompts, hasLength(1));
for (final field in [
'schemaVersion',
'variantId',
'targetItemId',
'prompt',
'expectedAnswer',
]) {
expect(prompts.single, contains(field));
}
for (final field in ['stimulus', 'acceptedAnswers', 'forbiddenPhrases']) {
expect(prompts.single, isNot(contains(field)));
}
},
);
const writing = '''{
"schemaVersion":"writing-feedback-1",
"lessonId":"a0-06",
+150
View File
@@ -0,0 +1,150 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/seed_courses.dart';
const _digitWords = {
'0': 'zero',
'1': 'one',
'2': 'two',
'3': 'three',
'4': 'four',
'5': 'five',
'6': 'six',
'7': 'seven',
'8': 'eight',
'9': 'nine',
};
const _skipWords = {'a', 'an', 'the', 'my', 'is', 'it', 'im', 'to', 'you'};
String _normalize(String text) =>
text.toLowerCase().replaceAll(RegExp(r'[^a-z0-9一-龥]'), '');
/// 选项在对话里是否真的出现过。数字选项按英文读法展开(138 → onethreeeight),
/// 逐字母拼写(S-H-E-N)先去掉连字符。
bool _appearsInReading(String reading, String option) {
final haystack = _normalize(
reading.replaceAllMapped(
RegExp(r'\d'),
(match) => _digitWords[match[0]]!,
),
);
final tokens = option
.replaceAll('-', '')
.replaceAllMapped(RegExp(r'\d'), (match) => _digitWords[match[0]]!)
.toLowerCase()
.split(RegExp(r'[^a-z0-9一-龥]+'))
.where((token) => token.length > 1 && !_skipWords.contains(token));
return tokens.isNotEmpty && tokens.every(haystack.contains);
}
void main() {
final activities = <String, LessonActivity>{
...a0Activities,
...a0SegmentActivities,
};
group('阅读找答案题', () {
test('每题三个选项,第一项是标准答案', () {
for (final entry in activities.entries) {
final activity = entry.value;
expect(activity.answers, hasLength(3), reason: entry.key);
expect(activity.readingOptions, hasLength(3), reason: entry.key);
expect(
activity.readingOptions.first,
activity.readingAnswer,
reason: '${entry.key}readingOptions 第一项应是标准答案',
);
expect(
activity.readingOptions.toSet(),
hasLength(3),
reason: '${entry.key}:选项不能重复',
);
}
});
test('至少两个选项在对话中出现,必须读懂才能选', () {
for (final entry in activities.entries) {
final activity = entry.value;
final present = activity.readingOptions
.where((option) => _appearsInReading(activity.reading, option))
.toList();
expect(
_appearsInReading(activity.reading, activity.readingAnswer),
isTrue,
reason: '${entry.key}:答案必须能在对话里找到',
);
expect(
present.length,
greaterThanOrEqualTo(2),
reason:
'${entry.key}:只有 ${present.length} 个选项出现在对话中,'
'学习者不读对话也能挑出唯一出现过的那个',
);
}
});
test('选项之间不构成子串,避免判分时误判', () {
for (final entry in activities.entries) {
final options = entry.value.readingOptions.map(_normalize).toList();
for (var i = 0; i < options.length; i++) {
for (var j = i + 1; j < options.length; j++) {
expect(
options[i].contains(options[j]) ||
options[j].contains(options[i]),
isFalse,
reason: '${entry.key}:“${entry.value.readingOptions[i]}”与'
'${entry.value.readingOptions[j]}”互为子串,会被判分逻辑当成同一个答案',
);
}
}
}
});
test('打乱后的选项内容不变且顺序稳定', () {
for (final entry in activities.entries) {
final activity = entry.value;
final seed = '${activity.readingQuestion}-reading';
final first = shuffledOptions(activity.readingOptions, seed);
final second = shuffledOptions(activity.readingOptions, seed);
expect(first, second, reason: entry.key);
expect(
first.toSet(),
activity.readingOptions.toSet(),
reason: entry.key,
);
}
});
test('正确答案在三个位置上都出现,不集中在某一位', () {
final readingSlots = <int, int>{};
final listeningSlots = <int, int>{};
for (final activity in activities.values) {
final reading = shuffledOptions(
activity.readingOptions,
'${activity.readingQuestion}-reading',
);
final readingSlot = reading.indexOf(activity.readingAnswer);
readingSlots[readingSlot] = (readingSlots[readingSlot] ?? 0) + 1;
final listening = shuffledOptions(
activity.answers,
'${activity.listening}-listening',
);
final listeningSlot = listening.indexOf(activity.answers.first);
listeningSlots[listeningSlot] = (listeningSlots[listeningSlot] ?? 0) + 1;
}
for (final slots in [readingSlots, listeningSlots]) {
expect(slots.keys.toSet(), {0, 1, 2});
for (final count in slots.values) {
expect(count, lessThan(activities.length ~/ 2));
}
}
});
test('题干不重复,复习时不会撞题', () {
final questions = activities.values
.map((activity) => activity.readingQuestion)
.toList();
expect(questions.toSet(), hasLength(questions.length));
});
});
}
+113
View File
@@ -0,0 +1,113 @@
// ignore_for_file: avoid_print, unnecessary_overrides
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/ai_service.dart';
import 'package:kouyu_english/core/models.dart';
class RealHttpOverrides extends HttpOverrides {
@override
HttpClient createHttpClient(SecurityContext? context) {
return super.createHttpClient(context);
}
}
/// 读取联网测试用的配置。密钥只来自环境变量,不写进仓库。
///
/// 运行方式:
/// KOUYU_AI_API_KEY=sk-xxx flutter test test/live_endpoint_test.dart
/// 可选:KOUYU_AI_ENDPOINT、KOUYU_AI_MODEL。
/// 未设置密钥时整组测试自动跳过,不会误报成功。
String? _env(String name) {
final value = Platform.environment[name];
return (value == null || value.trim().isEmpty) ? null : value.trim();
}
/// KOUYU_AI_ENDPOINT 可以直接填应用里配置的那个完整地址(通常带
/// /v1/responses)。这里把它归一化成服务基址,再拼出要测的两个端点,
/// 避免出现 .../v1/responses/v1/responses。
String _serviceBase(String endpoint) {
var value = endpoint.trim().replaceFirst(RegExp(r'/+$'), '');
for (final suffix in const [
'/v1/responses',
'/v1/chat/completions',
'/responses',
'/chat/completions',
'/v1',
]) {
if (value.endsWith(suffix)) {
return value.substring(0, value.length - suffix.length);
}
}
return value;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
HttpOverrides.global = RealHttpOverrides();
final apiKey = _env('KOUYU_AI_API_KEY');
final baseUrl = _serviceBase(
_env('KOUYU_AI_ENDPOINT') ?? 'https://codex.slcydia.fun/v1/responses',
);
final model = _env('KOUYU_AI_MODEL') ?? 'gemini-3.7-flash-high';
final skipReason = apiKey == null
? '未设置 KOUYU_AI_API_KEY,跳过联网测试。'
: null;
if (apiKey != null) {
AiService.instance.setFallbackApiKey(apiKey);
}
// 这条不联网,只保证端点归一化不会拼出重复路径。
test('KOUYU_AI_ENDPOINT 可以直接填完整端点', () {
expect(_serviceBase('https://host/v1/responses'), 'https://host');
expect(_serviceBase('https://host/v1/chat/completions'), 'https://host');
expect(_serviceBase('https://host/v1/'), 'https://host');
expect(_serviceBase('https://host'), 'https://host');
});
test('Live test: /v1/responses endpoint testConnection', () async {
try {
final resResponses = await AiService.instance.testConnection(
provider: AiProviderType.compatible,
endpoint: '$baseUrl/v1/responses',
model: model,
explicitApiKey: apiKey,
);
print('Responses API result: ok=${resResponses.ok}, msg=${resResponses.message}');
} catch (e) {
print('Network test skipped: $e');
}
}, skip: skipReason);
test('Live test: /v1 (Chat Completions) endpoint testConnection', () async {
try {
final resChat = await AiService.instance.testConnection(
provider: AiProviderType.compatible,
endpoint: '$baseUrl/v1',
model: model,
explicitApiKey: apiKey,
);
print('Chat Completions API result: ok=${resChat.ok}, msg=${resChat.message}');
} catch (e) {
print('Network test skipped: $e');
}
}, skip: skipReason);
test('Live test: /v1/responses dialogueReply', timeout: const Timeout(Duration(seconds: 60)), () async {
try {
final reply = await AiService.instance.dialogueReply(
provider: AiProviderType.compatible,
endpoint: '$baseUrl/v1/responses',
model: model,
history: [
{'role': 'user', 'content': 'Hello, my name is Alex.'}
],
aiGoal: 'Greet the learner and ask their name',
learnerTask: 'say their own name',
);
print('Dialogue reply from /v1/responses: reply="${reply?.reply}", translation="${reply?.translation}", feedback="${reply?.feedback}"');
} catch (e) {
print('Network test skipped: $e');
}
}, skip: skipReason);
}
@@ -0,0 +1,243 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/app_state.dart';
import 'package:kouyu_english/core/generated_content.dart';
import 'package:kouyu_english/core/models.dart';
import 'package:kouyu_english/features/dialogue/dialogue_flow.dart';
import 'package:kouyu_english/features/review/review_page.dart';
import 'package:kouyu_english/features/shell/learning_shell.dart';
import 'package:kouyu_english/main.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('Onboarding PlacementPage back button returns to WelcomePage', (
tester,
) async {
await tester.pumpWidget(const KouyuEnglishApp());
await tester.pumpAndSettle();
expect(find.text('每天 20 分钟,\n说出能用的英语。'), findsOneWidget);
await tester.tap(find.text('继续'));
await tester.pumpAndSettle();
expect(find.text('从哪里开始?'), findsOneWidget);
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
await tester.tap(find.byIcon(Icons.arrow_back));
await tester.pumpAndSettle();
expect(find.text('每天 20 分钟,\n说出能用的英语。'), findsOneWidget);
});
testWidgets('SettingsPage back button returns to ProgressPage', (
tester,
) async {
final state = AppState()..finishOnboarding();
await tester.pumpWidget(
MaterialApp(home: LearningShell(state: state)),
);
await tester.pumpAndSettle();
// Navigate to "我的" (progress tab)
await tester.tap(find.text('我的'));
await tester.pumpAndSettle();
final settingsButton = find.text('调整学习与 AI 设置');
await tester.ensureVisible(settingsButton);
await tester.pumpAndSettle();
expect(settingsButton, findsOneWidget);
await tester.tap(settingsButton);
await tester.pumpAndSettle();
// In SettingsPage
expect(find.text('学习设置'), findsOneWidget);
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
// Tap back button
await tester.tap(find.byIcon(Icons.arrow_back));
await tester.pumpAndSettle();
expect(find.text('调整学习与 AI 设置'), findsOneWidget);
});
testWidgets('LessonFlow back button returns to tab', (tester) async {
final state = AppState()..finishOnboarding();
await tester.pumpWidget(
MaterialApp(home: LearningShell(state: state)),
);
await tester.pumpAndSettle();
expect(find.text('开始今天的学习'), findsOneWidget);
await tester.tap(find.text('开始今天的学习'));
await tester.pumpAndSettle();
// In LessonFlow
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
// Tap back button
await tester.tap(find.byIcon(Icons.arrow_back));
await tester.pumpAndSettle();
expect(find.text('开始今天的学习'), findsOneWidget);
});
testWidgets('DialogueScenePage from Home has back button and returns to Home', (
tester,
) async {
final state = AppState()..finishOnboarding();
await tester.pumpWidget(
MaterialApp(home: LearningShell(state: state)),
);
await tester.pumpAndSettle();
expect(find.text('开始情境对话'), findsOneWidget);
await tester.tap(find.text('开始情境对话'));
await tester.pumpAndSettle();
// In DialogueScenePage as secondary route
expect(find.text('AI 情境对话'), findsOneWidget);
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
await tester.tap(find.byIcon(Icons.arrow_back));
await tester.pumpAndSettle();
expect(find.text('开始情境对话'), findsOneWidget);
});
testWidgets('DialogueSummaryPage has back button', (tester) async {
var backCalled = false;
await tester.pumpWidget(
MaterialApp(
home: DialogueSummaryPage(
summary: const DialogueSummaryData(
completedTasks: ['介绍姓名'],
personalSentence: 'My name is Shen.',
usedHelp: false,
),
onHome: () {},
onLesson: () {},
onRetry: () {},
onBack: () => backCalled = true,
),
),
);
await tester.pumpAndSettle();
expect(find.text('对话完成'), findsNWidgets(2)); // AppBar title and Eyebrow
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
await tester.tap(find.byIcon(Icons.arrow_back));
await tester.pumpAndSettle();
expect(backCalled, isTrue);
});
testWidgets('AdaptiveLessonPage has back button in all states', (tester) async {
final state = AppState()..finishOnboarding();
// 1. Empty state
var finished = false;
await tester.pumpWidget(
MaterialApp(
home: AdaptiveLessonPage(
state: state,
onFinished: () => finished = true,
),
),
);
await tester.pumpAndSettle();
expect(find.text('AI 四技能补练'), findsNWidgets(2));
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
await tester.tap(find.byIcon(Icons.arrow_back));
expect(finished, isTrue);
// 2. Active lesson state
state.cacheApprovedAdaptiveLesson(
const GeneratedLesson(
lessonId: 'adapt-1',
revision: 1,
stageVersion: 'A0',
abilityIds: ['greeting'],
prerequisiteIds: [],
targetItemIds: ['name'],
receptiveChunks: ['My name is Mia.'],
previewItemIds: ['name'],
estimatedMinutes: 5,
tasks: [
GeneratedLessonTask(
taskId: 't1',
skill: 'listening',
type: 'listen',
prompt: '听并写出名字',
stimulus: 'My name is Mia.',
answer: 'Mia',
targetItemIds: ['name'],
),
],
),
auditor: 'test',
);
finished = false;
await tester.pumpWidget(
MaterialApp(
home: AdaptiveLessonPage(
state: state,
onFinished: () => finished = true,
),
),
);
await tester.pumpAndSettle();
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
await tester.tap(find.byIcon(Icons.arrow_back));
expect(finished, isTrue);
});
testWidgets('AssessmentPreparationPage and AssessmentPage have back buttons', (
tester,
) async {
final state = AppState()..finishOnboarding();
await tester.pumpWidget(
MaterialApp(home: LearningShell(state: state)),
);
await tester.pumpAndSettle();
// Navigate to "我的"
await tester.tap(find.text('我的'));
await tester.pumpAndSettle();
// Open assessment pack A0-E1
final packAButton = find.text('开始 A0-E1');
await tester.ensureVisible(packAButton);
await tester.pumpAndSettle();
expect(packAButton, findsOneWidget);
await tester.tap(packAButton);
await tester.pumpAndSettle();
// In AssessmentPreparationPage
expect(find.text('评估准备'), findsOneWidget);
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
// Start assessment
await tester.tap(find.text('开始评估'));
await tester.pumpAndSettle();
// In AssessmentPage
expect(find.byIcon(Icons.arrow_back), findsOneWidget);
// Tap back button in AssessmentPage
await tester.tap(find.byIcon(Icons.arrow_back));
await tester.pumpAndSettle();
// Returns to Progress tab
expect(find.text('A0 四技能评估'), findsOneWidget);
});
}
@@ -79,4 +79,31 @@ void main() {
isTrue,
);
});
test('ignores multiple spaces and casing across review items', () {
// Multi-space and uppercase on A0-P03
expect(
ReviewFeedback.check(
review('A0-P03', 'Nice to meet you.'),
' NICE TO MEET YOU! ',
).complete,
isTrue,
);
// Multi-space and uppercase on A0-P08
expect(
ReviewFeedback.check(
review('A0-P08', 'My number is ...'),
'MY NUMBER IS ONE TWO THREE',
).complete,
isTrue,
);
// Multi-space on word item
expect(
ReviewFeedback.check(
review('A0-W08', 'three'),
' THREE ',
).complete,
isTrue,
);
});
}
@@ -0,0 +1,231 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/ai_service.dart';
import 'package:kouyu_english/core/app_state.dart';
import 'package:kouyu_english/core/models.dart';
import 'package:kouyu_english/widgets/lexicon_lookup.dart';
void main() {
group('Sentence and Phrase Parsing Logic', () {
test('isSentenceQuery correctly detects sentences vs words', () {
expect(isSentenceQuery('apple'), isFalse);
expect(isSentenceQuery('check in'), isFalse);
expect(isSentenceQuery('Where is the subway?'), isTrue);
expect(isSentenceQuery("I'd like to check in."), isTrue);
expect(isSentenceQuery('This is a longer sentence with more than three words'), isTrue);
});
test('extractCourseLexiconPhrases finds embedded course lexicon items', () {
final matches = extractCourseLexiconPhrases("Hello, nice to meet you, where's the taxi?");
expect(matches, isNotEmpty);
final words = matches.map((m) => m.word.toLowerCase()).toList();
expect(words.any((w) => w.contains('nice to meet you')), isTrue);
});
});
group('Models Serialization', () {
test('PhraseBreakdownItem serialization round-trip', () {
const item = PhraseBreakdownItem(
phrase: 'check in',
meaning: '办理入住/值机',
ipa: '/tʃek ɪn/',
usageNote: '连读发音,酒店或机场常用',
);
final json = item.toJson();
final parsed = PhraseBreakdownItem.fromJson(json);
expect(parsed.phrase, 'check in');
expect(parsed.meaning, '办理入住/值机');
expect(parsed.ipa, '/tʃek ɪn/');
expect(parsed.usageNote, '连读发音,酒店或机场常用');
});
test('SentenceAnalysisResult serialization round-trip', () {
final result = SentenceAnalysisResult(
originalText: "I would like to check in, please.",
translation: '我想办理入住手续,谢谢。',
sentencePattern: "I would like to + 动词原形 (礼貌表达需求)",
grammarNote: 'would like 语气委婉客气,适合服务场景。',
pronunciationTips: 'would like 发音轻柔,check in 发生连读。',
phrases: const [
PhraseBreakdownItem(
phrase: 'would like to',
meaning: '想要做某事(委婉)',
),
PhraseBreakdownItem(
phrase: 'check in',
meaning: '办理入住',
ipa: '/tʃek ɪn/',
),
],
provider: 'mock',
model: 'test-model',
createdAt: DateTime.utc(2026, 9, 16, 10, 0, 0),
);
final json = result.toJson();
final parsed = SentenceAnalysisResult.fromJson(json);
expect(parsed.originalText, result.originalText);
expect(parsed.translation, result.translation);
expect(parsed.sentencePattern, result.sentencePattern);
expect(parsed.grammarNote, result.grammarNote);
expect(parsed.pronunciationTips, result.pronunciationTips);
expect(parsed.phrases.length, 2);
expect(parsed.phrases[0].phrase, 'would like to');
expect(parsed.phrases[1].ipa, '/tʃek ɪn/');
expect(parsed.provider, 'mock');
expect(parsed.model, 'test-model');
});
});
group('AiService Mock Sentence Analysis', () {
test('returns structured breakdown for would like', () async {
final analysis = await AiService.instance.analyzeSentence(
provider: AiProviderType.mock,
endpoint: '',
model: 'mock',
text: 'I would like a cup of tea.',
);
expect(analysis, isNotNull);
expect(analysis!.translation, contains('想要'));
expect(analysis.sentencePattern, contains('would like'));
expect(analysis.pronunciationTips, isNotNull);
expect(analysis.phrases, isNotEmpty);
expect(analysis.phrases.any((p) => p.phrase == 'would like'), isTrue);
});
test('returns structured breakdown for where is', () async {
final analysis = await AiService.instance.analyzeSentence(
provider: AiProviderType.mock,
endpoint: '',
model: 'mock',
text: 'Where is the gate?',
);
expect(analysis, isNotNull);
expect(analysis!.translation, contains('在哪里'));
expect(analysis.sentencePattern, contains('Where is'));
expect(analysis.phrases.any((p) => p.phrase == 'where is'), isTrue);
});
test('returns structured fallback for generic sentence', () async {
final analysis = await AiService.instance.analyzeSentence(
provider: AiProviderType.mock,
endpoint: '',
model: 'mock',
text: 'The weather is very sunny today.',
);
expect(analysis, isNotNull);
expect(analysis!.translation, isNotEmpty);
expect(analysis.sentencePattern, isNotEmpty);
expect(analysis.grammarNote, isNotEmpty);
});
});
group('AppState Sentence Analysis & Review Integration', () {
test('caching and retrieving sentence analyses with query normalization', () {
final state = AppState();
final result = SentenceAnalysisResult(
originalText: ' Where is the bus stop? ',
translation: '请问公交站在哪里?',
sentencePattern: 'Where is + 地点',
createdAt: DateTime.now(),
);
state.cacheSentenceAnalysis(result);
expect(state.sentenceAnalysisFor('where is the bus stop?'), isNotNull);
expect(state.sentenceAnalysisFor('WHERE IS THE BUS STOP? '), isNotNull);
expect(state.sentenceAnalysisFor('where is the bus stop?')?.translation, '请问公交站在哪里?');
state.removeSentenceAnalysis('where is the bus stop?');
expect(state.sentenceAnalysisFor('where is the bus stop?'), isNull);
});
test('addPhraseToReview converts phrase breakdown into vocabulary item in review queue', () {
final state = AppState();
state.addPhraseToReview(
phrase: 'check in',
meaning: '办理登机或入住',
ipa: '/tʃek ɪn/',
usageNote: '酒店机场高频词',
contextSentence: "I'd like to check in please.",
);
expect(state.reviewQueue.any((r) => r.target == 'check in'), isTrue);
final saved = state.reviewQueue.firstWhere((r) => r.target == 'check in');
expect(saved.hint, contains('办理登机或入住'));
expect(saved.hint, contains('酒店机场高频词'));
expect(saved.prompt, "I'd like to check in please.");
});
});
group('UI Lookup Sheet Sentence Analysis View', () {
testWidgets('renders sentence analysis result cards when cached', (tester) async {
final state = AppState();
final analysis = SentenceAnalysisResult(
originalText: 'Where is the departure gate?',
translation: '请问登机口在哪里?',
sentencePattern: 'Where is + 目的地',
grammarNote: 'where 引导的疑问句,注意语调。',
pronunciationTips: 'Where 与 is 自然连读。',
phrases: const [
PhraseBreakdownItem(
phrase: 'departure gate',
ipa: '/dɪˈpɑːrtʃər ɡeɪt/',
meaning: '登机口',
usageNote: '机场核心词汇',
),
],
provider: 'mock',
createdAt: DateTime.now(),
);
state.cacheSentenceAnalysis(analysis);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Builder(
builder: (context) => ElevatedButton(
onPressed: () => showLexiconLookup(
context,
state: state,
initialText: 'Where is the departure gate?',
),
child: const Text('查句'),
),
),
),
),
);
await tester.tap(find.text('查句'));
await tester.pumpAndSettle();
expect(find.text('查词与整句深度解析'), findsOneWidget);
expect(find.text('中文整句翻译'), findsOneWidget);
expect(find.text('请问登机口在哪里?'), findsOneWidget);
expect(find.text('核心句型'), findsOneWidget);
expect(find.text('Where is + 目的地'), findsOneWidget);
expect(find.text('口语连读与发音'), findsOneWidget);
expect(find.text('重点短语与搭配 (1)'), findsOneWidget);
expect(find.text('departure gate'), findsOneWidget);
expect(find.text('登机口'), findsOneWidget);
expect(find.text('加复习'), findsOneWidget);
// Scroll to '加复习' and tap
await tester.ensureVisible(find.text('加复习'));
await tester.pumpAndSettle();
await tester.tap(find.text('加复习'));
await tester.pumpAndSettle();
expect(state.reviewQueue.any((r) => r.target == 'departure gate'), isTrue);
expect(find.text('已在复习'), findsOneWidget);
});
});
}
+75
View File
@@ -0,0 +1,75 @@
// ignore_for_file: avoid_print
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:kouyu_english/core/sherpa_stt_service.dart';
import 'package:sherpa_onnx/sherpa_onnx.dart' as sherpa_onnx;
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final home = Platform.environment['HOME'] ?? '';
final pubCache = Platform.environment['PUB_CACHE'] ?? '$home/.pub-cache';
final candidateDirs = [
'$pubCache/hosted/pub.dev/sherpa_onnx_macos-1.13.8/macos',
'$home/.pub-cache/hosted/pub.dev/sherpa_onnx_macos-1.13.8/macos',
'/Users/shenlei/.pub-cache/hosted/pub.dev/sherpa_onnx_macos-1.13.8/macos',
];
final macosDir = candidateDirs.firstWhere(
(d) => Directory(d).existsSync(),
orElse: () => candidateDirs.first,
);
test('SenseVoice-Small ONNX transcribes WAV audio file accurately', () async {
// Test direct Sherpa ASR initialization and decoding with SenseVoice
sherpa_onnx.initBindings(macosDir);
final modelConfig = sherpa_onnx.OfflineModelConfig(
senseVoice: const sherpa_onnx.OfflineSenseVoiceModelConfig(
model: 'assets/models/sense_voice/model.int8.onnx',
language: 'en',
useInverseTextNormalization: true,
),
tokens: 'assets/models/sense_voice/tokens.txt',
numThreads: 2,
debug: false,
);
final recognizerConfig = sherpa_onnx.OfflineRecognizerConfig(
model: modelConfig,
feat: const sherpa_onnx.FeatureConfig(sampleRate: 16000, featureDim: 80),
);
final recognizer = sherpa_onnx.OfflineRecognizer(recognizerConfig);
const testWave = '/tmp/sherpa_test/sherpa-onnx-zipformer-small-en-2023-06-26/test_wavs/0.wav';
if (File(testWave).existsSync()) {
final wave = sherpa_onnx.readWave(testWave);
expect(wave.samples.isNotEmpty, isTrue);
final stream = recognizer.createStream();
stream.acceptWaveform(samples: wave.samples, sampleRate: wave.sampleRate);
recognizer.decode(stream);
final result = recognizer.getResult(stream);
print('SenseVoice transcribed result: ${result.text}');
expect(result.text.toLowerCase().contains('nightfall'), isTrue);
stream.free();
}
recognizer.free();
});
test('SherpaSttService singleton initializes and transcribes cleanly', () async {
final ready = await SherpaSttService.instance.initialize(nativeLibDir: macosDir);
expect(ready, isTrue);
expect(SherpaSttService.instance.isReady, isTrue);
const testWave = '/tmp/sherpa_test/sherpa-onnx-zipformer-small-en-2023-06-26/test_wavs/0.wav';
if (File(testWave).existsSync()) {
final transcribed = await SherpaSttService.instance.transcribeWav(testWave);
expect(transcribed, isNotNull);
print('SherpaSttService transcribed: $transcribed');
expect(transcribed!.toLowerCase().contains('nightfall'), isTrue);
// SenseVoice tags should be stripped
expect(transcribed.contains('<|'), isFalse);
}
});
}
+504
View File
@@ -0,0 +1,504 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:kouyu_english/core/app_state.dart';
import 'package:kouyu_english/core/models.dart';
import 'package:kouyu_english/core/sync/sync_coordinator.dart';
import 'package:kouyu_english/core/sync/sync_merger.dart';
import 'package:kouyu_english/core/sync/sync_models.dart';
import 'package:kouyu_english/core/sync/sync_service.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
});
group('Sync Models Serialization', () {
test('SyncConfig toJson and fromJson round-trip', () {
final config = SyncConfig(
serverUrl: 'https://sync.example.com',
token: 'test_token_123',
username: 'alice',
userId: 'u-1',
lastSyncTime: DateTime.parse('2026-09-16T08:00:00.000Z'),
autoSyncEnabled: true,
);
final json = config.toJson();
final recovered = SyncConfig.fromJson(json);
expect(recovered.serverUrl, 'https://sync.example.com');
expect(recovered.token, 'test_token_123');
expect(recovered.username, 'alice');
expect(recovered.userId, 'u-1');
expect(recovered.isLoggedIn, isTrue);
expect(recovered.lastSyncTime?.toIso8601String(), '2026-09-16T08:00:00.000Z');
expect(recovered.autoSyncEnabled, isTrue);
});
test('SyncPushRequest and SyncPullResponse parsing', () {
final pullJson = {
'code': 0,
'message': 'success',
'data': {
'server_time': '2026-09-16T10:00:00Z',
'progress': {
'active_lesson_id': 'a0-02',
'completed_lesson_ids': ['a0-01', 'a0-02'],
'completed_segment_ids': ['a0-01-s1', 'a0-02-s1'],
'active_step': 'listening',
'streak_days': 2,
'updated_at': '2026-09-16T10:00:00Z',
},
'mastery_updates': [
{
'item_id': 'A0-P01',
'checkpoint': 3,
'status': 'use',
'due_at': '2026-09-17T10:00:00Z',
'successful_reviews': 3,
'attempts': 3,
'payload': {'label': 'I need coffee'},
'updated_at': '2026-09-16T10:00:00Z',
}
],
'profile': {
'onboarding_complete': true,
'goal': 'workplace',
'placement': 'A0',
'daily_minutes': 20,
'show_chinese_hints': true,
'updated_at': '2026-09-16T10:00:00Z',
},
}
};
final response = SyncPullResponse.fromJson(pullJson);
expect(response.serverTime, '2026-09-16T10:00:00Z');
expect(response.progress?.completedLessonIds, ['a0-01', 'a0-02']);
expect(response.masteryUpdates.length, 1);
expect(response.masteryUpdates.first.itemId, 'A0-P01');
expect(response.masteryUpdates.first.checkpoint, 3);
expect(response.profile?.goal, 'workplace');
});
});
group('SyncMerger Logic', () {
test('buildPushRequest extracts current AppState data', () {
final state = AppState();
state.completedLessonIds.add('a0-01');
state.completedLessons = 1;
state.mastery['A0-P01'] = const MasteryItem(
id: 'A0-P01',
label: 'I am Shen',
status: MasteryStatus.recall,
checkpoint: 2,
evidence: [EvidenceKind.independentSuccess],
);
final req = SyncMerger.buildPushRequest(state, deviceName: 'MacBook');
expect(req.deviceName, 'MacBook');
expect(req.progress?.completedLessonIds, contains('a0-01'));
expect(req.masteryUpdates.any((m) => m.itemId == 'A0-P01'), isTrue);
final item = req.masteryUpdates.firstWhere((m) => m.itemId == 'A0-P01');
expect(item.checkpoint, 2);
expect(item.status, 'recall');
});
test('applyPullResponse merges lessons by union and upgrades mastery by max checkpoint', () {
final state = AppState();
state.completedLessonIds.add('a0-01');
state.completedLessons = 1;
state.mastery['A0-P01'] = const MasteryItem(
id: 'A0-P01',
label: 'I am Shen',
status: MasteryStatus.recognize,
checkpoint: 1,
evidence: [],
);
final remotePull = SyncPullResponse(
serverTime: '2026-09-16T10:00:00Z',
progress: const SyncProgressPayload(
activeLessonId: 'a0-03',
completedLessonIds: ['a0-01', 'a0-02'],
completedSegmentIds: ['a0-01-s1', 'a0-02-s1'],
updatedAt: '2026-09-16T10:00:00Z',
),
masteryUpdates: [
const SyncMasteryItemPayload(
itemId: 'A0-P01',
checkpoint: 3,
status: 'use',
dueAt: '2026-09-20T10:00:00Z',
payload: {'label': 'I am Shen'},
updatedAt: '2026-09-16T10:00:00Z',
),
const SyncMasteryItemPayload(
itemId: 'A0-P02',
checkpoint: 1,
status: 'recognize',
dueAt: '2026-09-18T10:00:00Z',
payload: {'label': 'Thank you'},
updatedAt: '2026-09-16T10:00:00Z',
),
],
profile: const SyncProfilePayload(
onboardingComplete: true,
goal: 'dailyLife',
updatedAt: '2026-09-16T10:00:00Z',
),
);
final changed = SyncMerger.applyPullResponse(state, remotePull);
expect(changed, isTrue);
// Lesson union
expect(state.completedLessonIds, containsAll(['a0-01', 'a0-02']));
expect(state.completedLessons, 2);
// Mastery max checkpoint upgrade
expect(state.mastery['A0-P01']?.checkpoint, 3);
expect(state.mastery['A0-P01']?.status, MasteryStatus.use);
// New item added from remote
expect(state.mastery['A0-P02']?.checkpoint, 1);
expect(state.mastery['A0-P02']?.label, 'Thank you');
});
});
group('SyncMerger restores home page position on a fresh device', () {
// Mirrors what the server held for a real account: lesson a0-01 was
// finished on one device, then another device pushed its stale position
// (active lesson a0-01, review due_at overwritten with the push time).
SyncPullResponse stalePull() => const SyncPullResponse(
serverTime: '2026-09-16T08:00:00Z',
progress: SyncProgressPayload(
activeLessonId: 'a0-01',
completedLessonIds: ['a0-01'],
completedSegmentIds: ['a0-01-a'],
updatedAt: '2026-09-16T07:54:27Z',
),
masteryUpdates: [
SyncMasteryItemPayload(
itemId: 'A0-P03',
checkpoint: 0,
status: 'recall',
dueAt: '2026-09-16T07:55:13Z',
payload: {
'label': 'A0-P03',
'evidence': ['exposure', 'assisted', 'independentSuccess'],
'firstTaughtAt': '2026-09-16T07:49:45Z',
},
updatedAt: '2026-09-16T07:54:27Z',
),
],
);
test('advances past lessons completed on another device', () {
final state = AppState();
state.lessonStep = LessonStep.speaking;
final changed = SyncMerger.applyPullResponse(state, stalePull());
expect(changed, isTrue);
expect(state.completedLessonIds, contains('a0-01'));
expect(state.activeLessonId, 'a0-02');
expect(state.activeSegmentIndexFor('a0-01'), 0);
expect(state.lessonStep, LessonStep.preview);
});
test('keeps a completed lesson the learner reopened locally', () {
final state = AppState();
state.completedLessonIds.addAll(['a0-01', 'a0-02']);
state.completedLessons = 2;
state.activeLessonId = 'a0-01';
SyncMerger.applyPullResponse(state, stalePull());
expect(state.activeLessonId, 'a0-01');
});
test('never moves the position backwards', () {
final state = AppState();
state.completedLessonIds.addAll(['a0-01', 'a0-02']);
state.activeLessonId = 'a0-03';
SyncMerger.applyPullResponse(state, stalePull());
expect(state.activeLessonId, 'a0-03');
});
test('adopts a further remote lesson and segment position', () {
final state = AppState();
final pull = SyncPullResponse(
serverTime: '2026-09-16T08:00:00Z',
progress: SyncProgressPayload(
activeLessonId: 'a0-04',
completedLessonIds: const ['a0-01', 'a0-02', 'a0-03'],
completedSegmentIds: const ['a0-01-a', 'a0-02-a', 'a0-03-a', 'a0-04-a'],
updatedAt: '2026-09-16T07:54:27Z',
),
);
SyncMerger.applyPullResponse(state, pull);
expect(state.activeLessonId, 'a0-04');
expect(state.activeSegmentIndexFor('a0-04'), 1);
});
test('rebuilds review cards and repairs never-reviewed due dates', () {
final state = AppState();
SyncMerger.applyPullResponse(state, stalePull());
final review = state.reviewQueue.singleWhere((r) => r.id == 'A0-P03');
expect(
review.dueAt.toUtc(),
DateTime.parse('2026-09-17T07:49:45Z'),
);
expect(state.mastery['A0-P03']?.status, MasteryStatus.recall);
});
test('equal checkpoint with more remote evidence updates local status', () {
final state = AppState();
state.mastery['A0-P03'] = const MasteryItem(
id: 'A0-P03',
label: 'A0-P03',
status: MasteryStatus.newItem,
evidence: [EvidenceKind.exposure],
);
SyncMerger.applyPullResponse(state, stalePull());
expect(state.mastery['A0-P03']?.status, MasteryStatus.recall);
expect(state.mastery['A0-P03']?.evidence.length, 3);
});
test('push without a review card derives due date from first teaching', () {
final state = AppState();
state.mastery['A0-P01'] = MasteryItem(
id: 'A0-P01',
label: 'A0-P01',
status: MasteryStatus.newItem,
evidence: const [],
firstTaughtAt: DateTime.parse('2026-09-16T07:49:45Z'),
);
final req = SyncMerger.buildPushRequest(state);
expect(req.masteryUpdates.single.dueAt, '2026-09-17T07:49:45.000Z');
});
});
group('SyncService HTTP operations', () {
test('testConnection returns true on 200 health check', () async {
final mockClient = MockClient((request) async {
if (request.url.path == '/api/v1/health') {
return http.Response(jsonEncode({'status': 'ok'}), 200);
}
return http.Response('Not Found', 404);
});
final service = SyncService(client: mockClient);
final ok = await service.testConnection('http://127.0.0.1:8080');
expect(ok, isTrue);
});
test('register and login return auth tokens', () async {
final mockClient = MockClient((request) async {
if (request.url.path == '/api/v1/auth/register' ||
request.url.path == '/api/v1/auth/login') {
return http.Response(
jsonEncode({
'code': 0,
'message': 'success',
'data': {
'user_id': 'usr-888',
'username': 'shen',
'token': 'jwt_mock_token_abc',
'expires_in': 604800,
}
}),
200,
);
}
return http.Response('Not Found', 404);
});
final service = SyncService(client: mockClient);
final regRes = await service.register(
serverUrl: 'http://127.0.0.1:8080',
username: 'shen',
password: 'password123',
);
expect(regRes.userId, 'usr-888');
expect(regRes.token, 'jwt_mock_token_abc');
final logRes = await service.login(
serverUrl: 'http://127.0.0.1:8080',
username: 'shen',
password: 'password123',
);
expect(logRes.userId, 'usr-888');
expect(logRes.token, 'jwt_mock_token_abc');
});
test('pull and push send and receive data correctly', () async {
final mockClient = MockClient((request) async {
if (request.url.path == '/api/v1/sync/pull') {
expect(request.headers['Authorization'], 'Bearer mock_token');
return http.Response(
jsonEncode({
'code': 0,
'message': 'success',
'data': {
'server_time': '2026-09-16T12:00:00Z',
'progress': {
'active_lesson_id': 'a0-01',
'completed_lesson_ids': ['a0-01'],
'completed_segment_ids': ['a0-01-s1'],
'active_step': 'speaking',
'streak_days': 1,
'updated_at': '2026-09-16T12:00:00Z',
},
'mastery_updates': [],
'profile': null,
}
}),
200,
);
} else if (request.url.path == '/api/v1/sync/push') {
expect(request.headers['Authorization'], 'Bearer mock_token');
return http.Response(
jsonEncode({
'code': 0,
'message': 'success',
'data': {'server_time': '2026-09-16T12:05:00Z'}
}),
200,
);
}
return http.Response('Not Found', 404);
});
final service = SyncService(client: mockClient);
final pullResp = await service.pull(
serverUrl: 'http://127.0.0.1:8080',
token: 'mock_token',
);
expect(pullResp.progress?.completedLessonIds, ['a0-01']);
final sTime = await service.push(
serverUrl: 'http://127.0.0.1:8080',
token: 'mock_token',
request: const SyncPushRequest(clientTime: '2026-09-16T12:04:00Z'),
);
expect(sTime, '2026-09-16T12:05:00Z');
});
});
group('SyncCoordinator Integration', () {
test('login, syncNow and logout lifecycle', () async {
Future<http.Response> handler(http.Request request) async {
if (request.url.path == '/api/v1/auth/login') {
return http.Response(
jsonEncode({
'code': 0,
'message': 'success',
'data': {
'user_id': 'u100',
'username': 'tester',
'token': 'auth_token_999',
'expires_in': 604800,
}
}),
200,
);
} else if (request.url.path == '/api/v1/sync/pull') {
return http.Response(
jsonEncode({
'code': 0,
'message': 'success',
'data': {
'server_time': '2026-09-16T15:00:00Z',
'progress': {
'active_lesson_id': 'a0-01',
'completed_lesson_ids': ['a0-01'],
'completed_segment_ids': ['a0-01-s1'],
'active_step': 'reading',
'streak_days': 1,
'updated_at': '2026-09-16T15:00:00Z',
},
'mastery_updates': [],
'profile': null,
}
}),
200,
);
} else if (request.url.path == '/api/v1/sync/push') {
return http.Response(
jsonEncode({
'code': 0,
'message': 'success',
'data': {'server_time': '2026-09-16T15:00:01Z'}
}),
200,
);
}
return http.Response('Not Found', 404);
}
final service = SyncService(client: MockClient(handler));
final coordinator = SyncCoordinator.createForTesting(service: service);
await coordinator.init();
expect(coordinator.isLoggedIn, isFalse);
final loginSuccess = await coordinator.login(
serverUrl: 'http://127.0.0.1:8080',
username: 'tester',
password: 'password123',
);
expect(loginSuccess, isTrue);
expect(coordinator.isLoggedIn, isTrue);
expect(coordinator.username, 'tester');
final state = AppState();
final syncSuccess = await coordinator.syncNow(state);
expect(syncSuccess, isTrue);
expect(coordinator.state, SyncState.success);
expect(coordinator.lastSyncTime, isNotNull);
expect(state.completedLessonIds, contains('a0-01'));
final pullQueries = <Map<String, String>>[];
final relogCoordinator = SyncCoordinator.createForTesting(
service: SyncService(
client: MockClient((request) async {
if (request.url.path == '/api/v1/sync/pull') {
pullQueries.add(request.url.queryParameters);
}
return handler(request);
}),
),
);
await relogCoordinator.init();
await relogCoordinator.syncNow(AppState());
expect(relogCoordinator.lastSyncTime, isNotNull);
await relogCoordinator.login(
serverUrl: 'http://127.0.0.1:8080',
username: 'tester',
password: 'password123',
);
await relogCoordinator.syncNow(AppState());
// A fresh login must pull everything, not only changes since last sync.
expect(pullQueries.last.containsKey('since'), isFalse);
await coordinator.logout();
expect(coordinator.isLoggedIn, isFalse);
expect(coordinator.config.token, isNull);
});
});
}
+15
View File
@@ -41,4 +41,19 @@ void main() {
);
expect(check.onPressed, isNotNull);
});
testWidgets('speaking step displays use mic and play repeat buttons', (
tester,
) async {
final state = AppState()..lessonStep = LessonStep.speaking;
await tester.pumpWidget(
MaterialApp(
home: LessonFlow(state: state, onOpenDialogue: () {}, onFinish: () {}),
),
);
expect(find.text('跟读'), findsOneWidget);
expect(find.text('使用麦克风跟读'), findsOneWidget);
expect(find.text('播放跟读'), findsOneWidget);
});
}
@@ -31,4 +31,31 @@ void main() {
isTrue,
);
});
test('ignores multiple spaces, mixed casing and curly apostrophes', () {
// a0-01 greeting and name with multiple spaces and uppercase
expect(
WritingFeedback.check('a0-01', ' HELLO I AM ALEX ').complete,
isTrue,
);
// a0-01 with curly quote and multiple spaces
expect(
WritingFeedback.check('a0-01', 'HI IM MIA').complete,
isTrue,
);
// a0-07 with multiple spaces in phrase
expect(
WritingFeedback.check('a0-07', 'THIS IS MY MOTHER').complete,
isTrue,
);
// a0-08-c with multiple spaces and curly apostrophe
expect(
WritingFeedback.check(
'a0-08-c',
'ITS THREE OCLOCK',
segmentId: 'a0-08-c',
).complete,
isTrue,
);
});
}

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