feat: 完善听力口语辅助状态记录、练习分块与复习抽查间隔机制
This commit is contained in:
@@ -151,8 +151,8 @@ A1 材料短、面向零基础,`unknownTolerance` 设为 0.08(听读材料
|
|||||||
{
|
{
|
||||||
"id": "B1-U14-S1", "title": "说明写信目的", "minutes": 12,
|
"id": "B1-U14-S1", "title": "说明写信目的", "minutes": 12,
|
||||||
"itemIds": ["B1-U14-P01", "B1-U14-P02"],
|
"itemIds": ["B1-U14-P01", "B1-U14-P02"],
|
||||||
"listening": {"text": "…", "question": "…", "options": ["正确", "干扰", "干扰"]},
|
"listening": {"text": "…", "question": "…", "options": ["正确", "干扰", "干扰"], "itemIds": []},
|
||||||
"speaking": [{"text": "…", "tip": "可选的发音提示"}],
|
"speaking": [{"text": "…", "tip": "可选的发音提示", "itemIds": ["B1-U14-P01"]}],
|
||||||
"reading": {"text": "多行用 \n", "question": "…", "options": ["…", "…", "…"]},
|
"reading": {"text": "多行用 \n", "question": "…", "options": ["…", "…", "…"]},
|
||||||
"writing": {"prompt": "…", "example": "…", "itemIds": ["B1-U14-P01"]},
|
"writing": {"prompt": "…", "example": "…", "itemIds": ["B1-U14-P01"]},
|
||||||
"independent": {"prompt": "…", "itemIds": ["B1-U14-P02"]}
|
"independent": {"prompt": "…", "itemIds": ["B1-U14-P02"]}
|
||||||
@@ -160,6 +160,9 @@ A1 材料短、面向零基础,`unknownTolerance` 设为 0.08(听读材料
|
|||||||
```
|
```
|
||||||
|
|
||||||
- 选项恰好 3 个,第一个是正确答案(界面随机排序);选项不能互为子串。英文选项至少两个在原文出现,且正确选项必须出现。
|
- 选项恰好 3 个,第一个是正确答案(界面随机排序);选项不能互为子串。英文选项至少两个在原文出现,且正确选项必须出现。
|
||||||
|
- `listening.itemIds` 和 `speaking[0].itemIds` 必填,且只能引用本段 `itemIds`。只填写题目直接考到、或跟读句实际包含的核心;整段理解题填 `[]`。空数组只生成教学段学习记录,不提升任何核心掌握状态。旧包缺少字段时运行时按空数组处理。
|
||||||
|
- `speaking[0]` 是本段必练句,后续句在跟读页的「更多示范句」中提供查看和播放,不产生完成证据。
|
||||||
|
- `speaking[0].chunks` 可选,仅对适合拆段练习的较长句配置至少两个词块;用单个空格拼接后必须等于 `speaking[0].text`。拆段跟读只算辅助练习,不产生独立口语证据。
|
||||||
- `writing.example` 必须用到它引用的每个产出核心。
|
- `writing.example` 必须用到它引用的每个产出核心。
|
||||||
|
|
||||||
### 4.6 scenes 与 tasks
|
### 4.6 scenes 与 tasks
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ M1 范围、生成与数据契约见 [开发与验收约定](M1-IMPLEMENTATION-C
|
|||||||
|
|
||||||
不会时可恢复提示:该次记辅助完成,安排下一次不同任务的独立尝试,不强制当场循环。跳过记待做,时间不够则续学。只有教学步骤已完成且独立尝试已有结果(成功、辅助、有效错误或待确认)才记录教学段完成;“待确认”仍不构成掌握。各段轮换听、说、读、写;每个种子主题结束前四技能至少各有一次独立尝试机会,见课程包映射。主题可完成而能力未掌握,后者进入复习和补练。
|
不会时可恢复提示:该次记辅助完成,安排下一次不同任务的独立尝试,不强制当场循环。跳过记待做,时间不够则续学。只有教学步骤已完成且独立尝试已有结果(成功、辅助、有效错误或待确认)才记录教学段完成;“待确认”仍不构成掌握。各段轮换听、说、读、写;每个种子主题结束前四技能至少各有一次独立尝试机会,见课程包映射。主题可完成而能力未掌握,后者进入复习和补练。
|
||||||
|
|
||||||
|
课程写作与独立尝试在用户提交时自动检查,不另设 AI 检查按钮,也不在每次输入时请求 AI。本地任务规则先检查是否用到目标表达;配置 AI 时,只有本地规则通过且 AI 返回 `accepted`,无提示的开放回答才记为独立成功。AI 返回 `rewrite` 时留在当前题,展示改正并要求重写;看过改正后本次最多记辅助练习。`uncertain`、服务不可用或未配置 AI 时可保存练习继续,但记为待确认,不提升独立掌握状态。
|
||||||
|
|
||||||
### 2.3 AI 生成输入格式
|
### 2.3 AI 生成输入格式
|
||||||
|
|
||||||
每次请求 AI 时必须携带以下结构化信息:
|
每次请求 AI 时必须携带以下结构化信息:
|
||||||
@@ -125,7 +127,7 @@ AI 对话中的词条必须来自当前允许语言范围,或是事先标为
|
|||||||
|
|
||||||
每个词句单独保存首次正式教学日期:已获得释义/示范并提交至少一次该项应用练习(可带帮助,不要求答对)时登记,只登记一次;不等待整课或主题完成。仅看预热、查词或跳过教学不触发;主动收藏可以创建独立的待学提醒,但不启动四个掌握检查点。若该项当天有真实错误,补救与次日复习合并。
|
每个词句单独保存首次正式教学日期:已获得释义/示范并提交至少一次该项应用练习(可带帮助,不要求答对)时登记,只登记一次;不等待整课或主题完成。仅看预热、查词或跳过教学不触发;主动收藏可以创建独立的待学提醒,但不启动四个掌握检查点。若该项当天有真实错误,补救与次日复习合并。
|
||||||
|
|
||||||
按本地日期排复习,正常四次成功的间隔为 1、2、4、7 天。逾期不补造结果:完成当前检查点后,下一检查点从实际完成日期按后续间隔安排,每天同一项最多推进一个检查点;漏学不清空历史。第四次后按 30 天抽查。提示后答对保持当前检查点,次日换题独立复测,不计失败。遗忘处理按下一节,不因单次错误清空历史或四个检查点。
|
按本地日期排复习,正常四次成功的间隔为 1、2、4、7 天。逾期不补造结果:完成当前检查点后,下一检查点从实际完成日期按后续间隔安排,每天同一项最多推进一个检查点;漏学不清空历史。第四次后先按 30 天抽查;后续首次无提示答对可将下一次抽查延至 45 天,再次稳定成功可延至 60 天。带提示、重试后答对不推进检查点,次日换题独立复测;旧记录未保存首次作答信息时按 30 天安排。遗忘处理按下一节,不因单次错误清空历史或四个检查点。
|
||||||
|
|
||||||
每次作答区分独立成功、带帮助完成、语言错误、待确认和技术失败。记录听/说/读/写、提示、翻译、字幕、输入与转写修改状态;看答案后复述、同题立即重做不算独立间隔或迁移证据。文字可支持词句回忆/使用,但不能补齐口语证据;看字幕或翻译不能补齐独立听力。反馈来源为转写不确定或服务异常时,不进入失败计数。
|
每次作答区分独立成功、带帮助完成、语言错误、待确认和技术失败。记录听/说/读/写、提示、翻译、字幕、输入与转写修改状态;看答案后复述、同题立即重做不算独立间隔或迁移证据。文字可支持词句回忆/使用,但不能补齐口语证据;看字幕或翻译不能补齐独立听力。反馈来源为转写不确定或服务异常时,不进入失败计数。
|
||||||
|
|
||||||
|
|||||||
@@ -213,12 +213,20 @@
|
|||||||
"自我介绍",
|
"自我介绍",
|
||||||
"买东西",
|
"买东西",
|
||||||
"问时间"
|
"问时间"
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"A0-P01"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Hello. I’m Shen. Nice to meet you.",
|
"text": "Hello. I’m Shen. Nice to meet you.",
|
||||||
"tip": ""
|
"tip": "",
|
||||||
|
"itemIds": [
|
||||||
|
"A0-W36",
|
||||||
|
"A0-P01",
|
||||||
|
"A0-P03"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"reading": {
|
"reading": {
|
||||||
|
|||||||
@@ -81,12 +81,16 @@
|
|||||||
"你的名字怎么拼?",
|
"你的名字怎么拼?",
|
||||||
"你的名字是什么?",
|
"你的名字是什么?",
|
||||||
"你来自哪里?"
|
"你来自哪里?"
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"A0-P04"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "S H E N",
|
"text": "S H E N",
|
||||||
"tip": "字母之间留一个短停顿:S /es/ - H /eɪtʃ/ - E /iː/ - N /en/。先清楚,不必快。"
|
"tip": "字母之间留一个短停顿:S /es/ - H /eɪtʃ/ - E /iː/ - N /en/。先清楚,不必快。",
|
||||||
|
"itemIds": []
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"reading": {
|
"reading": {
|
||||||
|
|||||||
@@ -168,12 +168,19 @@
|
|||||||
"你的状态",
|
"你的状态",
|
||||||
"你的号码",
|
"你的号码",
|
||||||
"你的地点"
|
"你的地点"
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"A0-P05"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "How are you? I’m good, thanks.",
|
"text": "How are you? I’m good, thanks.",
|
||||||
"tip": ""
|
"tip": "",
|
||||||
|
"itemIds": [
|
||||||
|
"A0-P05",
|
||||||
|
"A0-W33"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"reading": {
|
"reading": {
|
||||||
|
|||||||
@@ -375,12 +375,23 @@
|
|||||||
"123",
|
"123",
|
||||||
"132",
|
"132",
|
||||||
"213"
|
"213"
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"A0-W02",
|
||||||
|
"A0-W03",
|
||||||
|
"A0-W04"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Zero, one, two, three.",
|
"text": "Zero, one, two, three.",
|
||||||
"tip": ""
|
"tip": "",
|
||||||
|
"itemIds": [
|
||||||
|
"A0-W01",
|
||||||
|
"A0-W02",
|
||||||
|
"A0-W03",
|
||||||
|
"A0-W04"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"reading": {
|
"reading": {
|
||||||
@@ -498,12 +509,20 @@
|
|||||||
"678",
|
"678",
|
||||||
"687",
|
"687",
|
||||||
"768"
|
"768"
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"A0-W07",
|
||||||
|
"A0-W08",
|
||||||
|
"A0-W09"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "My phone is here.",
|
"text": "My phone is here.",
|
||||||
"tip": ""
|
"tip": "",
|
||||||
|
"itemIds": [
|
||||||
|
"A0-W20"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"reading": {
|
"reading": {
|
||||||
@@ -579,12 +598,18 @@
|
|||||||
"138",
|
"138",
|
||||||
"183",
|
"183",
|
||||||
"318"
|
"318"
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"A0-P08"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "My number is one-three-eight.",
|
"text": "My number is one-three-eight.",
|
||||||
"tip": "号码一个数字一个数字地读,数字之间稍停:one - three - eight。"
|
"tip": "号码一个数字一个数字地读,数字之间稍停:one - three - eight。",
|
||||||
|
"itemIds": [
|
||||||
|
"A0-P08"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"reading": {
|
"reading": {
|
||||||
|
|||||||
@@ -200,12 +200,20 @@
|
|||||||
"钥匙",
|
"钥匙",
|
||||||
"书",
|
"书",
|
||||||
"水"
|
"水"
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"A0-W23"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "What’s this? It’s a pen.",
|
"text": "What’s this? It’s a pen.",
|
||||||
"tip": ""
|
"tip": "",
|
||||||
|
"itemIds": [
|
||||||
|
"A0-W21",
|
||||||
|
"A0-P09",
|
||||||
|
"A0-P10"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"reading": {
|
"reading": {
|
||||||
|
|||||||
@@ -101,12 +101,18 @@
|
|||||||
"来自哪里",
|
"来自哪里",
|
||||||
"叫什么",
|
"叫什么",
|
||||||
"喜欢什么"
|
"喜欢什么"
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"A0-P11"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I’m from Hong Kong.",
|
"text": "I’m from Hong Kong.",
|
||||||
"tip": ""
|
"tip": "",
|
||||||
|
"itemIds": [
|
||||||
|
"A0-P12"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"reading": {
|
"reading": {
|
||||||
|
|||||||
@@ -160,12 +160,18 @@
|
|||||||
"妈妈",
|
"妈妈",
|
||||||
"朋友",
|
"朋友",
|
||||||
"老师"
|
"老师"
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"A0-W25"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "This is my family.",
|
"text": "This is my family.",
|
||||||
"tip": ""
|
"tip": "",
|
||||||
|
"itemIds": [
|
||||||
|
"A0-P13"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"reading": {
|
"reading": {
|
||||||
|
|||||||
@@ -332,12 +332,19 @@
|
|||||||
"星期一",
|
"星期一",
|
||||||
"星期二",
|
"星期二",
|
||||||
"星期三"
|
"星期三"
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"A0-W12"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "It is Monday.",
|
"text": "It is Monday.",
|
||||||
"tip": ""
|
"tip": "",
|
||||||
|
"itemIds": [
|
||||||
|
"A0-W12",
|
||||||
|
"A0-P15"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"reading": {
|
"reading": {
|
||||||
@@ -429,12 +436,18 @@
|
|||||||
"星期五",
|
"星期五",
|
||||||
"星期四",
|
"星期四",
|
||||||
"星期日"
|
"星期日"
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"A0-W16"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "It is Friday.",
|
"text": "It is Friday.",
|
||||||
"tip": ""
|
"tip": "",
|
||||||
|
"itemIds": [
|
||||||
|
"A0-W16"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"reading": {
|
"reading": {
|
||||||
@@ -508,12 +521,18 @@
|
|||||||
"三点",
|
"三点",
|
||||||
"一点",
|
"一点",
|
||||||
"星期三"
|
"星期三"
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"A0-P17"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "It is three o’clock.",
|
"text": "It is three o’clock.",
|
||||||
"tip": ""
|
"tip": "",
|
||||||
|
"itemIds": [
|
||||||
|
"A0-P17"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"reading": {
|
"reading": {
|
||||||
|
|||||||
@@ -227,12 +227,18 @@
|
|||||||
"喜欢",
|
"喜欢",
|
||||||
"不喜欢",
|
"不喜欢",
|
||||||
"不知道"
|
"不知道"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I like tea. Do you like tea?",
|
"text": "I like tea. Do you like tea?",
|
||||||
"tip": ""
|
"tip": "",
|
||||||
|
"itemIds": [
|
||||||
|
"A0-W30",
|
||||||
|
"A0-P18",
|
||||||
|
"A0-P19"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"reading": {
|
"reading": {
|
||||||
|
|||||||
@@ -95,12 +95,19 @@
|
|||||||
"再说一遍",
|
"再说一遍",
|
||||||
"说慢一点",
|
"说慢一点",
|
||||||
"给他一杯水"
|
"给他一杯水"
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"A0-P20"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Sorry, please say that again. I like water.",
|
"text": "Sorry, please say that again. I like water.",
|
||||||
"tip": "water 的 t 常读得很轻,像轻轻的 d。先清楚,不必快。"
|
"tip": "water 的 t 常读得很轻,像轻轻的 d。先清楚,不必快。",
|
||||||
|
"itemIds": [
|
||||||
|
"A0-W24",
|
||||||
|
"A0-P20"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"reading": {
|
"reading": {
|
||||||
|
|||||||
@@ -457,12 +457,21 @@
|
|||||||
"说慢一点",
|
"说慢一点",
|
||||||
"写下来",
|
"写下来",
|
||||||
"再帮一次忙"
|
"再帮一次忙"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Excuse me. Can you help me?",
|
"text": "Excuse me. Can you help me?",
|
||||||
"tip": "excuse 的 x 读 /ks/"
|
"tip": "excuse 的 x 读 /ks/",
|
||||||
|
"chunks": [
|
||||||
|
"Excuse me.",
|
||||||
|
"Can you help me?"
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U01-P01",
|
||||||
|
"A1-U01-P04"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Sorry, I don't understand."
|
"text": "Sorry, I don't understand."
|
||||||
@@ -517,12 +526,14 @@
|
|||||||
"地址",
|
"地址",
|
||||||
"电话",
|
"电话",
|
||||||
"名字"
|
"名字"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "A B C D E F G, H I J K L M N O P, Q R S T U V, W X Y Z.",
|
"text": "A B C D E F G, H I J K L M N O P, Q R S T U V, W X Y Z.",
|
||||||
"tip": "先念熟 26 个字母,才能拼读和听懂别人拼写"
|
"tip": "先念熟 26 个字母,才能拼读和听懂别人拼写",
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "What does this word mean?"
|
"text": "What does this word mean?"
|
||||||
@@ -580,11 +591,16 @@
|
|||||||
"15",
|
"15",
|
||||||
"50",
|
"50",
|
||||||
"51"
|
"51"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "eleven, twelve"
|
"text": "eleven, twelve",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U01-W01",
|
||||||
|
"A1-U01-W02"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "thirteen, fourteen, fifteen, sixteen, seventeen, eighteen, nineteen",
|
"text": "thirteen, fourteen, fifteen, sixteen, seventeen, eighteen, nineteen",
|
||||||
|
|||||||
@@ -399,11 +399,15 @@
|
|||||||
"15 美元",
|
"15 美元",
|
||||||
"40 美元",
|
"40 美元",
|
||||||
"50 美元"
|
"50 美元"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "How much is it?"
|
"text": "How much is it?",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U02-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "It's twenty dollars.",
|
"text": "It's twenty dollars.",
|
||||||
@@ -460,12 +464,16 @@
|
|||||||
"只收现金",
|
"只收现金",
|
||||||
"只收卡",
|
"只收卡",
|
||||||
"现金和卡都收"
|
"现金和卡都收"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I'll take it.",
|
"text": "I'll take it.",
|
||||||
"tip": "I'll 连读成一个音节"
|
"tip": "I'll 连读成一个音节",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U02-P03"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Can I pay by card?"
|
"text": "Can I pay by card?"
|
||||||
@@ -521,12 +529,16 @@
|
|||||||
"商店旁边",
|
"商店旁边",
|
||||||
"酒店里面",
|
"酒店里面",
|
||||||
"公交车上"
|
"公交车上"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Is there an ATM near here?",
|
"text": "Is there an ATM near here?",
|
||||||
"tip": "ATM 三个字母分开读"
|
"tip": "ATM 三个字母分开读",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U02-P07"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I only have cards."
|
"text": "I only have cards."
|
||||||
|
|||||||
@@ -431,11 +431,15 @@
|
|||||||
"大杯",
|
"大杯",
|
||||||
"小杯",
|
"小杯",
|
||||||
"没说大小"
|
"没说大小"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Can I see the menu, please?"
|
"text": "Can I see the menu, please?",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U03-P02"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I'd like a coffee, please.",
|
"text": "I'd like a coffee, please.",
|
||||||
@@ -493,11 +497,15 @@
|
|||||||
"热的,加奶,不加糖",
|
"热的,加奶,不加糖",
|
||||||
"冰的,加奶,加糖",
|
"冰的,加奶,加糖",
|
||||||
"热的,不加奶,加糖"
|
"热的,不加奶,加糖"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Hot, please."
|
"text": "Hot, please.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U03-W04"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "With milk, please."
|
"text": "With milk, please."
|
||||||
@@ -553,11 +561,15 @@
|
|||||||
"带走",
|
"带走",
|
||||||
"在店里吃",
|
"在店里吃",
|
||||||
"还没决定"
|
"还没决定"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "A chicken sandwich, please."
|
"text": "A chicken sandwich, please.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U03-W07"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "To go, please."
|
"text": "To go, please."
|
||||||
|
|||||||
@@ -473,11 +473,15 @@
|
|||||||
"火车站",
|
"火车站",
|
||||||
"洗手间",
|
"洗手间",
|
||||||
"银行"
|
"银行"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Sorry, I'm lost."
|
"text": "Sorry, I'm lost.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U04-P03"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Excuse me. Where is the station?"
|
"text": "Excuse me. Where is the station?"
|
||||||
@@ -537,11 +541,15 @@
|
|||||||
"右边,医院旁边",
|
"右边,医院旁边",
|
||||||
"左边,医院旁边",
|
"左边,医院旁边",
|
||||||
"右边,公园旁边"
|
"右边,公园旁边"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Go straight."
|
"text": "Go straight.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U04-P05"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Turn left.",
|
"text": "Turn left.",
|
||||||
@@ -599,11 +607,15 @@
|
|||||||
"十分钟",
|
"十分钟",
|
||||||
"五分钟",
|
"五分钟",
|
||||||
"半小时"
|
"半小时"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Is it far?"
|
"text": "Is it far?",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U04-P08"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "It's five minutes from here."
|
"text": "It's five minutes from here."
|
||||||
|
|||||||
@@ -455,11 +455,15 @@
|
|||||||
"两人桌",
|
"两人桌",
|
||||||
"三人桌",
|
"三人桌",
|
||||||
"四人桌"
|
"四人桌"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "A table for two, please."
|
"text": "A table for two, please.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U05-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I'm very hungry."
|
"text": "I'm very hungry."
|
||||||
@@ -517,11 +521,17 @@
|
|||||||
"鸡汤和米饭",
|
"鸡汤和米饭",
|
||||||
"鱼和米饭",
|
"鱼和米饭",
|
||||||
"鸡汤和蔬菜"
|
"鸡汤和蔬菜"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "soup, rice, fish"
|
"text": "soup, rice, fish",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U05-W01",
|
||||||
|
"A1-U05-W02",
|
||||||
|
"A1-U05-W03"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I'd like some vegetables, please.",
|
"text": "I'd like some vegetables, please.",
|
||||||
@@ -576,11 +586,15 @@
|
|||||||
"已经吃饱了",
|
"已经吃饱了",
|
||||||
"不喜欢冰淇淋",
|
"不喜欢冰淇淋",
|
||||||
"甜点太贵"
|
"甜点太贵"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I'm full."
|
"text": "I'm full.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U05-P06"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "No dessert, thanks."
|
"text": "No dessert, thanks."
|
||||||
|
|||||||
@@ -506,11 +506,15 @@
|
|||||||
"八岁",
|
"八岁",
|
||||||
"六岁",
|
"六岁",
|
||||||
"三岁"
|
"三岁"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "This is my family."
|
"text": "This is my family.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U06-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "These are my parents."
|
"text": "These are my parents."
|
||||||
@@ -572,12 +576,16 @@
|
|||||||
"老师",
|
"老师",
|
||||||
"医生",
|
"医生",
|
||||||
"学生"
|
"学生"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "What does he do?",
|
"text": "What does he do?",
|
||||||
"tip": "does he 连读,he 的 h 可以很弱"
|
"tip": "does he 连读,he 的 h 可以很弱",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U06-P03"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "She's a teacher."
|
"text": "She's a teacher."
|
||||||
@@ -631,11 +639,15 @@
|
|||||||
"两间",
|
"两间",
|
||||||
"三间",
|
"三间",
|
||||||
"一间"
|
"一间"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I live in a small flat."
|
"text": "I live in a small flat.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U06-W05"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "There are two bedrooms.",
|
"text": "There are two bedrooms.",
|
||||||
|
|||||||
@@ -451,11 +451,15 @@
|
|||||||
"到街对面坐车",
|
"到街对面坐车",
|
||||||
"就坐这辆车",
|
"就坐这辆车",
|
||||||
"改坐地铁"
|
"改坐地铁"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "A ticket to the airport, please."
|
"text": "A ticket to the airport, please.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U07-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "A single ticket, please."
|
"text": "A single ticket, please."
|
||||||
@@ -513,11 +517,15 @@
|
|||||||
"城市公园站",
|
"城市公园站",
|
||||||
"动物园站",
|
"动物园站",
|
||||||
"第五站"
|
"第五站"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Where do I change?"
|
"text": "Where do I change?",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U07-P04"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "How many stops?"
|
"text": "How many stops?"
|
||||||
@@ -576,11 +584,15 @@
|
|||||||
"下一站",
|
"下一站",
|
||||||
"这一站",
|
"这一站",
|
||||||
"终点站"
|
"终点站"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Is this seat free?"
|
"text": "Is this seat free?",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U07-P10"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Where do I get off?",
|
"text": "Where do I get off?",
|
||||||
|
|||||||
@@ -452,11 +452,20 @@
|
|||||||
"4 号通道最下层",
|
"4 号通道最下层",
|
||||||
"7 号通道最下层",
|
"7 号通道最下层",
|
||||||
"4 号通道最上层"
|
"4 号通道最上层"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Excuse me. Where can I find the milk?"
|
"text": "Excuse me. Where can I find the milk?",
|
||||||
|
"chunks": [
|
||||||
|
"Excuse me.",
|
||||||
|
"Where can I find",
|
||||||
|
"the milk?"
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U08-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I'm looking for the bread.",
|
"text": "I'm looking for the bread.",
|
||||||
@@ -517,11 +526,15 @@
|
|||||||
"两瓶",
|
"两瓶",
|
||||||
"一瓶",
|
"一瓶",
|
||||||
"三瓶"
|
"三瓶"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "a bottle of water"
|
"text": "a bottle of water",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U08-P04"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "a box of eggs"
|
"text": "a box of eggs"
|
||||||
@@ -582,11 +595,15 @@
|
|||||||
"十点",
|
"十点",
|
||||||
"九点",
|
"九点",
|
||||||
"十一点"
|
"十一点"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Where do I pay?"
|
"text": "Where do I pay?",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U08-P07"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "What time do you close?",
|
"text": "What time do you close?",
|
||||||
|
|||||||
@@ -494,12 +494,16 @@
|
|||||||
"高瘦,短黑发,戴眼镜",
|
"高瘦,短黑发,戴眼镜",
|
||||||
"矮个子,长黑发,戴眼镜",
|
"矮个子,长黑发,戴眼镜",
|
||||||
"高瘦,短黑发,不戴眼镜"
|
"高瘦,短黑发,不戴眼镜"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "What does she look like?",
|
"text": "What does she look like?",
|
||||||
"tip": "look like 连读,重音在 look"
|
"tip": "look like 连读,重音在 look",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U09-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "She's tall."
|
"text": "She's tall."
|
||||||
@@ -559,12 +563,16 @@
|
|||||||
"友善又有趣",
|
"友善又有趣",
|
||||||
"聪明但害羞",
|
"聪明但害羞",
|
||||||
"严厉又无聊"
|
"严厉又无聊"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "What is he like?",
|
"text": "What is he like?",
|
||||||
"tip": "不要和 What does he like? 混淆"
|
"tip": "不要和 What does he like? 混淆",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U09-P04"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "She's very kind."
|
"text": "She's very kind."
|
||||||
@@ -620,11 +628,15 @@
|
|||||||
"他的猫走丢了",
|
"他的猫走丢了",
|
||||||
"他工作太多",
|
"他工作太多",
|
||||||
"他生病了"
|
"他生病了"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I'm tired."
|
"text": "I'm tired.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U09-P05"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I'm very happy today."
|
"text": "I'm very happy today."
|
||||||
|
|||||||
@@ -426,11 +426,15 @@
|
|||||||
"四十美元左右",
|
"四十美元左右",
|
||||||
"三十美元左右",
|
"三十美元左右",
|
||||||
"十四美元左右"
|
"十四美元左右"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Take me to the airport, please."
|
"text": "Take me to the airport, please.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U10-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Take me to this address, please."
|
"text": "Take me to this address, please."
|
||||||
@@ -489,12 +493,16 @@
|
|||||||
"桥上车很多",
|
"桥上车很多",
|
||||||
"桥上有事故",
|
"桥上有事故",
|
||||||
"公路更便宜"
|
"公路更便宜"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Please use the meter.",
|
"text": "Please use the meter.",
|
||||||
"tip": "meter 读作 /ˈmiːtə/"
|
"tip": "meter 读作 /ˈmiːtə/",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U10-P06"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Is this the right way?"
|
"text": "Is this the right way?"
|
||||||
@@ -547,11 +555,15 @@
|
|||||||
"三十美元",
|
"三十美元",
|
||||||
"二十美元",
|
"二十美元",
|
||||||
"五十美元"
|
"五十美元"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Can you stop here, please?"
|
"text": "Can you stop here, please?",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U10-P04"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Stop at the corner, please."
|
"text": "Stop at the corner, please."
|
||||||
|
|||||||
@@ -479,12 +479,16 @@
|
|||||||
"七点半",
|
"七点半",
|
||||||
"六点半",
|
"六点半",
|
||||||
"八点"
|
"八点"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I get up at seven.",
|
"text": "I get up at seven.",
|
||||||
"tip": "get up 连读成 /ɡe tʌp/"
|
"tip": "get up 连读成 /ɡe tʌp/",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U11-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "What time do you get up?"
|
"text": "What time do you get up?"
|
||||||
@@ -544,11 +548,15 @@
|
|||||||
"看电视",
|
"看电视",
|
||||||
"跑步",
|
"跑步",
|
||||||
"吃早饭"
|
"吃早饭"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I always have breakfast."
|
"text": "I always have breakfast.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U11-W02"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I usually go to work by bus.",
|
"text": "I usually go to work by bus.",
|
||||||
@@ -606,11 +614,15 @@
|
|||||||
"十一点",
|
"十一点",
|
||||||
"十点",
|
"十点",
|
||||||
"十二点"
|
"十二点"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I watch TV in the evening."
|
"text": "I watch TV in the evening.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U11-P05"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "In my free time, I read books."
|
"text": "In my free time, I read books."
|
||||||
|
|||||||
@@ -500,11 +500,15 @@
|
|||||||
"镜子旁边",
|
"镜子旁边",
|
||||||
"门口",
|
"门口",
|
||||||
"楼上"
|
"楼上"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Just looking, thanks."
|
"text": "Just looking, thanks.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U12-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I need some new clothes.",
|
"text": "I need some new clothes.",
|
||||||
@@ -564,11 +568,15 @@
|
|||||||
"衣服太小",
|
"衣服太小",
|
||||||
"衣服太贵",
|
"衣服太贵",
|
||||||
"试衣间有人"
|
"试衣间有人"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Can I try it on?"
|
"text": "Can I try it on?",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U12-P03"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Where is the changing room?"
|
"text": "Where is the changing room?"
|
||||||
@@ -626,11 +634,15 @@
|
|||||||
"蓝色",
|
"蓝色",
|
||||||
"红色",
|
"红色",
|
||||||
"粉色"
|
"粉色"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Do you have it in blue?"
|
"text": "Do you have it in blue?",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U12-P08"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "It looks good on you."
|
"text": "It looks good on you."
|
||||||
|
|||||||
@@ -508,11 +508,15 @@
|
|||||||
"五月六日",
|
"五月六日",
|
||||||
"五月五日",
|
"五月五日",
|
||||||
"六月五日"
|
"六月五日"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "What's the date today?"
|
"text": "What's the date today?",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U13-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "When is your birthday?"
|
"text": "When is your birthday?"
|
||||||
@@ -568,11 +572,15 @@
|
|||||||
"周日",
|
"周日",
|
||||||
"周六",
|
"周六",
|
||||||
"周五"
|
"周五"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Are you free on Saturday?"
|
"text": "Are you free on Saturday?",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U13-P04"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Sorry, I'm busy."
|
"text": "Sorry, I'm busy."
|
||||||
@@ -632,11 +640,17 @@
|
|||||||
"下周",
|
"下周",
|
||||||
"明天",
|
"明天",
|
||||||
"下个月"
|
"下个月"
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U13-P10"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Can we change the date?"
|
"text": "Can we change the date?",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U13-P11"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I'm free next week."
|
"text": "I'm free next week."
|
||||||
|
|||||||
@@ -475,11 +475,15 @@
|
|||||||
"靠窗",
|
"靠窗",
|
||||||
"靠过道",
|
"靠过道",
|
||||||
"最前排"
|
"最前排"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Here is my passport."
|
"text": "Here is my passport.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U14-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "My flight is at ten."
|
"text": "My flight is at ten."
|
||||||
@@ -535,11 +539,15 @@
|
|||||||
"三点半",
|
"三点半",
|
||||||
"十二点",
|
"十二点",
|
||||||
"两点半"
|
"两点半"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Which gate is it?"
|
"text": "Which gate is it?",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U14-P02"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Is it on time?"
|
"text": "Is it on time?"
|
||||||
@@ -596,11 +604,15 @@
|
|||||||
"两周",
|
"两周",
|
||||||
"两天",
|
"两天",
|
||||||
"两个月"
|
"两个月"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I'm here on holiday."
|
"text": "I'm here on holiday.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U14-P06"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I'm visiting friends."
|
"text": "I'm visiting friends."
|
||||||
|
|||||||
@@ -444,12 +444,16 @@
|
|||||||
"三晚",
|
"三晚",
|
||||||
"两晚",
|
"两晚",
|
||||||
"一晚"
|
"一晚"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I have a reservation.",
|
"text": "I have a reservation.",
|
||||||
"tip": "reservation 重音在第三个音节:res-er-VA-tion"
|
"tip": "reservation 重音在第三个音节:res-er-VA-tion",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U15-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "It's under the name Chen."
|
"text": "It's under the name Chen."
|
||||||
@@ -508,11 +512,15 @@
|
|||||||
"七点",
|
"七点",
|
||||||
"十点",
|
"十点",
|
||||||
"十二点"
|
"十二点"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Here is your key."
|
"text": "Here is your key.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U15-P07"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "What's the Wi-Fi password?"
|
"text": "What's the Wi-Fi password?"
|
||||||
@@ -568,11 +576,15 @@
|
|||||||
"航班在六点",
|
"航班在六点",
|
||||||
"房间太吵",
|
"房间太吵",
|
||||||
"他要去海滩"
|
"他要去海滩"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "What time is check-out?"
|
"text": "What time is check-out?",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U15-P05"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Can I have a quiet room?"
|
"text": "Can I have a quiet room?"
|
||||||
|
|||||||
@@ -482,11 +482,15 @@
|
|||||||
"多云",
|
"多云",
|
||||||
"晴天",
|
"晴天",
|
||||||
"下雨"
|
"下雨"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "What's the weather like today?"
|
"text": "What's the weather like today?",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U16-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "It's sunny."
|
"text": "It's sunny."
|
||||||
@@ -547,11 +551,15 @@
|
|||||||
"凉快",
|
"凉快",
|
||||||
"暖和",
|
"暖和",
|
||||||
"很热"
|
"很热"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "It's warm today."
|
"text": "It's warm today.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U16-W01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "The evening is cool."
|
"text": "The evening is cool."
|
||||||
@@ -610,11 +618,15 @@
|
|||||||
"可以每天游泳",
|
"可以每天游泳",
|
||||||
"天气凉快",
|
"天气凉快",
|
||||||
"可以看雪"
|
"可以看雪"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I like summer."
|
"text": "I like summer.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U16-P09"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Is it going to rain?"
|
"text": "Is it going to rain?"
|
||||||
|
|||||||
@@ -445,11 +445,15 @@
|
|||||||
"没有热水,电视坏了",
|
"没有热水,电视坏了",
|
||||||
"太吵,门打不开",
|
"太吵,门打不开",
|
||||||
"毛巾脏了"
|
"毛巾脏了"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "There's no hot water."
|
"text": "There's no hot water.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U17-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "The TV doesn't work."
|
"text": "The TV doesn't work."
|
||||||
@@ -510,11 +514,15 @@
|
|||||||
"毛巾和水",
|
"毛巾和水",
|
||||||
"毛毯和水",
|
"毛毯和水",
|
||||||
"毛巾和洗发水"
|
"毛巾和洗发水"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Can I have another towel?"
|
"text": "Can I have another towel?",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U17-P03"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Can you send someone?"
|
"text": "Can you send someone?"
|
||||||
@@ -572,11 +580,17 @@
|
|||||||
"太吵了",
|
"太吵了",
|
||||||
"门打不开",
|
"门打不开",
|
||||||
"房间太脏"
|
"房间太脏"
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U17-P04"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "It's too noisy."
|
"text": "It's too noisy.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U17-P04"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Can I change rooms?"
|
"text": "Can I change rooms?"
|
||||||
|
|||||||
@@ -455,11 +455,15 @@
|
|||||||
"头疼和脖子疼",
|
"头疼和脖子疼",
|
||||||
"发烧和胃疼",
|
"发烧和胃疼",
|
||||||
"牙疼"
|
"牙疼"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I don't feel well."
|
"text": "I don't feel well.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U18-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I have a headache.",
|
"text": "I have a headache.",
|
||||||
@@ -520,11 +524,15 @@
|
|||||||
"一天三次,饭后",
|
"一天三次,饭后",
|
||||||
"一天两次,饭前",
|
"一天两次,饭前",
|
||||||
"一天一次,睡前"
|
"一天一次,睡前"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I need some medicine."
|
"text": "I need some medicine.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U18-W02"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Is there a pharmacy near here?"
|
"text": "Is there a pharmacy near here?"
|
||||||
@@ -585,11 +593,15 @@
|
|||||||
"好多了",
|
"好多了",
|
||||||
"还在发烧",
|
"还在发烧",
|
||||||
"要去看医生"
|
"要去看医生"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I feel better now."
|
"text": "I feel better now.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U18-P10"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Take care."
|
"text": "Take care."
|
||||||
|
|||||||
@@ -440,11 +440,15 @@
|
|||||||
"钱包,也找不到护照",
|
"钱包,也找不到护照",
|
||||||
"手机和包",
|
"手机和包",
|
||||||
"酒店钥匙"
|
"酒店钥匙"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "What happened?"
|
"text": "What happened?",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U19-P06"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I lost my wallet."
|
"text": "I lost my wallet."
|
||||||
@@ -504,11 +508,15 @@
|
|||||||
"银行旁边",
|
"银行旁边",
|
||||||
"公交车站旁边",
|
"公交车站旁边",
|
||||||
"酒店对面"
|
"酒店对面"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Someone took my bag."
|
"text": "Someone took my bag.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U19-P02"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Call the police!",
|
"text": "Call the police!",
|
||||||
@@ -567,11 +575,15 @@
|
|||||||
"都很安全",
|
"都很安全",
|
||||||
"有人受伤",
|
"有人受伤",
|
||||||
"还在厨房里"
|
"还在厨房里"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Don't worry."
|
"text": "Don't worry.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U19-P09"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Please come quickly!"
|
"text": "Please come quickly!"
|
||||||
|
|||||||
@@ -395,11 +395,15 @@
|
|||||||
"不在",
|
"不在",
|
||||||
"在开会",
|
"在开会",
|
||||||
"马上来接电话"
|
"马上来接电话"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Hello, this is Chen."
|
"text": "Hello, this is Chen.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U20-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Can I speak to Mr Brown?"
|
"text": "Can I speak to Mr Brown?"
|
||||||
@@ -459,11 +463,15 @@
|
|||||||
"给他回电话",
|
"给他回电话",
|
||||||
"发邮件",
|
"发邮件",
|
||||||
"去找他"
|
"去找他"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Can I leave a message?"
|
"text": "Can I leave a message?",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U20-P06"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Can you call me back?"
|
"text": "Can you call me back?"
|
||||||
@@ -520,11 +528,15 @@
|
|||||||
"打错了",
|
"打错了",
|
||||||
"听不见",
|
"听不见",
|
||||||
"没人接"
|
"没人接"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Sorry, I can't hear you."
|
"text": "Sorry, I can't hear you.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U20-P09"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Sorry, wrong number."
|
"text": "Sorry, wrong number."
|
||||||
|
|||||||
@@ -481,11 +481,15 @@
|
|||||||
"山里",
|
"山里",
|
||||||
"海滩",
|
"海滩",
|
||||||
"公园"
|
"公园"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "What are you doing this weekend?"
|
"text": "What are you doing this weekend?",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U21-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I'm going to the beach."
|
"text": "I'm going to the beach."
|
||||||
@@ -543,11 +547,15 @@
|
|||||||
"他没空",
|
"他没空",
|
||||||
"他不喜欢音乐会",
|
"他不喜欢音乐会",
|
||||||
"他生病了"
|
"他生病了"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Do you want to come?"
|
"text": "Do you want to come?",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U21-P03"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Would you like to join us?",
|
"text": "Would you like to join us?",
|
||||||
@@ -607,11 +615,15 @@
|
|||||||
"打网球和野餐",
|
"打网球和野餐",
|
||||||
"去海滩",
|
"去海滩",
|
||||||
"看音乐会"
|
"看音乐会"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Let's go together."
|
"text": "Let's go together.",
|
||||||
|
"itemIds": [
|
||||||
|
"A1-U21-P09"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Let's play tennis."
|
"text": "Let's play tennis."
|
||||||
|
|||||||
@@ -627,12 +627,16 @@
|
|||||||
"在家",
|
"在家",
|
||||||
"在市场",
|
"在市场",
|
||||||
"在奶奶家"
|
"在奶奶家"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "How was your weekend?",
|
"text": "How was your weekend?",
|
||||||
"tip": "weekend 重音在前:/ˈwiːkend/"
|
"tip": "weekend 重音在前:/ˈwiːkend/",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U01-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "What did you do last weekend?"
|
"text": "What did you do last weekend?"
|
||||||
@@ -690,12 +694,16 @@
|
|||||||
"和朋友烧烤",
|
"和朋友烧烤",
|
||||||
"打扫房子",
|
"打扫房子",
|
||||||
"看电影"
|
"看电影"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I didn't do much.",
|
"text": "I didn't do much.",
|
||||||
"tip": "didn't 末尾的 t 很轻"
|
"tip": "didn't 末尾的 t 很轻",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U01-P06"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I watched a film."
|
"text": "I watched a film."
|
||||||
@@ -754,12 +762,18 @@
|
|||||||
"很棒",
|
"很棒",
|
||||||
"很无聊",
|
"很无聊",
|
||||||
"很糟糕"
|
"很糟糕"
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U01-P10"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Did you have a good time?",
|
"text": "Did you have a good time?",
|
||||||
"tip": "句尾语调上扬"
|
"tip": "句尾语调上扬",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U01-P03"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "It was great!"
|
"text": "It was great!"
|
||||||
|
|||||||
@@ -653,12 +653,16 @@
|
|||||||
"先坐飞机再坐大巴",
|
"先坐飞机再坐大巴",
|
||||||
"先坐火车再坐船",
|
"先坐火车再坐船",
|
||||||
"一直开车"
|
"一直开车"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Where did you go?",
|
"text": "Where did you go?",
|
||||||
"tip": "where did 连读"
|
"tip": "where did 连读",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U02-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "How long did you stay?"
|
"text": "How long did you stay?"
|
||||||
@@ -717,11 +721,15 @@
|
|||||||
"酒店",
|
"酒店",
|
||||||
"当地人",
|
"当地人",
|
||||||
"城市"
|
"城市"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "What was it like?"
|
"text": "What was it like?",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U02-P06"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "It was my first time abroad.",
|
"text": "It was my first time abroad.",
|
||||||
@@ -781,11 +789,15 @@
|
|||||||
"当地的食物",
|
"当地的食物",
|
||||||
"照片",
|
"照片",
|
||||||
"海滩"
|
"海滩"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I took lots of photos."
|
"text": "I took lots of photos.",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U02-P08"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "The best part was the food."
|
"text": "The best part was the food."
|
||||||
|
|||||||
@@ -629,12 +629,16 @@
|
|||||||
"晚上九点左右",
|
"晚上九点左右",
|
||||||
"下午两点",
|
"下午两点",
|
||||||
"早上九点"
|
"早上九点"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I'd like to book a room.",
|
"text": "I'd like to book a room.",
|
||||||
"tip": "book a 连读"
|
"tip": "book a 连读",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U03-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "How much is it for one night?"
|
"text": "How much is it for one night?"
|
||||||
@@ -697,11 +701,15 @@
|
|||||||
"妈妈的生日",
|
"妈妈的生日",
|
||||||
"结婚纪念日",
|
"结婚纪念日",
|
||||||
"公司聚餐"
|
"公司聚餐"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "A table for four, please."
|
"text": "A table for four, please.",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U03-P03"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Could we sit by the window?",
|
"text": "Could we sit by the window?",
|
||||||
@@ -758,11 +766,15 @@
|
|||||||
"轮椅方便的房间",
|
"轮椅方便的房间",
|
||||||
"早一点的早餐",
|
"早一点的早餐",
|
||||||
"去车站的车"
|
"去车站的车"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Is it possible to check in early?"
|
"text": "Is it possible to check in early?",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U03-P11"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I have a special request.",
|
"text": "I have a special request.",
|
||||||
|
|||||||
@@ -666,12 +666,16 @@
|
|||||||
"要加班",
|
"要加班",
|
||||||
"生病了",
|
"生病了",
|
||||||
"家里有事"
|
"家里有事"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I'm afraid I can't come.",
|
"text": "I'm afraid I can't come.",
|
||||||
"tip": "afraid 重音在后:/əˈfreɪd/"
|
"tip": "afraid 重音在后:/əˈfreɪd/",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U04-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Something came up."
|
"text": "Something came up."
|
||||||
@@ -731,11 +735,15 @@
|
|||||||
"周五",
|
"周五",
|
||||||
"周四",
|
"周四",
|
||||||
"周二"
|
"周二"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Can we move it to Thursday?"
|
"text": "Can we move it to Thursday?",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U04-P03"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Is Friday OK for you?"
|
"text": "Is Friday OK for you?"
|
||||||
@@ -795,11 +803,15 @@
|
|||||||
"闹钟没响,又堵车",
|
"闹钟没响,又堵车",
|
||||||
"走错路了",
|
"走错路了",
|
||||||
"公交车坏了"
|
"公交车坏了"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I'm running late."
|
"text": "I'm running late.",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U04-P07"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I'll be there in twenty minutes."
|
"text": "I'll be there in twenty minutes."
|
||||||
|
|||||||
@@ -670,12 +670,16 @@
|
|||||||
"花生",
|
"花生",
|
||||||
"大蒜",
|
"大蒜",
|
||||||
"蘑菇"
|
"蘑菇"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I'm allergic to nuts.",
|
"text": "I'm allergic to nuts.",
|
||||||
"tip": "allergic 重音在 ler:/əˈlɜːdʒɪk/"
|
"tip": "allergic 重音在 ler:/əˈlɜːdʒɪk/",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U05-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I don't eat meat."
|
"text": "I don't eat meat."
|
||||||
@@ -734,11 +738,15 @@
|
|||||||
"鸡肉",
|
"鸡肉",
|
||||||
"鱼",
|
"鱼",
|
||||||
"汤"
|
"汤"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "This isn't what I ordered."
|
"text": "This isn't what I ordered.",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U05-P04"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I ordered the chicken."
|
"text": "I ordered the chicken."
|
||||||
@@ -798,11 +806,15 @@
|
|||||||
"多算了两杯啤酒",
|
"多算了两杯啤酒",
|
||||||
"少算了一道菜",
|
"少算了一道菜",
|
||||||
"算成了别桌的"
|
"算成了别桌的"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "We're still waiting for our food."
|
"text": "We're still waiting for our food.",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U05-P11"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Can we split the bill?"
|
"text": "Can we split the bill?"
|
||||||
|
|||||||
@@ -642,12 +642,16 @@
|
|||||||
"是皮的",
|
"是皮的",
|
||||||
"更便宜",
|
"更便宜",
|
||||||
"更大"
|
"更大"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Which one is better?",
|
"text": "Which one is better?",
|
||||||
"tip": "better 重读"
|
"tip": "better 重读",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U06-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "It's cheaper than the other one.",
|
"text": "It's cheaper than the other one.",
|
||||||
@@ -712,12 +716,16 @@
|
|||||||
"城市房",
|
"城市房",
|
||||||
"花园房",
|
"花园房",
|
||||||
"两个都没选"
|
"两个都没选"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "This one is the most comfortable.",
|
"text": "This one is the most comfortable.",
|
||||||
"tip": "most 重读"
|
"tip": "most 重读",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U06-P03"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "It's too expensive for me."
|
"text": "It's too expensive for me."
|
||||||
@@ -778,12 +786,16 @@
|
|||||||
"坐火车",
|
"坐火车",
|
||||||
"坐出租车",
|
"坐出租车",
|
||||||
"坐公交车"
|
"坐公交车"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "It depends.",
|
"text": "It depends.",
|
||||||
"tip": "depends 重音在后"
|
"tip": "depends 重音在后",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U06-P05"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Which do you like better?"
|
"text": "Which do you like better?"
|
||||||
|
|||||||
@@ -667,11 +667,15 @@
|
|||||||
"有个洞,还少了扣子",
|
"有个洞,还少了扣子",
|
||||||
"太紧了",
|
"太紧了",
|
||||||
"颜色不对"
|
"颜色不对"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "What's wrong with it?"
|
"text": "What's wrong with it?",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U07-P12"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "There's a hole in it."
|
"text": "There's a hole in it."
|
||||||
@@ -732,11 +736,15 @@
|
|||||||
"没有大一号的",
|
"没有大一号的",
|
||||||
"外套坏了",
|
"外套坏了",
|
||||||
"颜色不喜欢"
|
"颜色不喜欢"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I'd like to return this."
|
"text": "I'd like to return this.",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U07-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I bought it here last week."
|
"text": "I bought it here last week."
|
||||||
@@ -800,11 +808,15 @@
|
|||||||
"还在保修期内",
|
"还在保修期内",
|
||||||
"顾客找到了小票",
|
"顾客找到了小票",
|
||||||
"店员弄坏的"
|
"店员弄坏的"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Can you repair it?"
|
"text": "Can you repair it?",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U07-P09"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Can I have a new one instead?"
|
"text": "Can I have a new one instead?"
|
||||||
|
|||||||
@@ -619,11 +619,15 @@
|
|||||||
"三点左右",
|
"三点左右",
|
||||||
"两点",
|
"两点",
|
||||||
"一点"
|
"一点"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I missed my train."
|
"text": "I missed my train.",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U08-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "My flight is delayed.",
|
"text": "My flight is delayed.",
|
||||||
@@ -685,11 +689,15 @@
|
|||||||
"十五美元",
|
"十五美元",
|
||||||
"五十美元",
|
"五十美元",
|
||||||
"不用付钱"
|
"不用付钱"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Can I change my ticket?"
|
"text": "Can I change my ticket?",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U08-P05"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Do I have to pay extra?"
|
"text": "Do I have to pay extra?"
|
||||||
@@ -751,11 +759,15 @@
|
|||||||
"过桥去另一边坐车",
|
"过桥去另一边坐车",
|
||||||
"在车站等两小时",
|
"在车站等两小时",
|
||||||
"坐出租车"
|
"坐出租车"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I'm on the wrong train."
|
"text": "I'm on the wrong train.",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U08-P08"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "What should I do now?"
|
"text": "What should I do now?"
|
||||||
|
|||||||
@@ -646,11 +646,15 @@
|
|||||||
"第三次",
|
"第三次",
|
||||||
"第一次",
|
"第一次",
|
||||||
"第二次"
|
"第二次"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "The air conditioning isn't working."
|
"text": "The air conditioning isn't working.",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U09-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "It's been like this since yesterday.",
|
"text": "It's been like this since yesterday.",
|
||||||
@@ -710,11 +714,15 @@
|
|||||||
"隔壁在开派对,很吵",
|
"隔壁在开派对,很吵",
|
||||||
"空调坏了",
|
"空调坏了",
|
||||||
"床不舒服"
|
"床不舒服"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "There's a smell of smoke."
|
"text": "There's a smell of smoke.",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U09-P04"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "The people next door are having a party."
|
"text": "The people next door are having a party."
|
||||||
@@ -773,11 +781,15 @@
|
|||||||
"两点",
|
"两点",
|
||||||
"十二点",
|
"十二点",
|
||||||
"晚上"
|
"晚上"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Could I have an extra blanket?"
|
"text": "Could I have an extra blanket?",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U09-P08"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Can I check out late?",
|
"text": "Can I check out late?",
|
||||||
|
|||||||
@@ -650,12 +650,16 @@
|
|||||||
"39 度",
|
"39 度",
|
||||||
"37 度",
|
"37 度",
|
||||||
"38 度"
|
"38 度"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I've had a fever for two days.",
|
"text": "I've had a fever for two days.",
|
||||||
"tip": "I've had 连读"
|
"tip": "I've had 连读",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U10-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I feel dizzy."
|
"text": "I feel dizzy."
|
||||||
@@ -719,11 +723,15 @@
|
|||||||
"跑步时摔倒了",
|
"跑步时摔倒了",
|
||||||
"骑车时摔倒了",
|
"骑车时摔倒了",
|
||||||
"打球时撞到了"
|
"打球时撞到了"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I hurt my ankle."
|
"text": "I hurt my ankle.",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U10-P07"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "It hurts when I walk."
|
"text": "It hurts when I walk."
|
||||||
@@ -778,11 +786,15 @@
|
|||||||
"一周后还没好",
|
"一周后还没好",
|
||||||
"两天后",
|
"两天后",
|
||||||
"吃完药以后"
|
"吃完药以后"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "How many should I take?"
|
"text": "How many should I take?",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U10-P09"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Take one before bed."
|
"text": "Take one before bed."
|
||||||
|
|||||||
@@ -676,12 +676,16 @@
|
|||||||
"摄影师",
|
"摄影师",
|
||||||
"小提琴老师",
|
"小提琴老师",
|
||||||
"画家"
|
"画家"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I'm really into photography.",
|
"text": "I'm really into photography.",
|
||||||
"tip": "photography 重音在 tog"
|
"tip": "photography 重音在 tog",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U11-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "How did you get into it?"
|
"text": "How did you get into it?"
|
||||||
@@ -741,11 +745,15 @@
|
|||||||
"人",
|
"人",
|
||||||
"鱼",
|
"鱼",
|
||||||
"海"
|
"海"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Where did you grow up?"
|
"text": "Where did you grow up?",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U11-P05"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "My hometown is famous for its food."
|
"text": "My hometown is famous for its food."
|
||||||
@@ -808,11 +816,15 @@
|
|||||||
"体育场附近的俱乐部",
|
"体育场附近的俱乐部",
|
||||||
"学校",
|
"学校",
|
||||||
"公园"
|
"公园"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "How about you?"
|
"text": "How about you?",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U11-P09"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "That sounds interesting."
|
"text": "That sounds interesting."
|
||||||
|
|||||||
@@ -620,11 +620,15 @@
|
|||||||
"再看看其他酒店",
|
"再看看其他酒店",
|
||||||
"订了贵的酒店",
|
"订了贵的酒店",
|
||||||
"订了便宜的酒店"
|
"订了便宜的酒店"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "What do you think?"
|
"text": "What do you think?",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U12-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "In my opinion, it's too expensive.",
|
"text": "In my opinion, it's too expensive.",
|
||||||
@@ -688,11 +692,15 @@
|
|||||||
"跟团游",
|
"跟团游",
|
||||||
"自己开车",
|
"自己开车",
|
||||||
"住在朋友家"
|
"住在朋友家"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "You should try it."
|
"text": "You should try it.",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U12-P05"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Why don't you ask your manager?",
|
"text": "Why don't you ask your manager?",
|
||||||
@@ -747,11 +755,15 @@
|
|||||||
"先去试一年",
|
"先去试一年",
|
||||||
"拒绝这份工作",
|
"拒绝这份工作",
|
||||||
"带家人一起去"
|
"带家人一起去"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Can I ask your advice?"
|
"text": "Can I ask your advice?",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U12-P09"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "What would you do?"
|
"text": "What would you do?"
|
||||||
|
|||||||
@@ -664,11 +664,15 @@
|
|||||||
"河和整个老城",
|
"河和整个老城",
|
||||||
"大海",
|
"大海",
|
||||||
"新城区"
|
"新城区"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "You can't miss it."
|
"text": "You can't miss it.",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U13-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "It's one of the oldest temples in China."
|
"text": "It's one of the oldest temples in China."
|
||||||
@@ -729,11 +733,15 @@
|
|||||||
"周末人很多",
|
"周末人很多",
|
||||||
"周末不开门",
|
"周末不开门",
|
||||||
"周末车票贵"
|
"周末车票贵"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "It's about an hour from here."
|
"text": "It's about an hour from here.",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U13-P04"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "The best time to go is in spring."
|
"text": "The best time to go is in spring."
|
||||||
@@ -792,11 +800,15 @@
|
|||||||
"免费演出",
|
"免费演出",
|
||||||
"夜市",
|
"夜市",
|
||||||
"电影"
|
"电影"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "It's a great place for families."
|
"text": "It's a great place for families.",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U13-P10"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "The old town is very attractive.",
|
"text": "The old town is very attractive.",
|
||||||
|
|||||||
@@ -635,11 +635,20 @@
|
|||||||
"说话人的朋友",
|
"说话人的朋友",
|
||||||
"说话人自己",
|
"说话人自己",
|
||||||
"火车站"
|
"火车站"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I think I left it on the train."
|
"text": "I think I left it on the train.",
|
||||||
|
"chunks": [
|
||||||
|
"I think",
|
||||||
|
"I left it",
|
||||||
|
"on the train."
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U14-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "What does it look like?",
|
"text": "What does it look like?",
|
||||||
@@ -702,11 +711,15 @@
|
|||||||
"找到后打电话",
|
"找到后打电话",
|
||||||
"马上去找",
|
"马上去找",
|
||||||
"给一个新包"
|
"给一个新包"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Where's the lost property office?"
|
"text": "Where's the lost property office?",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U14-P11"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Has anyone handed in a bag?",
|
"text": "Has anyone handed in a bag?",
|
||||||
@@ -766,11 +779,15 @@
|
|||||||
"在公交车上被人从口袋里偷走",
|
"在公交车上被人从口袋里偷走",
|
||||||
"有人抢了",
|
"有人抢了",
|
||||||
"落在出租车上"
|
"落在出租车上"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "My phone was stolen."
|
"text": "My phone was stolen.",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U14-P07"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I need to report it."
|
"text": "I need to report it."
|
||||||
|
|||||||
@@ -585,11 +585,15 @@
|
|||||||
"带点喝的,周五前回复",
|
"带点喝的,周五前回复",
|
||||||
"做饭",
|
"做饭",
|
||||||
"周六前回复"
|
"周六前回复"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "We're having a party on Saturday."
|
"text": "We're having a party on Saturday.",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U15-P03"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I hope you can come."
|
"text": "I hope you can come."
|
||||||
@@ -651,11 +655,20 @@
|
|||||||
"感谢安娜昨晚的邀请",
|
"感谢安娜昨晚的邀请",
|
||||||
"邀请安娜吃饭",
|
"邀请安娜吃饭",
|
||||||
"说自己会晚到"
|
"说自己会晚到"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Just a quick message to say thank you."
|
"text": "Just a quick message to say thank you.",
|
||||||
|
"chunks": [
|
||||||
|
"Just a quick message",
|
||||||
|
"to say",
|
||||||
|
"thank you."
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U15-P12"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Thanks for inviting me."
|
"text": "Thanks for inviting me."
|
||||||
@@ -717,11 +730,15 @@
|
|||||||
"堵车",
|
"堵车",
|
||||||
"车坏了",
|
"车坏了",
|
||||||
"忘了时间"
|
"忘了时间"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Sorry I didn't reply sooner."
|
"text": "Sorry I didn't reply sooner.",
|
||||||
|
"itemIds": [
|
||||||
|
"A2-U15-P06"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I'm going to be ten minutes late."
|
"text": "I'm going to be ten minutes late."
|
||||||
|
|||||||
@@ -830,11 +830,20 @@
|
|||||||
"沿着路走",
|
"沿着路走",
|
||||||
"等公交车",
|
"等公交车",
|
||||||
"在饭店吃饭"
|
"在饭店吃饭"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "It all started when we missed the last bus."
|
"text": "It all started when we missed the last bus.",
|
||||||
|
"chunks": [
|
||||||
|
"It all started",
|
||||||
|
"when we missed",
|
||||||
|
"the last bus."
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U01-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "We were walking along the beach when it started to rain.",
|
"text": "We were walking along the beach when it started to rain.",
|
||||||
@@ -891,11 +900,20 @@
|
|||||||
"酒店老板",
|
"酒店老板",
|
||||||
"餐厅厨师",
|
"餐厅厨师",
|
||||||
"公交车司机"
|
"公交车司机"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "After a while, a car stopped next to us."
|
"text": "After a while, a car stopped next to us.",
|
||||||
|
"chunks": [
|
||||||
|
"After a while,",
|
||||||
|
"a car stopped",
|
||||||
|
"next to us."
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U01-P04"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "By the time we got there, the restaurant had closed.",
|
"text": "By the time we got there, the restaurant had closed.",
|
||||||
@@ -957,11 +975,20 @@
|
|||||||
"第二天早上的日出",
|
"第二天早上的日出",
|
||||||
"司机做的晚饭",
|
"司机做的晚饭",
|
||||||
"坐车的经历"
|
"坐车的经历"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "In the end, we had a great time."
|
"text": "In the end, we had a great time.",
|
||||||
|
"chunks": [
|
||||||
|
"In the end,",
|
||||||
|
"we had",
|
||||||
|
"a great time."
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U01-P08"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "The best part was the sunrise."
|
"text": "The best part was the sunrise."
|
||||||
|
|||||||
@@ -809,11 +809,15 @@
|
|||||||
"坐夜车",
|
"坐夜车",
|
||||||
"坐大巴",
|
"坐大巴",
|
||||||
"自己开车"
|
"自己开车"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "What's the cheapest way to get there?"
|
"text": "What's the cheapest way to get there?",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U02-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "The train is faster, but the bus is much cheaper.",
|
"text": "The train is faster, but the bus is much cheaper.",
|
||||||
@@ -879,11 +883,20 @@
|
|||||||
"每天都要坐火车",
|
"每天都要坐火车",
|
||||||
"那里最便宜",
|
"那里最便宜",
|
||||||
"那里有早餐"
|
"那里有早餐"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "We have a budget of about 500 dollars each."
|
"text": "We have a budget of about 500 dollars each.",
|
||||||
|
"chunks": [
|
||||||
|
"We have a budget",
|
||||||
|
"of about 500 dollars",
|
||||||
|
"each."
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U02-P05"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Does that include accommodation?",
|
"text": "Does that include accommodation?",
|
||||||
@@ -942,11 +955,15 @@
|
|||||||
"票价会涨",
|
"票价会涨",
|
||||||
"夜车快满了",
|
"夜车快满了",
|
||||||
"车要还了"
|
"车要还了"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "It's not worth the extra money."
|
"text": "It's not worth the extra money.",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U02-P04"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "We should book it in advance.",
|
"text": "We should book it in advance.",
|
||||||
|
|||||||
@@ -768,11 +768,15 @@
|
|||||||
"旅游签证",
|
"旅游签证",
|
||||||
"工作签证",
|
"工作签证",
|
||||||
"学生签证"
|
"学生签证"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I'm here on holiday."
|
"text": "I'm here on holiday.",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U03-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I'm staying for ten days."
|
"text": "I'm staying for ten days."
|
||||||
@@ -826,11 +830,15 @@
|
|||||||
"自己用的",
|
"自己用的",
|
||||||
"送人的",
|
"送人的",
|
||||||
"卖的"
|
"卖的"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I have nothing to declare."
|
"text": "I have nothing to declare.",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U03-P04"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "It's for my own use."
|
"text": "It's for my own use."
|
||||||
@@ -881,11 +889,15 @@
|
|||||||
"银色大箱子,上面有名字",
|
"银色大箱子,上面有名字",
|
||||||
"黑色小包",
|
"黑色小包",
|
||||||
"红色箱子,两个轮子"
|
"红色箱子,两个轮子"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "My suitcase didn't arrive."
|
"text": "My suitcase didn't arrive.",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U03-P06"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I was on flight CA173 from Beijing."
|
"text": "I was on flight CA173 from Beijing."
|
||||||
@@ -940,11 +952,15 @@
|
|||||||
"一两天",
|
"一两天",
|
||||||
"一周",
|
"一周",
|
||||||
"当天晚上"
|
"当天晚上"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "My suitcase is damaged."
|
"text": "My suitcase is damaged.",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U03-P09"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "One of the wheels is broken."
|
"text": "One of the wheels is broken."
|
||||||
|
|||||||
@@ -821,11 +821,15 @@
|
|||||||
"10:30 从 4 站台出发的车",
|
"10:30 从 4 站台出发的车",
|
||||||
"9:40 去约克的车",
|
"9:40 去约克的车",
|
||||||
"10:05 从 2 站台出发的车"
|
"10:05 从 2 站台出发的车"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "The train is delayed."
|
"text": "The train is delayed.",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U04-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "The train has been cancelled.",
|
"text": "The train has been cancelled.",
|
||||||
@@ -879,11 +883,20 @@
|
|||||||
"旅客 Tom Hughes",
|
"旅客 Tom Hughes",
|
||||||
"去巴黎的所有旅客",
|
"去巴黎的所有旅客",
|
||||||
"带小孩的旅客"
|
"带小孩的旅客"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Flight EZ482 is now boarding at gate 18."
|
"text": "Flight EZ482 is now boarding at gate 18.",
|
||||||
|
"chunks": [
|
||||||
|
"Flight EZ482",
|
||||||
|
"is now boarding",
|
||||||
|
"at gate 18."
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U04-P04"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "This is the final call.",
|
"text": "This is the final call.",
|
||||||
@@ -932,11 +945,15 @@
|
|||||||
"20 号",
|
"20 号",
|
||||||
"12 号",
|
"12 号",
|
||||||
"8 号"
|
"8 号"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Sorry, I didn't catch that."
|
"text": "Sorry, I didn't catch that.",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U04-P07"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Did they say gate 12?"
|
"text": "Did they say gate 12?"
|
||||||
@@ -990,11 +1007,15 @@
|
|||||||
"三点",
|
"三点",
|
||||||
"两点",
|
"两点",
|
||||||
"四点"
|
"四点"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Please stay together."
|
"text": "Please stay together.",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U04-P10"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "On your left, you can see the old town hall."
|
"text": "On your left, you can see the old town hall."
|
||||||
|
|||||||
@@ -736,11 +736,15 @@
|
|||||||
"在公交车上",
|
"在公交车上",
|
||||||
"在酒店里",
|
"在酒店里",
|
||||||
"在餐馆里"
|
"在餐馆里"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I'd like to report a theft."
|
"text": "I'd like to report a theft.",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U05-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Someone stole my wallet.",
|
"text": "Someone stole my wallet.",
|
||||||
@@ -798,11 +802,15 @@
|
|||||||
"拿一份报案证明办保险",
|
"拿一份报案证明办保险",
|
||||||
"马上换一个钱包",
|
"马上换一个钱包",
|
||||||
"去机场问问"
|
"去机场问问"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I need a police report."
|
"text": "I need a police report.",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U05-P05"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I need to block my card."
|
"text": "I need to block my card."
|
||||||
@@ -852,11 +860,15 @@
|
|||||||
"两张照片和报案证明",
|
"两张照片和报案证明",
|
||||||
"机票和银行卡",
|
"机票和银行卡",
|
||||||
"酒店的房卡"
|
"酒店的房卡"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I've lost my passport."
|
"text": "I've lost my passport.",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U05-P07"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Where is the embassy?",
|
"text": "Where is the embassy?",
|
||||||
@@ -907,11 +919,15 @@
|
|||||||
"一个骑车的男人",
|
"一个骑车的男人",
|
||||||
"一个开车的女人",
|
"一个开车的女人",
|
||||||
"一个小孩"
|
"一个小孩"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "There's been an accident."
|
"text": "There's been an accident.",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U05-P09"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Someone is hurt."
|
"text": "Someone is hurt."
|
||||||
|
|||||||
@@ -735,11 +735,15 @@
|
|||||||
"发烧",
|
"发烧",
|
||||||
"咳嗽",
|
"咳嗽",
|
||||||
"胸口疼"
|
"胸口疼"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "It's not getting any better."
|
"text": "It's not getting any better.",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U06-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "The pain comes and goes."
|
"text": "The pain comes and goes."
|
||||||
@@ -795,11 +799,15 @@
|
|||||||
"膝盖手术",
|
"膝盖手术",
|
||||||
"心脏手术",
|
"心脏手术",
|
||||||
"眼睛手术"
|
"眼睛手术"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I've had this problem before."
|
"text": "I've had this problem before.",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U06-P03"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I'm allergic to some medicines.",
|
"text": "I'm allergic to some medicines.",
|
||||||
@@ -853,11 +861,15 @@
|
|||||||
"大约一周",
|
"大约一周",
|
||||||
"两三天",
|
"两三天",
|
||||||
"一个月"
|
"一个月"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Is it serious?"
|
"text": "Is it serious?",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U06-P07"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "How long will it take to get better?"
|
"text": "How long will it take to get better?"
|
||||||
@@ -908,11 +920,15 @@
|
|||||||
"每天两次,随餐服用",
|
"每天两次,随餐服用",
|
||||||
"每天一次,睡前服用",
|
"每天一次,睡前服用",
|
||||||
"每天三次,饭前服用"
|
"每天三次,饭前服用"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Take it with food."
|
"text": "Take it with food.",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U06-P10"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Don't drink alcohol while you're taking it."
|
"text": "Don't drink alcohol while you're taking it."
|
||||||
|
|||||||
@@ -729,12 +729,16 @@
|
|||||||
"房间和网站上说的不一样",
|
"房间和网站上说的不一样",
|
||||||
"房费太贵",
|
"房费太贵",
|
||||||
"前台态度不好"
|
"前台态度不好"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I'd like to make a complaint.",
|
"text": "I'd like to make a complaint.",
|
||||||
"tip": "complaint 读 /kəmˈpleɪnt/"
|
"tip": "complaint 读 /kəmˈpleɪnt/",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U07-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I'm not satisfied with the room."
|
"text": "I'm not satisfied with the room."
|
||||||
@@ -784,11 +788,15 @@
|
|||||||
"和负责人谈",
|
"和负责人谈",
|
||||||
"免费喝一杯",
|
"免费喝一杯",
|
||||||
"马上退房"
|
"马上退房"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "That doesn't seem fair."
|
"text": "That doesn't seem fair.",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U07-P04"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I'm afraid that's not good enough.",
|
"text": "I'm afraid that's not good enough.",
|
||||||
@@ -844,11 +852,15 @@
|
|||||||
"今晚打八折,明天换房",
|
"今晚打八折,明天换房",
|
||||||
"马上换海景房",
|
"马上换海景房",
|
||||||
"全额退款"
|
"全额退款"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "What can you offer me?"
|
"text": "What can you offer me?",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U07-P05"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Could you at least give us a discount?"
|
"text": "Could you at least give us a discount?"
|
||||||
@@ -902,11 +914,15 @@
|
|||||||
"30%",
|
"30%",
|
||||||
"20%",
|
"20%",
|
||||||
"全额"
|
"全额"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "That sounds reasonable."
|
"text": "That sounds reasonable.",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U07-P10"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Could I have that in writing?"
|
"text": "Could I have that in writing?"
|
||||||
|
|||||||
@@ -736,11 +736,15 @@
|
|||||||
"周六十点",
|
"周六十点",
|
||||||
"周日十点",
|
"周日十点",
|
||||||
"周六下午"
|
"周六下午"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I'm looking for a place to rent."
|
"text": "I'm looking for a place to rent.",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U08-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Is it furnished?",
|
"text": "Is it furnished?",
|
||||||
@@ -795,11 +799,15 @@
|
|||||||
"水费和网费",
|
"水费和网费",
|
||||||
"燃气费",
|
"燃气费",
|
||||||
"所有费用"
|
"所有费用"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "How much is the rent a month?"
|
"text": "How much is the rent a month?",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U08-P02"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Are bills included?",
|
"text": "Are bills included?",
|
||||||
@@ -850,11 +858,15 @@
|
|||||||
"一个月",
|
"一个月",
|
||||||
"一周",
|
"一周",
|
||||||
"三个月"
|
"三个月"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "When can I move in?"
|
"text": "When can I move in?",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U08-P06"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Who do I call if something breaks?"
|
"text": "Who do I call if something breaks?"
|
||||||
@@ -905,11 +917,15 @@
|
|||||||
"绿色垃圾桶",
|
"绿色垃圾桶",
|
||||||
"楼门口",
|
"楼门口",
|
||||||
"厨房里"
|
"厨房里"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Are pets allowed?"
|
"text": "Are pets allowed?",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U08-P08"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "The rubbish goes out on Mondays.",
|
"text": "The rubbish goes out on Mondays.",
|
||||||
|
|||||||
@@ -702,11 +702,15 @@
|
|||||||
"没有,学生免费",
|
"没有,学生免费",
|
||||||
"每月五英镑",
|
"每月五英镑",
|
||||||
"第一年免费"
|
"第一年免费"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I'd like to open a bank account."
|
"text": "I'd like to open a bank account.",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U09-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "What do I need to bring?"
|
"text": "What do I need to bring?"
|
||||||
@@ -760,11 +764,15 @@
|
|||||||
"商店里",
|
"商店里",
|
||||||
"银行柜台",
|
"银行柜台",
|
||||||
"邮局"
|
"邮局"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I'd like to send money to China."
|
"text": "I'd like to send money to China.",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U09-P04"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I'd like to top up my phone.",
|
"text": "I'd like to top up my phone.",
|
||||||
@@ -815,11 +823,20 @@
|
|||||||
"十天左右",
|
"十天左右",
|
||||||
"三天左右",
|
"三天左右",
|
||||||
"一个月左右"
|
"一个月左右"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I'd like to send this parcel to China."
|
"text": "I'd like to send this parcel to China.",
|
||||||
|
"chunks": [
|
||||||
|
"I'd like to send",
|
||||||
|
"this parcel",
|
||||||
|
"to China."
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U09-P06"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "When will it arrive?"
|
"text": "When will it arrive?"
|
||||||
@@ -878,11 +895,15 @@
|
|||||||
"周四上午",
|
"周四上午",
|
||||||
"周四下午",
|
"周四下午",
|
||||||
"周五上午"
|
"周五上午"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I'd like to book a repair."
|
"text": "I'd like to book a repair.",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U09-P10"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Is anyone free on Thursday?"
|
"text": "Is anyone free on Thursday?"
|
||||||
|
|||||||
@@ -719,12 +719,16 @@
|
|||||||
"车站",
|
"车站",
|
||||||
"学校",
|
"学校",
|
||||||
"海滩"
|
"海滩"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I'd highly recommend it.",
|
"text": "I'd highly recommend it.",
|
||||||
"tip": "highly 读 /ˈhaɪli/"
|
"tip": "highly 读 /ˈhaɪli/",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U10-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "For one thing, it's close to the station."
|
"text": "For one thing, it's close to the station."
|
||||||
@@ -780,12 +784,20 @@
|
|||||||
"没什么特别的",
|
"没什么特别的",
|
||||||
"非常安静",
|
"非常安静",
|
||||||
"音乐很好听"
|
"音乐很好听"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "The reason I like it is the atmosphere.",
|
"text": "The reason I like it is the atmosphere.",
|
||||||
"tip": "atmosphere 读 /ˈætməsfɪə/"
|
"tip": "atmosphere 读 /ˈætməsfɪə/",
|
||||||
|
"chunks": [
|
||||||
|
"The reason I like it",
|
||||||
|
"is the atmosphere."
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U10-P02"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Personally, I prefer the smaller one.",
|
"text": "Personally, I prefer the smaller one.",
|
||||||
@@ -842,12 +854,20 @@
|
|||||||
"去问山姆",
|
"去问山姆",
|
||||||
"住在海边",
|
"住在海边",
|
||||||
"住在镇上"
|
"住在镇上"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I see what you mean, but it's too far.",
|
"text": "I see what you mean, but it's too far.",
|
||||||
"tip": "see what you mean 连读"
|
"tip": "see what you mean 连读",
|
||||||
|
"chunks": [
|
||||||
|
"I see what you mean,",
|
||||||
|
"but it's too far."
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U10-P06"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "That's a fair point."
|
"text": "That's a fair point."
|
||||||
@@ -901,12 +921,16 @@
|
|||||||
"他的弟弟",
|
"他的弟弟",
|
||||||
"他的朋友",
|
"他的朋友",
|
||||||
"他的妈妈"
|
"他的妈妈"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "It depends on what you're looking for.",
|
"text": "It depends on what you're looking for.",
|
||||||
"tip": "depends on 连读"
|
"tip": "depends on 连读",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U10-P09"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Have you thought about going by bike?"
|
"text": "Have you thought about going by bike?"
|
||||||
|
|||||||
@@ -774,11 +774,15 @@
|
|||||||
"百分之十",
|
"百分之十",
|
||||||
"百分之二十",
|
"百分之二十",
|
||||||
"不给小费"
|
"不给小费"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Do people tip in restaurants here?"
|
"text": "Do people tip in restaurants here?",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U11-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "It's common to leave ten percent.",
|
"text": "It's common to leave ten percent.",
|
||||||
@@ -840,11 +844,19 @@
|
|||||||
"脱鞋",
|
"脱鞋",
|
||||||
"带花",
|
"带花",
|
||||||
"鞠躬"
|
"鞠躬"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Is it rude to eat on the train?"
|
"text": "Is it rude to eat on the train?",
|
||||||
|
"chunks": [
|
||||||
|
"Is it rude",
|
||||||
|
"to eat on the train?"
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U11-P03"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "You're supposed to take your shoes off.",
|
"text": "You're supposed to take your shoes off.",
|
||||||
@@ -909,11 +921,19 @@
|
|||||||
"装钱的红包",
|
"装钱的红包",
|
||||||
"新衣服",
|
"新衣服",
|
||||||
"蛋糕"
|
"蛋糕"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "What do people usually do at New Year?"
|
"text": "What do people usually do at New Year?",
|
||||||
|
"chunks": [
|
||||||
|
"What do people usually do",
|
||||||
|
"at New Year?"
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U11-P08"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "It's a tradition to give red envelopes.",
|
"text": "It's a tradition to give red envelopes.",
|
||||||
|
|||||||
@@ -767,12 +767,16 @@
|
|||||||
"比想象的难",
|
"比想象的难",
|
||||||
"非常轻松",
|
"非常轻松",
|
||||||
"没有跑完"
|
"没有跑完"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "What have you been up to lately?",
|
"text": "What have you been up to lately?",
|
||||||
"tip": "been up to 连读,lately 重音在第一个音节"
|
"tip": "been up to 连读,lately 重音在第一个音节",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U12-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "How did it go?"
|
"text": "How did it go?"
|
||||||
@@ -834,12 +838,16 @@
|
|||||||
"喜欢周末做的志愿工作",
|
"喜欢周末做的志愿工作",
|
||||||
"工资太低",
|
"工资太低",
|
||||||
"想搬家"
|
"想搬家"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I'm thinking of changing jobs.",
|
"text": "I'm thinking of changing jobs.",
|
||||||
"tip": "thinking of 连读"
|
"tip": "thinking of 连读",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U12-P06"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "What made you decide to do that?"
|
"text": "What made you decide to do that?"
|
||||||
@@ -901,11 +909,15 @@
|
|||||||
"孤单",
|
"孤单",
|
||||||
"兴奋",
|
"兴奋",
|
||||||
"很累"
|
"很累"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "How did you feel about it?"
|
"text": "How did you feel about it?",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U12-P08"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "That must have been hard.",
|
"text": "That must have been hard.",
|
||||||
|
|||||||
@@ -890,12 +890,20 @@
|
|||||||
"很吵",
|
"很吵",
|
||||||
"很贵",
|
"很贵",
|
||||||
"很远"
|
"很远"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "According to the reviews, the food is great.",
|
"text": "According to the reviews, the food is great.",
|
||||||
"tip": "according to 连读"
|
"tip": "according to 连读",
|
||||||
|
"chunks": [
|
||||||
|
"According to the reviews,",
|
||||||
|
"the food is great."
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U13-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "It says here that breakfast is included."
|
"text": "It says here that breakfast is included."
|
||||||
@@ -950,11 +958,15 @@
|
|||||||
"五月底",
|
"五月底",
|
||||||
"两周后",
|
"两周后",
|
||||||
"三月初"
|
"三月初"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "Read the instructions first."
|
"text": "Read the instructions first.",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U13-P05"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Keep it in a cool, dry place.",
|
"text": "Keep it in a cool, dry place.",
|
||||||
@@ -1004,11 +1016,17 @@
|
|||||||
"儿童必须有成人陪同",
|
"儿童必须有成人陪同",
|
||||||
"狗必须拴绳",
|
"狗必须拴绳",
|
||||||
"禁止游泳"
|
"禁止游泳"
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U13-P10"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "What does this sign mean?"
|
"text": "What does this sign mean?",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U13-P07"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "Dogs must be kept on a lead.",
|
"text": "Dogs must be kept on a lead.",
|
||||||
@@ -1060,12 +1078,16 @@
|
|||||||
"填一份简短的问卷",
|
"填一份简短的问卷",
|
||||||
"买两件以上",
|
"买两件以上",
|
||||||
"办会员卡"
|
"办会员卡"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "It's closed until further notice.",
|
"text": "It's closed until further notice.",
|
||||||
"tip": "further 读 /ˈfɜːðə/"
|
"tip": "further 读 /ˈfɜːðə/",
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U13-P08"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "The offer ends on Sunday."
|
"text": "The offer ends on Sunday."
|
||||||
|
|||||||
@@ -721,12 +721,21 @@
|
|||||||
"外套晚到且尺码不对",
|
"外套晚到且尺码不对",
|
||||||
"外套颜色不对",
|
"外套颜色不对",
|
||||||
"外套价格太贵"
|
"外套价格太贵"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I am writing to complain about my order.",
|
"text": "I am writing to complain about my order.",
|
||||||
"tip": "writing to 连读"
|
"tip": "writing to 连读",
|
||||||
|
"chunks": [
|
||||||
|
"I am writing",
|
||||||
|
"to complain",
|
||||||
|
"about my order."
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U14-P01"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I am writing to let you know about a problem."
|
"text": "I am writing to let you know about a problem."
|
||||||
@@ -783,12 +792,21 @@
|
|||||||
"找了另一家酒店",
|
"找了另一家酒店",
|
||||||
"自己打扫房间",
|
"自己打扫房间",
|
||||||
"回家了"
|
"回家了"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "On arrival, we found that the room was dirty.",
|
"text": "On arrival, we found that the room was dirty.",
|
||||||
"tip": "arrival 重音在第二个音节"
|
"tip": "arrival 重音在第二个音节",
|
||||||
|
"chunks": [
|
||||||
|
"On arrival,",
|
||||||
|
"we found that",
|
||||||
|
"the room was dirty."
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U14-P03"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "To make matters worse, nobody helped us."
|
"text": "To make matters worse, nobody helped us."
|
||||||
@@ -851,12 +869,21 @@
|
|||||||
"更正名字",
|
"更正名字",
|
||||||
"换一个房间",
|
"换一个房间",
|
||||||
"寄一个新的"
|
"寄一个新的"
|
||||||
]
|
],
|
||||||
|
"itemIds": []
|
||||||
},
|
},
|
||||||
"speaking": [
|
"speaking": [
|
||||||
{
|
{
|
||||||
"text": "I would be grateful if you could reply soon.",
|
"text": "I would be grateful if you could reply soon.",
|
||||||
"tip": "grateful 读 /ˈɡreɪtfl/"
|
"tip": "grateful 读 /ˈɡreɪtfl/",
|
||||||
|
"chunks": [
|
||||||
|
"I would be grateful",
|
||||||
|
"if you could",
|
||||||
|
"reply soon."
|
||||||
|
],
|
||||||
|
"itemIds": [
|
||||||
|
"B1-U14-P07"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"text": "I would like to request a full refund.",
|
"text": "I would like to request a full refund.",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
part of '../ai_service.dart';
|
part of '../ai_service.dart';
|
||||||
|
|
||||||
/// Advisory evaluation for open-ended learner answers. Local rules still
|
/// Evaluation for open-ended learner answers. Lesson submissions combine this
|
||||||
/// decide whether an attempt counts as evidence or changes learning state.
|
/// verdict with local task rules before recording independent evidence.
|
||||||
class AiAnswerEvaluationCapability {
|
class AiAnswerEvaluationCapability {
|
||||||
const AiAnswerEvaluationCapability._(this._service);
|
const AiAnswerEvaluationCapability._(this._service);
|
||||||
|
|
||||||
|
|||||||
@@ -964,8 +964,9 @@ class AiService {
|
|||||||
'''Evaluate a beginner's open-ended English writing response using the input JSON.
|
'''Evaluate a beginner's open-ended English writing response using the input JSON.
|
||||||
Return JSON only with exactly these fields: schemaVersion, verdict, feedback, suggestion, missing, lessonId.
|
Return JSON only with exactly these fields: schemaVersion, verdict, feedback, suggestion, missing, lessonId.
|
||||||
schemaVersion must be "writing-feedback-1" and lessonId must exactly copy input.lessonId.
|
schemaVersion must be "writing-feedback-1" and lessonId must exactly copy input.lessonId.
|
||||||
verdict must be accepted, rewrite, or uncertain. feedback is one short helpful Chinese sentence (max 80 Chinese characters). suggestion is null or one simple rewrite at input.level (max 18 words). missing is an array of at most 3 short Chinese descriptions.
|
verdict must be accepted, rewrite, or uncertain. Use accepted only when the answer meets input.taskPrompt and has no spelling or grammar mistakes; ignore minor capitalisation and final punctuation. Use rewrite when the task is missed or there is a spelling or grammar mistake. Use uncertain when the intended meaning cannot be determined.
|
||||||
Assess only whether the learner expressed input.taskPrompt. Do not claim pronunciation, do not introduce grammar beyond input.level, and do not invent facts the learner did not write.''';
|
feedback is one short helpful Chinese sentence (max 80 Chinese characters). suggestion is null or one simple rewrite at input.level (max 18 words). missing is an array of at most 3 short Chinese descriptions of actual issues.
|
||||||
|
Do not claim pronunciation, do not introduce grammar beyond input.level, and do not invent facts the learner did not write.''';
|
||||||
final content = await _requestStructured(
|
final content = await _requestStructured(
|
||||||
provider: provider,
|
provider: provider,
|
||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
@@ -985,8 +986,9 @@ Assess only whether the learner expressed input.taskPrompt. Do not claim pronunc
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Checks a review answer or dialogue turn for spelling and grammar. It
|
/// Checks a review answer or dialogue turn for spelling and grammar. It
|
||||||
/// reuses the writing feedback schema, keyed by [answerId], and stays
|
/// reuses the writing feedback schema, keyed by [answerId]. Lesson answers
|
||||||
/// advisory: the local check still decides whether the answer counts.
|
/// need both a local task match and an accepted verdict for independent
|
||||||
|
/// evidence; the caller makes that decision on-device.
|
||||||
Future<WritingAiFeedback?> answerFeedback({
|
Future<WritingAiFeedback?> answerFeedback({
|
||||||
required AiProviderType provider,
|
required AiProviderType provider,
|
||||||
required String endpoint,
|
required String endpoint,
|
||||||
|
|||||||
@@ -53,12 +53,19 @@ abstract class _AppStateData extends ChangeNotifier {
|
|||||||
LessonStep lessonStep = LessonStep.preview;
|
LessonStep lessonStep = LessonStep.preview;
|
||||||
int previewIndex = 0;
|
int previewIndex = 0;
|
||||||
bool lessonListeningComplete = false;
|
bool lessonListeningComplete = false;
|
||||||
|
bool listeningAudioPlayed = false;
|
||||||
|
bool listeningAudioUnavailable = false;
|
||||||
|
ListeningSupport listeningSupport = ListeningSupport.none;
|
||||||
|
int listeningAttempts = 0;
|
||||||
|
bool sentenceBuildUsed = false;
|
||||||
|
int sentenceBuildIndex = 0;
|
||||||
bool lessonSpeakingComplete = false;
|
bool lessonSpeakingComplete = false;
|
||||||
bool lessonReadingComplete = false;
|
bool lessonReadingComplete = false;
|
||||||
bool lessonWritingComplete = false;
|
bool lessonWritingComplete = false;
|
||||||
bool lessonDialogueComplete = false;
|
bool lessonDialogueComplete = false;
|
||||||
bool independentAttemptComplete = false;
|
bool independentAttemptComplete = false;
|
||||||
bool independentAttemptAssisted = false;
|
bool independentAttemptAssisted = false;
|
||||||
|
bool independentAttemptPending = false;
|
||||||
bool independentAttemptSpoken = false;
|
bool independentAttemptSpoken = false;
|
||||||
String lessonWritingDraft = '';
|
String lessonWritingDraft = '';
|
||||||
String independentAttemptDraft = '';
|
String independentAttemptDraft = '';
|
||||||
@@ -101,7 +108,8 @@ abstract class _AppStateData extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class AppState extends _AppStateData
|
class AppState extends _AppStateData
|
||||||
with _ReviewAndMastery,
|
with
|
||||||
|
_ReviewAndMastery,
|
||||||
_ReceptiveWords,
|
_ReceptiveWords,
|
||||||
_AssessmentProgress,
|
_AssessmentProgress,
|
||||||
_LessonProgress,
|
_LessonProgress,
|
||||||
@@ -245,10 +253,14 @@ class AppState extends _AppStateData
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Applies the placement result. The start may land on any loaded lesson
|
/// Placement never marks an earlier lesson as passed or opens draft content.
|
||||||
/// across A0–B1, and placement never counts as passing a lesson.
|
|
||||||
void setPlacementStartLesson(String lessonId) {
|
void setPlacementStartLesson(String lessonId) {
|
||||||
if (!allLessons.any((lesson) => lesson.id == lessonId)) return;
|
if (!allLessons.any(
|
||||||
|
(lesson) =>
|
||||||
|
lesson.id == lessonId && lesson.status == ContentStatus.approved,
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
placementStartLessonId = lessonId;
|
placementStartLessonId = lessonId;
|
||||||
activeLessonId = lessonId;
|
activeLessonId = lessonId;
|
||||||
_resetLessonFlow();
|
_resetLessonFlow();
|
||||||
@@ -312,12 +324,19 @@ class AppState extends _AppStateData
|
|||||||
lessonStep = LessonStep.preview;
|
lessonStep = LessonStep.preview;
|
||||||
previewIndex = 0;
|
previewIndex = 0;
|
||||||
lessonListeningComplete = false;
|
lessonListeningComplete = false;
|
||||||
|
listeningAudioPlayed = false;
|
||||||
|
listeningAudioUnavailable = false;
|
||||||
|
listeningSupport = ListeningSupport.none;
|
||||||
|
listeningAttempts = 0;
|
||||||
|
sentenceBuildUsed = false;
|
||||||
|
sentenceBuildIndex = 0;
|
||||||
lessonSpeakingComplete = false;
|
lessonSpeakingComplete = false;
|
||||||
lessonReadingComplete = false;
|
lessonReadingComplete = false;
|
||||||
lessonWritingComplete = false;
|
lessonWritingComplete = false;
|
||||||
lessonDialogueComplete = false;
|
lessonDialogueComplete = false;
|
||||||
independentAttemptComplete = false;
|
independentAttemptComplete = false;
|
||||||
independentAttemptAssisted = false;
|
independentAttemptAssisted = false;
|
||||||
|
independentAttemptPending = false;
|
||||||
independentAttemptSpoken = false;
|
independentAttemptSpoken = false;
|
||||||
mastery.clear();
|
mastery.clear();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ mixin _LessonProgress
|
|||||||
final lessons = allLessons;
|
final lessons = allLessons;
|
||||||
final index = lessons.indexWhere((lesson) => lesson.id == id);
|
final index = lessons.indexWhere((lesson) => lesson.id == id);
|
||||||
if (index < 0) return false;
|
if (index < 0) return false;
|
||||||
|
if (lessons[index].status != ContentStatus.approved) return false;
|
||||||
// The placement start and everything before it are open, so a learner
|
// The placement start and everything before it are open, so a learner
|
||||||
// placed further ahead can still go back to easier lessons.
|
// placed further ahead can still go back to easier lessons.
|
||||||
if (index == 0 || index <= _lessonIndex(placementStartLessonId)) {
|
if (index == 0 || index <= _lessonIndex(placementStartLessonId)) {
|
||||||
@@ -110,8 +111,9 @@ mixin _LessonProgress
|
|||||||
bool get a0LessonsComplete =>
|
bool get a0LessonsComplete =>
|
||||||
a0SeedLessons.every((lesson) => completedLessonIds.contains(lesson.id));
|
a0SeedLessons.every((lesson) => completedLessonIds.contains(lesson.id));
|
||||||
|
|
||||||
bool get allLessonsComplete =>
|
bool get allLessonsComplete => allLessons
|
||||||
allLessons.every((lesson) => completedLessonIds.contains(lesson.id));
|
.where((lesson) => lesson.status == ContentStatus.approved)
|
||||||
|
.every((lesson) => completedLessonIds.contains(lesson.id));
|
||||||
|
|
||||||
void openLesson(String id) {
|
void openLesson(String id) {
|
||||||
if (!isLessonUnlocked(id)) return;
|
if (!isLessonUnlocked(id)) return;
|
||||||
@@ -134,35 +136,89 @@ mixin _LessonProgress
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void recordListeningPlayback({bool slow = false}) {
|
||||||
|
listeningAudioPlayed = true;
|
||||||
|
listeningAudioUnavailable = false;
|
||||||
|
if (slow && listeningSupport == ListeningSupport.none) {
|
||||||
|
listeningSupport = ListeningSupport.slow;
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void recordListeningAudioUnavailable() {
|
||||||
|
listeningAudioUnavailable = true;
|
||||||
|
listeningSupport = ListeningSupport.transcript;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void revealListeningTranscript() {
|
||||||
|
listeningSupport = ListeningSupport.transcript;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void recordListeningMiss() {
|
||||||
|
listeningAttempts++;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void recordSentenceBuildProgress(int index) {
|
||||||
|
sentenceBuildUsed = true;
|
||||||
|
sentenceBuildIndex = index;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
/// [recognized] is true when the learner picked the right meaning on the
|
/// [recognized] is true when the learner picked the right meaning on the
|
||||||
/// first try, which is recognition evidence for the segment's targets.
|
/// first try, which is recognition evidence for explicitly linked items.
|
||||||
void completeListening({bool recognized = false}) {
|
void completeListening({bool recognized = false}) {
|
||||||
_introduceLessonTargets();
|
_introduceLessonTargets();
|
||||||
if (recognized && !lessonListeningComplete) {
|
final independent =
|
||||||
_recordLessonTaskEvidence(
|
recognized &&
|
||||||
targetIds: _activeTargetItemIds,
|
listeningAudioPlayed &&
|
||||||
|
listeningSupport == ListeningSupport.none &&
|
||||||
|
listeningAttempts == 0;
|
||||||
|
if (!lessonListeningComplete) {
|
||||||
|
final targetIds = activityBySegmentId(_activeSegmentId).listeningItemIds;
|
||||||
|
_recordActivityEvidence(
|
||||||
|
targetIds: targetIds,
|
||||||
taskSuffix: 'listening-check',
|
taskSuffix: 'listening-check',
|
||||||
skill: '听辨识别',
|
skill: targetIds.isEmpty ? '听力理解' : '听辨识别',
|
||||||
outcome: EvidenceKind.independentSuccess,
|
outcome: independent
|
||||||
|
? EvidenceKind.independentSuccess
|
||||||
|
: EvidenceKind.assisted,
|
||||||
|
assisted: !independent,
|
||||||
|
listeningSupport: listeningSupport,
|
||||||
|
firstTry: listeningAttempts == 0,
|
||||||
|
attemptCount: listeningAttempts + 1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
lessonListeningComplete = true;
|
lessonListeningComplete = true;
|
||||||
|
listeningAudioPlayed = false;
|
||||||
|
listeningAudioUnavailable = false;
|
||||||
|
listeningSupport = ListeningSupport.none;
|
||||||
|
listeningAttempts = 0;
|
||||||
lessonStep = LessonStep.speaking;
|
lessonStep = LessonStep.speaking;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Follow-reading repeats a model sentence, so it is always assisted
|
/// Follow-reading repeats a model sentence, so it is always assisted
|
||||||
/// practice and never independent speaking evidence.
|
/// practice and never independent speaking evidence.
|
||||||
void completeSpeaking() {
|
void completeSpeaking({bool practiced = false}) {
|
||||||
|
if (lessonSpeakingComplete) return;
|
||||||
lessonSpeakingComplete = true;
|
lessonSpeakingComplete = true;
|
||||||
lessonStep = LessonStep.reading;
|
lessonStep = LessonStep.reading;
|
||||||
_recordLessonTaskEvidence(
|
final targetIds = activityBySegmentId(_activeSegmentId).speakingItemIds;
|
||||||
targetIds: _activeTargetItemIds,
|
if (practiced) {
|
||||||
|
_recordActivityEvidence(
|
||||||
|
targetIds: targetIds,
|
||||||
taskSuffix: 'speaking',
|
taskSuffix: 'speaking',
|
||||||
skill: '口语表达',
|
skill: targetIds.isEmpty ? '句子跟读' : '口语表达',
|
||||||
outcome: EvidenceKind.assisted,
|
outcome: EvidenceKind.assisted,
|
||||||
assisted: true,
|
assisted: true,
|
||||||
|
usedSentenceBuild: sentenceBuildUsed,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
sentenceBuildUsed = false;
|
||||||
|
sentenceBuildIndex = 0;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,7 +263,11 @@ mixin _LessonProgress
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void completeWriting({bool assisted = false, String? rawAnswer}) {
|
void completeWriting({
|
||||||
|
bool assisted = false,
|
||||||
|
bool verified = true,
|
||||||
|
String? rawAnswer,
|
||||||
|
}) {
|
||||||
lessonWritingComplete = true;
|
lessonWritingComplete = true;
|
||||||
lessonWritingDraft = '';
|
lessonWritingDraft = '';
|
||||||
lessonStep = LessonStep.dialogue;
|
lessonStep = LessonStep.dialogue;
|
||||||
@@ -215,7 +275,9 @@ mixin _LessonProgress
|
|||||||
targetIds: _answerTargets(assisted: assisted, rawAnswer: rawAnswer),
|
targetIds: _answerTargets(assisted: assisted, rawAnswer: rawAnswer),
|
||||||
taskSuffix: 'writing',
|
taskSuffix: 'writing',
|
||||||
skill: '写作表达',
|
skill: '写作表达',
|
||||||
outcome: assisted
|
outcome: !verified
|
||||||
|
? EvidenceKind.pending
|
||||||
|
: assisted
|
||||||
? EvidenceKind.assisted
|
? EvidenceKind.assisted
|
||||||
: EvidenceKind.independentSuccess,
|
: EvidenceKind.independentSuccess,
|
||||||
rawAnswer: rawAnswer,
|
rawAnswer: rawAnswer,
|
||||||
@@ -239,6 +301,7 @@ mixin _LessonProgress
|
|||||||
|
|
||||||
void completeIndependentAttempt({
|
void completeIndependentAttempt({
|
||||||
required bool assisted,
|
required bool assisted,
|
||||||
|
bool verified = true,
|
||||||
bool spoken = false,
|
bool spoken = false,
|
||||||
String? rawAnswer,
|
String? rawAnswer,
|
||||||
String? recordingPath,
|
String? recordingPath,
|
||||||
@@ -246,14 +309,17 @@ mixin _LessonProgress
|
|||||||
independentAttemptComplete = true;
|
independentAttemptComplete = true;
|
||||||
independentAttemptDraft = '';
|
independentAttemptDraft = '';
|
||||||
independentAttemptAssisted = assisted;
|
independentAttemptAssisted = assisted;
|
||||||
independentAttemptSpoken = spoken && !assisted;
|
independentAttemptPending = !verified;
|
||||||
|
independentAttemptSpoken = spoken && !assisted && verified;
|
||||||
lessonStep = LessonStep.complete;
|
lessonStep = LessonStep.complete;
|
||||||
_recordLessonTaskEvidence(
|
_recordLessonTaskEvidence(
|
||||||
targetIds: _answerTargets(assisted: assisted, rawAnswer: rawAnswer),
|
targetIds: _answerTargets(assisted: assisted, rawAnswer: rawAnswer),
|
||||||
taskSuffix: 'independent',
|
taskSuffix: 'independent',
|
||||||
skill: spoken && !assisted ? '口语表达' : '写作表达',
|
skill: spoken && !assisted ? '口语表达' : '写作表达',
|
||||||
inputMode: spoken && !assisted ? 'speech-unedited-transcript' : 'text',
|
inputMode: spoken && !assisted ? 'speech-unedited-transcript' : 'text',
|
||||||
outcome: assisted
|
outcome: !verified
|
||||||
|
? EvidenceKind.pending
|
||||||
|
: assisted
|
||||||
? EvidenceKind.assisted
|
? EvidenceKind.assisted
|
||||||
: EvidenceKind.independentSuccess,
|
: EvidenceKind.independentSuccess,
|
||||||
rawAnswer: rawAnswer,
|
rawAnswer: rawAnswer,
|
||||||
@@ -361,7 +427,9 @@ mixin _LessonProgress
|
|||||||
String? get nextIncompleteLessonId => _nextIncompleteLessonId();
|
String? get nextIncompleteLessonId => _nextIncompleteLessonId();
|
||||||
|
|
||||||
String? _nextIncompleteLessonId() {
|
String? _nextIncompleteLessonId() {
|
||||||
final lessons = allLessons;
|
final lessons = allLessons
|
||||||
|
.where((lesson) => lesson.status == ContentStatus.approved)
|
||||||
|
.toList();
|
||||||
final start = _lessonIndex(placementStartLessonId).clamp(0, lessons.length);
|
final start = _lessonIndex(placementStartLessonId).clamp(0, lessons.length);
|
||||||
final ordered = [...lessons.skip(start), ...lessons.take(start)];
|
final ordered = [...lessons.skip(start), ...lessons.take(start)];
|
||||||
return ordered
|
return ordered
|
||||||
@@ -374,12 +442,19 @@ mixin _LessonProgress
|
|||||||
lessonStep = LessonStep.preview;
|
lessonStep = LessonStep.preview;
|
||||||
previewIndex = 0;
|
previewIndex = 0;
|
||||||
lessonListeningComplete = false;
|
lessonListeningComplete = false;
|
||||||
|
listeningAudioPlayed = false;
|
||||||
|
listeningAudioUnavailable = false;
|
||||||
|
listeningSupport = ListeningSupport.none;
|
||||||
|
listeningAttempts = 0;
|
||||||
|
sentenceBuildUsed = false;
|
||||||
|
sentenceBuildIndex = 0;
|
||||||
lessonSpeakingComplete = false;
|
lessonSpeakingComplete = false;
|
||||||
lessonReadingComplete = false;
|
lessonReadingComplete = false;
|
||||||
lessonWritingComplete = false;
|
lessonWritingComplete = false;
|
||||||
lessonDialogueComplete = false;
|
lessonDialogueComplete = false;
|
||||||
independentAttemptComplete = false;
|
independentAttemptComplete = false;
|
||||||
independentAttemptAssisted = false;
|
independentAttemptAssisted = false;
|
||||||
|
independentAttemptPending = false;
|
||||||
independentAttemptSpoken = false;
|
independentAttemptSpoken = false;
|
||||||
lessonWritingDraft = '';
|
lessonWritingDraft = '';
|
||||||
independentAttemptDraft = '';
|
independentAttemptDraft = '';
|
||||||
@@ -513,6 +588,10 @@ mixin _LessonProgress
|
|||||||
String inputMode = 'text',
|
String inputMode = 'text',
|
||||||
String? recordingPath,
|
String? recordingPath,
|
||||||
bool assisted = false,
|
bool assisted = false,
|
||||||
|
ListeningSupport? listeningSupport,
|
||||||
|
bool? firstTry,
|
||||||
|
int? attemptCount,
|
||||||
|
bool usedSentenceBuild = false,
|
||||||
}) {
|
}) {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
for (var index = 0; index < targetIds.length; index++) {
|
for (var index = 0; index < targetIds.length; index++) {
|
||||||
@@ -529,6 +608,10 @@ mixin _LessonProgress
|
|||||||
rawAnswer: rawAnswer,
|
rawAnswer: rawAnswer,
|
||||||
recordingPath: recordingPath,
|
recordingPath: recordingPath,
|
||||||
assisted: assisted,
|
assisted: assisted,
|
||||||
|
listeningSupport: listeningSupport,
|
||||||
|
firstTry: firstTry,
|
||||||
|
attemptCount: attemptCount,
|
||||||
|
usedSentenceBuild: usedSentenceBuild,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// The row goes in first: status is derived from the attempt history.
|
// The row goes in first: status is derived from the attempt history.
|
||||||
@@ -536,6 +619,52 @@ mixin _LessonProgress
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A comprehension question or model sentence without an exact core-item
|
||||||
|
/// link is still visible in learning records, but cannot change mastery.
|
||||||
|
void _recordActivityEvidence({
|
||||||
|
required List<String> targetIds,
|
||||||
|
required String taskSuffix,
|
||||||
|
required String skill,
|
||||||
|
required EvidenceKind outcome,
|
||||||
|
bool assisted = false,
|
||||||
|
ListeningSupport? listeningSupport,
|
||||||
|
bool? firstTry,
|
||||||
|
int? attemptCount,
|
||||||
|
bool usedSentenceBuild = false,
|
||||||
|
}) {
|
||||||
|
if (targetIds.isNotEmpty) {
|
||||||
|
_recordLessonTaskEvidence(
|
||||||
|
targetIds: targetIds,
|
||||||
|
taskSuffix: taskSuffix,
|
||||||
|
skill: skill,
|
||||||
|
outcome: outcome,
|
||||||
|
assisted: assisted,
|
||||||
|
listeningSupport: listeningSupport,
|
||||||
|
firstTry: firstTry,
|
||||||
|
attemptCount: attemptCount,
|
||||||
|
usedSentenceBuild: usedSentenceBuild,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final now = DateTime.now();
|
||||||
|
attemptEvidence.add(
|
||||||
|
AttemptEvidence(
|
||||||
|
id: 'lesson-$_activeSegmentId-$taskSuffix-${now.microsecondsSinceEpoch}',
|
||||||
|
itemId: 'segment:$_activeSegmentId',
|
||||||
|
taskId: 'lesson-$_activeSegmentId-$taskSuffix',
|
||||||
|
skill: skill,
|
||||||
|
inputMode: 'text',
|
||||||
|
outcome: outcome,
|
||||||
|
createdAt: now,
|
||||||
|
assisted: assisted,
|
||||||
|
listeningSupport: listeningSupport,
|
||||||
|
firstTry: firstTry,
|
||||||
|
attemptCount: attemptCount,
|
||||||
|
usedSentenceBuild: usedSentenceBuild,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _introduceLessonTargets() {
|
void _introduceLessonTargets() {
|
||||||
_recordLessonTaskEvidence(
|
_recordLessonTaskEvidence(
|
||||||
targetIds: _activeTargetItemIds,
|
targetIds: _activeTargetItemIds,
|
||||||
|
|||||||
@@ -139,11 +139,19 @@ mixin _ReviewAndMastery on _AppStateData {
|
|||||||
void completeReview(
|
void completeReview(
|
||||||
ReviewItem item, {
|
ReviewItem item, {
|
||||||
required bool assisted,
|
required bool assisted,
|
||||||
|
bool? firstTry,
|
||||||
|
int? attemptCount,
|
||||||
|
ListeningSupport? listeningSupport,
|
||||||
String? rawAnswer,
|
String? rawAnswer,
|
||||||
String inputMode = 'text',
|
String inputMode = 'text',
|
||||||
String? originalTranscript,
|
String? originalTranscript,
|
||||||
String? recordingPath,
|
String? recordingPath,
|
||||||
}) {
|
}) {
|
||||||
|
assisted =
|
||||||
|
assisted ||
|
||||||
|
(listeningSupport != null &&
|
||||||
|
listeningSupport != ListeningSupport.none) ||
|
||||||
|
(attemptCount != null && attemptCount > 1);
|
||||||
final index = _queueIndexOf(item);
|
final index = _queueIndexOf(item);
|
||||||
if (index < 0) return;
|
if (index < 0) return;
|
||||||
final current = reviewQueue[index];
|
final current = reviewQueue[index];
|
||||||
@@ -175,7 +183,8 @@ mixin _ReviewAndMastery on _AppStateData {
|
|||||||
: current.successfulReviews;
|
: current.successfulReviews;
|
||||||
// Checkpoints fall on days 1, 3, 7 and 14 after first teaching: the first
|
// Checkpoints fall on days 1, 3, 7 and 14 after first teaching: the first
|
||||||
// review is due one day after teaching, then 2, 4 and 7 days apart, and
|
// review is due one day after teaching, then 2, 4 and 7 days apart, and
|
||||||
// every 30 days once all four checkpoints are passed.
|
// the first mastered audit is 30 days later; later clean, first-try
|
||||||
|
// audits can stretch to 45 and then 60 days.
|
||||||
const intervals = [2, 4, 7];
|
const intervals = [2, 4, 7];
|
||||||
final intervalIndex = (nextSuccesses - 1)
|
final intervalIndex = (nextSuccesses - 1)
|
||||||
.clamp(0, intervals.length - 1)
|
.clamp(0, intervals.length - 1)
|
||||||
@@ -187,7 +196,9 @@ mixin _ReviewAndMastery on _AppStateData {
|
|||||||
masteryItem?.status == MasteryStatus.master
|
masteryItem?.status == MasteryStatus.master
|
||||||
? 7
|
? 7
|
||||||
: nextSuccesses >= 4
|
: nextSuccesses >= 4
|
||||||
? 30
|
? (firstTry == true && nextSuccesses >= 5
|
||||||
|
? (nextSuccesses >= 6 ? 60 : 45)
|
||||||
|
: 30)
|
||||||
: intervals[intervalIndex];
|
: intervals[intervalIndex];
|
||||||
// The next check of a core item uses a different reviewed situation, so
|
// The next check of a core item uses a different reviewed situation, so
|
||||||
// later successes also show the item works outside the lesson's context.
|
// later successes also show the item works outside the lesson's context.
|
||||||
@@ -211,6 +222,9 @@ mixin _ReviewAndMastery on _AppStateData {
|
|||||||
? EvidenceKind.assisted
|
? EvidenceKind.assisted
|
||||||
: EvidenceKind.independentSuccess,
|
: EvidenceKind.independentSuccess,
|
||||||
assisted: assisted,
|
assisted: assisted,
|
||||||
|
firstTry: firstTry,
|
||||||
|
attemptCount: attemptCount,
|
||||||
|
listeningSupport: listeningSupport,
|
||||||
rawAnswer: rawAnswer,
|
rawAnswer: rawAnswer,
|
||||||
inputMode: inputMode,
|
inputMode: inputMode,
|
||||||
originalTranscript: originalTranscript,
|
originalTranscript: originalTranscript,
|
||||||
@@ -273,7 +287,8 @@ mixin _ReviewAndMastery on _AppStateData {
|
|||||||
/// next real check still happens tomorrow on a different task.
|
/// next real check still happens tomorrow on a different task.
|
||||||
void _scheduleSessionRetry(ReviewItem item) {
|
void _scheduleSessionRetry(ReviewItem item) {
|
||||||
if (reviewQueue.any(
|
if (reviewQueue.any(
|
||||||
(candidate) => candidate.id == item.id && candidate.kind == ReviewKind.recap,
|
(candidate) =>
|
||||||
|
candidate.id == item.id && candidate.kind == ReviewKind.recap,
|
||||||
)) {
|
)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -425,6 +440,9 @@ mixin _ReviewAndMastery on _AppStateData {
|
|||||||
String inputMode = 'text',
|
String inputMode = 'text',
|
||||||
String? originalTranscript,
|
String? originalTranscript,
|
||||||
String? recordingPath,
|
String? recordingPath,
|
||||||
|
bool? firstTry,
|
||||||
|
int? attemptCount,
|
||||||
|
ListeningSupport? listeningSupport,
|
||||||
}) {
|
}) {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final spoken = originalTranscript != null;
|
final spoken = originalTranscript != null;
|
||||||
@@ -444,6 +462,9 @@ mixin _ReviewAndMastery on _AppStateData {
|
|||||||
originalTranscript: originalTranscript,
|
originalTranscript: originalTranscript,
|
||||||
transcriptConfirmed: spoken && originalTranscript == rawAnswer,
|
transcriptConfirmed: spoken && originalTranscript == rawAnswer,
|
||||||
transcriptEdited: spoken && originalTranscript != rawAnswer,
|
transcriptEdited: spoken && originalTranscript != rawAnswer,
|
||||||
|
firstTry: firstTry,
|
||||||
|
attemptCount: attemptCount,
|
||||||
|
listeningSupport: listeningSupport,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -523,8 +544,7 @@ mixin _ReviewAndMastery on _AppStateData {
|
|||||||
void applyGeneratedReviewVariant(GeneratedReviewVariant variant) {
|
void applyGeneratedReviewVariant(GeneratedReviewVariant variant) {
|
||||||
final index = reviewQueue.indexWhere(
|
final index = reviewQueue.indexWhere(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.id == variant.targetItemId &&
|
item.id == variant.targetItemId && item.kind == ReviewKind.checkpoint,
|
||||||
item.kind == ReviewKind.checkpoint,
|
|
||||||
);
|
);
|
||||||
if (index < 0) return;
|
if (index < 0) return;
|
||||||
final current = reviewQueue[index];
|
final current = reviewQueue[index];
|
||||||
@@ -620,8 +640,7 @@ mixin _ReviewAndMastery on _AppStateData {
|
|||||||
if (!isReview) continue;
|
if (!isReview) continue;
|
||||||
// The recognition gate and the contrast question are 「认识」 evidence
|
// The recognition gate and the contrast question are 「认识」 evidence
|
||||||
// only; producing the item is what earns a checkpoint.
|
// only; producing the item is what earns a checkpoint.
|
||||||
if (event.skill == recognitionGateSkill ||
|
if (event.skill == recognitionGateSkill || event.skill == contrastSkill) {
|
||||||
event.skill == contrastSkill) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (event.outcome == EvidenceKind.independentSuccess) {
|
if (event.outcome == EvidenceKind.independentSuccess) {
|
||||||
|
|||||||
@@ -142,6 +142,17 @@ extension _AppStateSnapshot on AppState {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
lessonListeningComplete = data['lessonListeningComplete'] as bool? ?? false;
|
lessonListeningComplete = data['lessonListeningComplete'] as bool? ?? false;
|
||||||
|
listeningAudioPlayed = data['listeningAudioPlayed'] as bool? ?? false;
|
||||||
|
listeningAudioUnavailable =
|
||||||
|
data['listeningAudioUnavailable'] as bool? ?? false;
|
||||||
|
listeningSupport = _enumValue(
|
||||||
|
ListeningSupport.values,
|
||||||
|
data['listeningSupport'] as String?,
|
||||||
|
ListeningSupport.none,
|
||||||
|
);
|
||||||
|
listeningAttempts = data['listeningAttempts'] as int? ?? 0;
|
||||||
|
sentenceBuildUsed = data['sentenceBuildUsed'] as bool? ?? false;
|
||||||
|
sentenceBuildIndex = data['sentenceBuildIndex'] as int? ?? 0;
|
||||||
lessonSpeakingComplete = data['lessonSpeakingComplete'] as bool? ?? false;
|
lessonSpeakingComplete = data['lessonSpeakingComplete'] as bool? ?? false;
|
||||||
lessonReadingComplete = data['lessonReadingComplete'] as bool? ?? false;
|
lessonReadingComplete = data['lessonReadingComplete'] as bool? ?? false;
|
||||||
lessonWritingComplete = data['lessonWritingComplete'] as bool? ?? false;
|
lessonWritingComplete = data['lessonWritingComplete'] as bool? ?? false;
|
||||||
@@ -150,6 +161,8 @@ extension _AppStateSnapshot on AppState {
|
|||||||
data['independentAttemptComplete'] as bool? ?? false;
|
data['independentAttemptComplete'] as bool? ?? false;
|
||||||
independentAttemptAssisted =
|
independentAttemptAssisted =
|
||||||
data['independentAttemptAssisted'] as bool? ?? false;
|
data['independentAttemptAssisted'] as bool? ?? false;
|
||||||
|
independentAttemptPending =
|
||||||
|
data['independentAttemptPending'] as bool? ?? false;
|
||||||
independentAttemptSpoken =
|
independentAttemptSpoken =
|
||||||
data['independentAttemptSpoken'] as bool? ?? false;
|
data['independentAttemptSpoken'] as bool? ?? false;
|
||||||
lessonWritingDraft = data['lessonWritingDraft'] as String? ?? '';
|
lessonWritingDraft = data['lessonWritingDraft'] as String? ?? '';
|
||||||
@@ -190,6 +203,16 @@ extension _AppStateSnapshot on AppState {
|
|||||||
transcriptConfirmed:
|
transcriptConfirmed:
|
||||||
item['transcriptConfirmed'] as bool? ?? false,
|
item['transcriptConfirmed'] as bool? ?? false,
|
||||||
transcriptEdited: item['transcriptEdited'] as bool? ?? false,
|
transcriptEdited: item['transcriptEdited'] as bool? ?? false,
|
||||||
|
listeningSupport: item['listeningSupport'] == null
|
||||||
|
? null
|
||||||
|
: _enumValue(
|
||||||
|
ListeningSupport.values,
|
||||||
|
item['listeningSupport'] as String?,
|
||||||
|
ListeningSupport.none,
|
||||||
|
),
|
||||||
|
firstTry: item['firstTry'] as bool?,
|
||||||
|
attemptCount: item['attemptCount'] as int?,
|
||||||
|
usedSentenceBuild: item['usedSentenceBuild'] as bool? ?? false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -234,7 +257,9 @@ extension _AppStateSnapshot on AppState {
|
|||||||
misses: item['misses'] as int? ?? 0,
|
misses: item['misses'] as int? ?? 0,
|
||||||
// Snapshots written before the spacing ladder carry no step;
|
// Snapshots written before the spacing ladder carry no step;
|
||||||
// place them on the rung their status implies.
|
// place them on the rung their status implies.
|
||||||
step: item['step'] as int? ?? switch (_enumValue(
|
step:
|
||||||
|
item['step'] as int? ??
|
||||||
|
switch (_enumValue(
|
||||||
WordStatus.values,
|
WordStatus.values,
|
||||||
item['status'] as String?,
|
item['status'] as String?,
|
||||||
WordStatus.newWord,
|
WordStatus.newWord,
|
||||||
@@ -396,12 +421,19 @@ extension _AppStateSnapshot on AppState {
|
|||||||
'reportedAiVariantKeys': reportedAiVariantKeys.toList(),
|
'reportedAiVariantKeys': reportedAiVariantKeys.toList(),
|
||||||
'activeSegmentIndexes': activeSegmentIndexes,
|
'activeSegmentIndexes': activeSegmentIndexes,
|
||||||
'lessonListeningComplete': lessonListeningComplete,
|
'lessonListeningComplete': lessonListeningComplete,
|
||||||
|
'listeningAudioPlayed': listeningAudioPlayed,
|
||||||
|
'listeningAudioUnavailable': listeningAudioUnavailable,
|
||||||
|
'listeningSupport': listeningSupport.name,
|
||||||
|
'listeningAttempts': listeningAttempts,
|
||||||
|
'sentenceBuildUsed': sentenceBuildUsed,
|
||||||
|
'sentenceBuildIndex': sentenceBuildIndex,
|
||||||
'lessonSpeakingComplete': lessonSpeakingComplete,
|
'lessonSpeakingComplete': lessonSpeakingComplete,
|
||||||
'lessonReadingComplete': lessonReadingComplete,
|
'lessonReadingComplete': lessonReadingComplete,
|
||||||
'lessonWritingComplete': lessonWritingComplete,
|
'lessonWritingComplete': lessonWritingComplete,
|
||||||
'lessonDialogueComplete': lessonDialogueComplete,
|
'lessonDialogueComplete': lessonDialogueComplete,
|
||||||
'independentAttemptComplete': independentAttemptComplete,
|
'independentAttemptComplete': independentAttemptComplete,
|
||||||
'independentAttemptAssisted': independentAttemptAssisted,
|
'independentAttemptAssisted': independentAttemptAssisted,
|
||||||
|
'independentAttemptPending': independentAttemptPending,
|
||||||
'independentAttemptSpoken': independentAttemptSpoken,
|
'independentAttemptSpoken': independentAttemptSpoken,
|
||||||
'lessonWritingDraft': lessonWritingDraft,
|
'lessonWritingDraft': lessonWritingDraft,
|
||||||
'independentAttemptDraft': independentAttemptDraft,
|
'independentAttemptDraft': independentAttemptDraft,
|
||||||
@@ -464,6 +496,10 @@ extension _AppStateSnapshot on AppState {
|
|||||||
'originalTranscript': entry.originalTranscript,
|
'originalTranscript': entry.originalTranscript,
|
||||||
'transcriptConfirmed': entry.transcriptConfirmed,
|
'transcriptConfirmed': entry.transcriptConfirmed,
|
||||||
'transcriptEdited': entry.transcriptEdited,
|
'transcriptEdited': entry.transcriptEdited,
|
||||||
|
'listeningSupport': entry.listeningSupport?.name,
|
||||||
|
'firstTry': entry.firstTry,
|
||||||
|
'attemptCount': entry.attemptCount,
|
||||||
|
'usedSentenceBuild': entry.usedSentenceBuild,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
@@ -549,7 +585,11 @@ ReviewItem _reviewFromJson(Map<String, dynamic> data) => ReviewItem(
|
|||||||
hint: data['hint'] as String,
|
hint: data['hint'] as String,
|
||||||
dueAt: DateTime.tryParse(data['dueAt'] as String? ?? '') ?? DateTime.now(),
|
dueAt: DateTime.tryParse(data['dueAt'] as String? ?? '') ?? DateTime.now(),
|
||||||
skill: data['skill'] as String,
|
skill: data['skill'] as String,
|
||||||
kind: _enumValue(ReviewKind.values, data['kind'] as String?, ReviewKind.checkpoint),
|
kind: _enumValue(
|
||||||
|
ReviewKind.values,
|
||||||
|
data['kind'] as String?,
|
||||||
|
ReviewKind.checkpoint,
|
||||||
|
),
|
||||||
attempts: data['attempts'] as int? ?? 0,
|
attempts: data['attempts'] as int? ?? 0,
|
||||||
successfulReviews: data['successfulReviews'] as int? ?? 0,
|
successfulReviews: data['successfulReviews'] as int? ?? 0,
|
||||||
variantIndex: data['variantIndex'] as int? ?? 0,
|
variantIndex: data['variantIndex'] as int? ?? 0,
|
||||||
|
|||||||
@@ -141,13 +141,17 @@ mixin _ReceptiveWords on _AppStateData {
|
|||||||
return WordStatus.recognized;
|
return WordStatus.recognized;
|
||||||
}
|
}
|
||||||
|
|
||||||
DateTime _tomorrow(DateTime now) =>
|
DateTime _tomorrow(DateTime now) => DateTime(
|
||||||
DateTime(now.year, now.month, now.day).add(const Duration(days: 1, hours: 9));
|
now.year,
|
||||||
|
now.month,
|
||||||
|
now.day,
|
||||||
|
).add(const Duration(days: 1, hours: 9));
|
||||||
|
|
||||||
/// Words whose check has come due, weakest first.
|
/// Words whose check has come due, weakest first.
|
||||||
List<String> get dueWords {
|
List<String> get dueWords {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final due = wordKnowledge.values
|
final due =
|
||||||
|
wordKnowledge.values
|
||||||
.where((word) => word.dueAt != null && !word.dueAt!.isAfter(now))
|
.where((word) => word.dueAt != null && !word.dueAt!.isAfter(now))
|
||||||
.toList()
|
.toList()
|
||||||
..sort((a, b) {
|
..sort((a, b) {
|
||||||
@@ -165,8 +169,11 @@ mixin _ReceptiveWords on _AppStateData {
|
|||||||
|
|
||||||
/// Words that have been missed and are not 「熟悉」 yet.
|
/// Words that have been missed and are not 「熟悉」 yet.
|
||||||
List<String> get shakyWords {
|
List<String> get shakyWords {
|
||||||
final shaky = wordKnowledge.values
|
final shaky =
|
||||||
.where((word) => word.misses > 0 && word.status != WordStatus.familiar)
|
wordKnowledge.values
|
||||||
|
.where(
|
||||||
|
(word) => word.misses > 0 && word.status != WordStatus.familiar,
|
||||||
|
)
|
||||||
.toList()
|
.toList()
|
||||||
..sort((a, b) => b.misses.compareTo(a.misses));
|
..sort((a, b) => b.misses.compareTo(a.misses));
|
||||||
return [for (final word in shaky) word.id];
|
return [for (final word in shaky) word.id];
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ class AssessmentTask {
|
|||||||
skill: AssessmentSkill.values.byName(json['skill'] as String),
|
skill: AssessmentSkill.values.byName(json['skill'] as String),
|
||||||
prompt: json['prompt'] as String? ?? '',
|
prompt: json['prompt'] as String? ?? '',
|
||||||
audio: json['audio'] as String?,
|
audio: json['audio'] as String?,
|
||||||
choices: [for (final choice in json['choices'] as List? ?? const []) '$choice'],
|
choices: [
|
||||||
|
for (final choice in json['choices'] as List? ?? const []) '$choice',
|
||||||
|
],
|
||||||
answerIndex: json['answerIndex'] as int?,
|
answerIndex: json['answerIndex'] as int?,
|
||||||
accept: [
|
accept: [
|
||||||
for (final group in json['accept'] as List? ?? const [])
|
for (final group in json['accept'] as List? ?? const [])
|
||||||
@@ -102,10 +104,7 @@ AssessmentPack? replacementFor(String packId) =>
|
|||||||
/// The pack whose results [packId] counts toward: the original of a
|
/// The pack whose results [packId] counts toward: the original of a
|
||||||
/// replacement pack, otherwise [packId] itself.
|
/// replacement pack, otherwise [packId] itself.
|
||||||
String canonicalAssessmentPackId(String packId) =>
|
String canonicalAssessmentPackId(String packId) =>
|
||||||
assessmentPacks
|
assessmentPacks.where((pack) => pack.id == packId).firstOrNull?.replaces ??
|
||||||
.where((pack) => pack.id == packId)
|
|
||||||
.firstOrNull
|
|
||||||
?.replaces ??
|
|
||||||
packId;
|
packId;
|
||||||
|
|
||||||
/// Local check of an open task with the same rule engine as the lessons: it
|
/// Local check of an open task with the same rule engine as the lessons: it
|
||||||
|
|||||||
@@ -63,13 +63,7 @@ void registerSavedWord(
|
|||||||
required String zh,
|
required String zh,
|
||||||
required String level,
|
required String level,
|
||||||
}) {
|
}) {
|
||||||
receptiveWordRegistry[id] = (
|
receptiveWordRegistry[id] = (level: level, unit: '', en: en, zh: zh, ipa: '');
|
||||||
level: level,
|
|
||||||
unit: '',
|
|
||||||
en: en,
|
|
||||||
zh: zh,
|
|
||||||
ipa: '',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Registers one word of the standalone word bank. Unlike a unit's receptive
|
/// Registers one word of the standalone word bank. Unlike a unit's receptive
|
||||||
@@ -191,9 +185,10 @@ WordQuestion? receptiveQuestion(
|
|||||||
if (wrong.isEmpty) return null;
|
if (wrong.isEmpty) return null;
|
||||||
// A different variant asks the same word against other meanings, which is
|
// A different variant asks the same word against other meanings, which is
|
||||||
// what 「换题、换语境」 means for a word this small.
|
// what 「换题、换语境」 means for a word this small.
|
||||||
final rotated = [...wrong.skip(variant % wrong.length), ...wrong.take(variant % wrong.length)]
|
final rotated = [
|
||||||
.take(2)
|
...wrong.skip(variant % wrong.length),
|
||||||
.toList();
|
...wrong.take(variant % wrong.length),
|
||||||
|
].take(2).toList();
|
||||||
// Asked from the meaning, the options are spellings and the prompt is the
|
// Asked from the meaning, the options are spellings and the prompt is the
|
||||||
// Chinese; asked either other way, it is the other way round.
|
// Chinese; asked either other way, it is the other way round.
|
||||||
final asksSpelling = mode == WordAskMode.recall;
|
final asksSpelling = mode == WordAskMode.recall;
|
||||||
@@ -339,7 +334,6 @@ const spokenRecallSkill = '口头回忆';
|
|||||||
/// review. Passing it is 「认识」 evidence and never advances a checkpoint.
|
/// review. Passing it is 「认识」 evidence and never advances a checkpoint.
|
||||||
const recognitionGateSkill = '听辨识别';
|
const recognitionGateSkill = '听辨识别';
|
||||||
|
|
||||||
|
|
||||||
/// The skill of a contrast question: a situation is described and the learner
|
/// The skill of a contrast question: a situation is described and the learner
|
||||||
/// picks which taught sentence it calls for. It shows the learner knows *when*
|
/// picks which taught sentence it calls for. It shows the learner knows *when*
|
||||||
/// to say something, which is not the same as being able to produce it, so it
|
/// to say something, which is not the same as being able to produce it, so it
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ class LessonSegment {
|
|||||||
this.estimatedMinutes = 12,
|
this.estimatedMinutes = 12,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
final List<String> targetItemIds;
|
final List<String> targetItemIds;
|
||||||
final List<String> previewItemIds;
|
final List<String> previewItemIds;
|
||||||
@@ -86,8 +85,12 @@ class LessonActivity {
|
|||||||
required this.listening,
|
required this.listening,
|
||||||
required this.listeningQuestion,
|
required this.listeningQuestion,
|
||||||
required this.answers,
|
required this.answers,
|
||||||
|
this.listeningItemIds = const [],
|
||||||
required this.speaking,
|
required this.speaking,
|
||||||
this.speakingTip = '',
|
this.speakingTip = '',
|
||||||
|
this.speakingChunks = const [],
|
||||||
|
this.speakingItemIds = const [],
|
||||||
|
this.speakingExamples = const [],
|
||||||
required this.reading,
|
required this.reading,
|
||||||
required this.readingQuestion,
|
required this.readingQuestion,
|
||||||
required this.readingAnswer,
|
required this.readingAnswer,
|
||||||
@@ -100,14 +103,17 @@ class LessonActivity {
|
|||||||
required this.independentHelp,
|
required this.independentHelp,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
final String listening;
|
final String listening;
|
||||||
final String listeningQuestion;
|
final String listeningQuestion;
|
||||||
final List<String> answers;
|
final List<String> answers;
|
||||||
|
final List<String> listeningItemIds;
|
||||||
final String speaking;
|
final String speaking;
|
||||||
|
|
||||||
/// 这句跟读的发音提示;为空时不显示,避免给每一句套用同一条提示。
|
/// 这句跟读的发音提示;为空时不显示,避免给每一句套用同一条提示。
|
||||||
final String speakingTip;
|
final String speakingTip;
|
||||||
|
final List<String> speakingChunks;
|
||||||
|
final List<String> speakingItemIds;
|
||||||
|
final List<String> speakingExamples;
|
||||||
final String reading;
|
final String reading;
|
||||||
final String readingQuestion;
|
final String readingQuestion;
|
||||||
final String readingAnswer;
|
final String readingAnswer;
|
||||||
@@ -135,7 +141,6 @@ class LessonDialogue {
|
|||||||
this.taskLabels = const [],
|
this.taskLabels = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
final String goal;
|
final String goal;
|
||||||
final List<String> prompts;
|
final List<String> prompts;
|
||||||
final List<String> hints;
|
final List<String> hints;
|
||||||
@@ -165,7 +170,6 @@ class DialogueScene {
|
|||||||
this.unlockAfterLessonId,
|
this.unlockAfterLessonId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
final String title;
|
final String title;
|
||||||
final String summary;
|
final String summary;
|
||||||
|
|||||||
@@ -214,8 +214,12 @@ class PackSegment {
|
|||||||
required this.listeningText,
|
required this.listeningText,
|
||||||
required this.listeningQuestion,
|
required this.listeningQuestion,
|
||||||
required this.listeningOptions,
|
required this.listeningOptions,
|
||||||
|
this.listeningItemIds = const [],
|
||||||
required this.speakingText,
|
required this.speakingText,
|
||||||
required this.speakingTip,
|
required this.speakingTip,
|
||||||
|
this.speakingChunks = const [],
|
||||||
|
this.speakingItemIds = const [],
|
||||||
|
this.speakingExamples = const [],
|
||||||
required this.readingText,
|
required this.readingText,
|
||||||
required this.readingQuestion,
|
required this.readingQuestion,
|
||||||
required this.readingOptions,
|
required this.readingOptions,
|
||||||
@@ -241,9 +245,13 @@ class PackSegment {
|
|||||||
final String listeningText;
|
final String listeningText;
|
||||||
final String listeningQuestion;
|
final String listeningQuestion;
|
||||||
final List<String> listeningOptions;
|
final List<String> listeningOptions;
|
||||||
|
final List<String> listeningItemIds;
|
||||||
|
|
||||||
final String speakingText;
|
final String speakingText;
|
||||||
final String speakingTip;
|
final String speakingTip;
|
||||||
|
final List<String> speakingChunks;
|
||||||
|
final List<String> speakingItemIds;
|
||||||
|
final List<String> speakingExamples;
|
||||||
|
|
||||||
final String readingText;
|
final String readingText;
|
||||||
final String readingQuestion;
|
final String readingQuestion;
|
||||||
@@ -288,8 +296,15 @@ class PackSegment {
|
|||||||
listeningText: listening['text'] as String? ?? '',
|
listeningText: listening['text'] as String? ?? '',
|
||||||
listeningQuestion: listening['question'] as String? ?? '',
|
listeningQuestion: listening['question'] as String? ?? '',
|
||||||
listeningOptions: _stringList(listening['options']),
|
listeningOptions: _stringList(listening['options']),
|
||||||
|
listeningItemIds: _stringList(listening['itemIds']),
|
||||||
speakingText: firstSpeaking['text'] as String? ?? '',
|
speakingText: firstSpeaking['text'] as String? ?? '',
|
||||||
speakingTip: firstSpeaking['tip'] as String? ?? '',
|
speakingTip: firstSpeaking['tip'] as String? ?? '',
|
||||||
|
speakingChunks: _stringList(firstSpeaking['chunks']),
|
||||||
|
speakingItemIds: _stringList(firstSpeaking['itemIds']),
|
||||||
|
speakingExamples: [
|
||||||
|
for (final line in speaking.skip(1))
|
||||||
|
if (line['text'] is String) line['text'] as String,
|
||||||
|
],
|
||||||
readingText: reading['text'] as String? ?? '',
|
readingText: reading['text'] as String? ?? '',
|
||||||
readingQuestion: reading['question'] as String? ?? '',
|
readingQuestion: reading['question'] as String? ?? '',
|
||||||
readingOptions: _stringList(reading['options']),
|
readingOptions: _stringList(reading['options']),
|
||||||
|
|||||||
@@ -225,8 +225,12 @@ class CourseRepository {
|
|||||||
answers: seg.listeningOptions.isNotEmpty
|
answers: seg.listeningOptions.isNotEmpty
|
||||||
? seg.listeningOptions
|
? seg.listeningOptions
|
||||||
: const [''],
|
: const [''],
|
||||||
|
listeningItemIds: seg.listeningItemIds,
|
||||||
speaking: seg.speakingText,
|
speaking: seg.speakingText,
|
||||||
speakingTip: seg.speakingTip,
|
speakingTip: seg.speakingTip,
|
||||||
|
speakingChunks: seg.speakingChunks,
|
||||||
|
speakingItemIds: seg.speakingItemIds,
|
||||||
|
speakingExamples: seg.speakingExamples,
|
||||||
reading: seg.readingText,
|
reading: seg.readingText,
|
||||||
readingQuestion: seg.readingQuestion,
|
readingQuestion: seg.readingQuestion,
|
||||||
readingAnswer: seg.readingOptions.isNotEmpty
|
readingAnswer: seg.readingOptions.isNotEmpty
|
||||||
|
|||||||
@@ -84,13 +84,7 @@ DialogueTurnDecision resolveDialogueTurnDecision({
|
|||||||
: DialogueValidationSource.local;
|
: DialogueValidationSource.local;
|
||||||
return DialogueTurnDecision(
|
return DialogueTurnDecision(
|
||||||
canAdvance: true,
|
canAdvance: true,
|
||||||
evidenceOutcome: assisted
|
evidenceOutcome: assisted ? EvidenceKind.assisted : EvidenceKind.pending,
|
||||||
? EvidenceKind.assisted
|
|
||||||
: aiWasAttempted
|
|
||||||
? EvidenceKind.pending
|
|
||||||
: isFreeScene
|
|
||||||
? EvidenceKind.independentSuccess
|
|
||||||
: EvidenceKind.pending,
|
|
||||||
validationSource: source,
|
validationSource: source,
|
||||||
supportLevel: supportLevel,
|
supportLevel: supportLevel,
|
||||||
feedback: ai?.explanation,
|
feedback: ai?.explanation,
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ class LocalSnapshotStore {
|
|||||||
outcome TEXT NOT NULL, created_at TEXT NOT NULL, raw_answer TEXT,
|
outcome TEXT NOT NULL, created_at TEXT NOT NULL, raw_answer TEXT,
|
||||||
recording_path TEXT, assisted INTEGER NOT NULL, variant_index INTEGER NOT NULL,
|
recording_path TEXT, assisted INTEGER NOT NULL, variant_index INTEGER NOT NULL,
|
||||||
original_transcript TEXT, transcript_confirmed INTEGER NOT NULL DEFAULT 0,
|
original_transcript TEXT, transcript_confirmed INTEGER NOT NULL DEFAULT 0,
|
||||||
transcript_edited INTEGER NOT NULL DEFAULT 0
|
transcript_edited INTEGER NOT NULL DEFAULT 0,
|
||||||
|
listening_support TEXT, first_try INTEGER, attempt_count INTEGER,
|
||||||
|
used_sentence_build INTEGER NOT NULL DEFAULT 0
|
||||||
)
|
)
|
||||||
''');
|
''');
|
||||||
await _ensureColumn(
|
await _ensureColumn(
|
||||||
@@ -73,6 +75,19 @@ class LocalSnapshotStore {
|
|||||||
name: 'transcript_edited',
|
name: 'transcript_edited',
|
||||||
definition: 'INTEGER NOT NULL DEFAULT 0',
|
definition: 'INTEGER NOT NULL DEFAULT 0',
|
||||||
);
|
);
|
||||||
|
for (final column in const <String, String>{
|
||||||
|
'listening_support': 'TEXT',
|
||||||
|
'first_try': 'INTEGER',
|
||||||
|
'attempt_count': 'INTEGER',
|
||||||
|
'used_sentence_build': 'INTEGER NOT NULL DEFAULT 0',
|
||||||
|
}.entries) {
|
||||||
|
await _ensureColumn(
|
||||||
|
executor,
|
||||||
|
table: 'attempt_evidence',
|
||||||
|
name: column.key,
|
||||||
|
definition: column.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
await executor.runCustom('''
|
await executor.runCustom('''
|
||||||
CREATE TABLE IF NOT EXISTS mastery_items (
|
CREATE TABLE IF NOT EXISTS mastery_items (
|
||||||
item_id TEXT PRIMARY KEY NOT NULL, label TEXT NOT NULL, status TEXT NOT NULL,
|
item_id TEXT PRIMARY KEY NOT NULL, label TEXT NOT NULL, status TEXT NOT NULL,
|
||||||
@@ -193,7 +208,8 @@ class LocalSnapshotStore {
|
|||||||
(await executor.runSelect(
|
(await executor.runSelect(
|
||||||
'''SELECT attempt_id, item_id, task_id, skill, input_mode, outcome,
|
'''SELECT attempt_id, item_id, task_id, skill, input_mode, outcome,
|
||||||
created_at, raw_answer, recording_path, assisted, variant_index,
|
created_at, raw_answer, recording_path, assisted, variant_index,
|
||||||
original_transcript, transcript_confirmed, transcript_edited
|
original_transcript, transcript_confirmed, transcript_edited,
|
||||||
|
listening_support, first_try, attempt_count, used_sentence_build
|
||||||
FROM attempt_evidence ORDER BY created_at''',
|
FROM attempt_evidence ORDER BY created_at''',
|
||||||
const [],
|
const [],
|
||||||
))
|
))
|
||||||
@@ -213,6 +229,12 @@ class LocalSnapshotStore {
|
|||||||
'originalTranscript': row['original_transcript'],
|
'originalTranscript': row['original_transcript'],
|
||||||
'transcriptConfirmed': row['transcript_confirmed'] == 1,
|
'transcriptConfirmed': row['transcript_confirmed'] == 1,
|
||||||
'transcriptEdited': row['transcript_edited'] == 1,
|
'transcriptEdited': row['transcript_edited'] == 1,
|
||||||
|
'listeningSupport': row['listening_support'],
|
||||||
|
'firstTry': row['first_try'] == null
|
||||||
|
? null
|
||||||
|
: row['first_try'] == 1,
|
||||||
|
'attemptCount': row['attempt_count'],
|
||||||
|
'usedSentenceBuild': row['used_sentence_build'] == 1,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
@@ -333,8 +355,9 @@ class LocalSnapshotStore {
|
|||||||
'''INSERT INTO attempt_evidence(
|
'''INSERT INTO attempt_evidence(
|
||||||
attempt_id, item_id, task_id, skill, input_mode, outcome, created_at,
|
attempt_id, item_id, task_id, skill, input_mode, outcome, created_at,
|
||||||
raw_answer, recording_path, assisted, variant_index,
|
raw_answer, recording_path, assisted, variant_index,
|
||||||
original_transcript, transcript_confirmed, transcript_edited
|
original_transcript, transcript_confirmed, transcript_edited,
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''',
|
listening_support, first_try, attempt_count, used_sentence_build
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''',
|
||||||
[
|
[
|
||||||
entry['id'],
|
entry['id'],
|
||||||
entry['itemId'],
|
entry['itemId'],
|
||||||
@@ -350,6 +373,14 @@ class LocalSnapshotStore {
|
|||||||
entry['originalTranscript'],
|
entry['originalTranscript'],
|
||||||
entry['transcriptConfirmed'] == true ? 1 : 0,
|
entry['transcriptConfirmed'] == true ? 1 : 0,
|
||||||
entry['transcriptEdited'] == true ? 1 : 0,
|
entry['transcriptEdited'] == true ? 1 : 0,
|
||||||
|
entry['listeningSupport'],
|
||||||
|
entry['firstTry'] == null
|
||||||
|
? null
|
||||||
|
: entry['firstTry'] == true
|
||||||
|
? 1
|
||||||
|
: 0,
|
||||||
|
entry['attemptCount'],
|
||||||
|
entry['usedSentenceBuild'] == true ? 1 : 0,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -435,6 +466,12 @@ class LocalSnapshotStore {
|
|||||||
'completedSegmentIds',
|
'completedSegmentIds',
|
||||||
'activeSegmentIndexes',
|
'activeSegmentIndexes',
|
||||||
'lessonListeningComplete',
|
'lessonListeningComplete',
|
||||||
|
'listeningAudioPlayed',
|
||||||
|
'listeningAudioUnavailable',
|
||||||
|
'listeningSupport',
|
||||||
|
'listeningAttempts',
|
||||||
|
'sentenceBuildUsed',
|
||||||
|
'sentenceBuildIndex',
|
||||||
'lessonSpeakingComplete',
|
'lessonSpeakingComplete',
|
||||||
'lessonReadingComplete',
|
'lessonReadingComplete',
|
||||||
'lessonWritingComplete',
|
'lessonWritingComplete',
|
||||||
|
|||||||
@@ -92,6 +92,8 @@ enum EvidenceKind {
|
|||||||
pending,
|
pending,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ListeningSupport { none, slow, transcript }
|
||||||
|
|
||||||
/// Local dialogue matching is intentionally tri-state. A natural English
|
/// Local dialogue matching is intentionally tri-state. A natural English
|
||||||
/// reply that does not match a strict taught-language rule is unknown, not
|
/// reply that does not match a strict taught-language rule is unknown, not
|
||||||
/// automatically wrong.
|
/// automatically wrong.
|
||||||
@@ -160,6 +162,10 @@ class AttemptEvidence {
|
|||||||
this.originalTranscript,
|
this.originalTranscript,
|
||||||
this.transcriptConfirmed = false,
|
this.transcriptConfirmed = false,
|
||||||
this.transcriptEdited = false,
|
this.transcriptEdited = false,
|
||||||
|
this.listeningSupport,
|
||||||
|
this.firstTry,
|
||||||
|
this.attemptCount,
|
||||||
|
this.usedSentenceBuild = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
@@ -182,6 +188,10 @@ class AttemptEvidence {
|
|||||||
final String? originalTranscript;
|
final String? originalTranscript;
|
||||||
final bool transcriptConfirmed;
|
final bool transcriptConfirmed;
|
||||||
final bool transcriptEdited;
|
final bool transcriptEdited;
|
||||||
|
final ListeningSupport? listeningSupport;
|
||||||
|
final bool? firstTry;
|
||||||
|
final int? attemptCount;
|
||||||
|
final bool usedSentenceBuild;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AiProviderType { mock, gemini, openAi, compatible }
|
enum AiProviderType { mock, gemini, openAi, compatible }
|
||||||
@@ -212,8 +222,9 @@ class DialogueAiResponse {
|
|||||||
final String? feedback;
|
final String? feedback;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A bounded suggestion only. It never changes completion, mastery, or a
|
/// A bounded evaluation input. Lesson free-response evidence requires both
|
||||||
/// stage result; those decisions remain on-device and evidence-based.
|
/// local task matching and an accepted verdict; mastery and stage decisions
|
||||||
|
/// still remain on-device and derive from recorded attempts.
|
||||||
class WritingAiFeedback {
|
class WritingAiFeedback {
|
||||||
const WritingAiFeedback({
|
const WritingAiFeedback({
|
||||||
required this.verdict,
|
required this.verdict,
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ class VoiceService {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> speak(String text, {bool slow = false}) async {
|
Future<bool> speak(String text, {bool slow = false}) async {
|
||||||
try {
|
try {
|
||||||
await stopRecordingPlayback();
|
await stopRecordingPlayback();
|
||||||
await _initTts();
|
await _initTts();
|
||||||
@@ -95,8 +95,11 @@ class VoiceService {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
await _tts.setSpeechRate(slow ? 0.35 : 0.48);
|
await _tts.setSpeechRate(slow ? 0.35 : 0.48);
|
||||||
await _tts.speak(text);
|
final result = await _tts.speak(text);
|
||||||
} catch (_) {}
|
return result == 1 || result == true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> stopSpeaking() async {
|
Future<void> stopSpeaking() async {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import '../../core/app_state.dart';
|
import '../../core/app_state.dart';
|
||||||
import '../../core/app_theme.dart';
|
import '../../core/app_theme.dart';
|
||||||
import '../../core/courses/courses.dart';
|
import '../../core/courses/courses.dart';
|
||||||
|
import '../../core/models.dart';
|
||||||
import '../../widgets/app_widgets.dart';
|
import '../../widgets/app_widgets.dart';
|
||||||
|
|
||||||
/// 课程路径:全部课程按顺序排列,显示完成、当前与锁定状态。
|
/// 课程路径:全部课程按顺序排列,显示完成、当前与锁定状态。
|
||||||
@@ -17,7 +18,10 @@ class LessonPath extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final done = allLessons
|
final available = allLessons
|
||||||
|
.where((lesson) => lesson.status == ContentStatus.approved)
|
||||||
|
.toList();
|
||||||
|
final done = available
|
||||||
.where((lesson) => state.completedLessonIds.contains(lesson.id))
|
.where((lesson) => state.completedLessonIds.contains(lesson.id))
|
||||||
.length;
|
.length;
|
||||||
return SpacedColumn(
|
return SpacedColumn(
|
||||||
@@ -32,7 +36,7 @@ class LessonPath extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'$done/${allLessons.length} 完成',
|
'$done/${available.length} 已开放课程完成',
|
||||||
style: TextStyle(fontSize: 13, color: AppColors.muted),
|
style: TextStyle(fontSize: 13, color: AppColors.muted),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -66,7 +70,8 @@ class _LessonTile extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final completed = state.completedLessonIds.contains(lesson.id);
|
final completed = state.completedLessonIds.contains(lesson.id);
|
||||||
final current = lesson.id == state.activeLessonId && !completed;
|
final draft = lesson.status != ContentStatus.approved;
|
||||||
|
final current = lesson.id == state.activeLessonId && !completed && !draft;
|
||||||
final open =
|
final open =
|
||||||
state.isLessonUnlocked(lesson.id) &&
|
state.isLessonUnlocked(lesson.id) &&
|
||||||
(!state.reviewBacklog || completed);
|
(!state.reviewBacklog || completed);
|
||||||
@@ -98,7 +103,9 @@ class _LessonTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 3),
|
const SizedBox(height: 3),
|
||||||
Text(
|
Text(
|
||||||
segments > 1
|
draft
|
||||||
|
? '待教学审校后开放'
|
||||||
|
: segments > 1
|
||||||
? '${lesson.outcome} · 小段 $segmentsDone/$segments'
|
? '${lesson.outcome} · 小段 $segmentsDone/$segments'
|
||||||
: lesson.outcome,
|
: lesson.outcome,
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import '../../core/app_state.dart';
|
import '../../core/app_state.dart';
|
||||||
import '../../core/app_theme.dart';
|
import '../../core/app_theme.dart';
|
||||||
import '../../core/courses/courses.dart';
|
import '../../core/courses/courses.dart';
|
||||||
|
import '../../core/models.dart';
|
||||||
import '../../widgets/app_widgets.dart';
|
import '../../widgets/app_widgets.dart';
|
||||||
|
|
||||||
/// 今天最该做的一件事:继续课程对话 → 到期复习 → 阶段评估/巩固 → 下一课。
|
/// 今天最该做的一件事:继续课程对话 → 到期复习 → 阶段评估/巩固 → 下一课。
|
||||||
@@ -50,7 +51,8 @@ class TodayTaskCard extends StatelessWidget {
|
|||||||
|
|
||||||
_TodayTask _todayTask() {
|
_TodayTask _todayTask() {
|
||||||
var lessonId = state.activeLessonId;
|
var lessonId = state.activeLessonId;
|
||||||
if (state.completedLessonIds.contains(lessonId)) {
|
if (state.completedLessonIds.contains(lessonId) ||
|
||||||
|
lessonById(lessonId).status != ContentStatus.approved) {
|
||||||
final next = state.nextIncompleteLessonId;
|
final next = state.nextIncompleteLessonId;
|
||||||
if (next != null) {
|
if (next != null) {
|
||||||
lessonId = next;
|
lessonId = next;
|
||||||
@@ -101,8 +103,8 @@ class TodayTaskCard extends StatelessWidget {
|
|||||||
if (state.allLessonsComplete) {
|
if (state.allLessonsComplete) {
|
||||||
return _TodayTask(
|
return _TodayTask(
|
||||||
label: '已学完',
|
label: '已学完',
|
||||||
title: '所有课程已完成',
|
title: '当前开放课程已完成',
|
||||||
note: '你已学完全部课程内容!建议保持日常到期复习或自由情境对话。',
|
note: '后续课程待教学审校开放。现在可以继续到期复习或自由情境对话。',
|
||||||
action: '去复习',
|
action: '去复习',
|
||||||
minutes: '10 分钟',
|
minutes: '10 分钟',
|
||||||
onPressed: onStartReview,
|
onPressed: onStartReview,
|
||||||
|
|||||||
@@ -44,11 +44,7 @@ class _LessonFlowState extends State<LessonFlow> {
|
|||||||
final independentController = TextEditingController();
|
final independentController = TextEditingController();
|
||||||
int selectedAnswer = -1;
|
int selectedAnswer = -1;
|
||||||
|
|
||||||
/// Set once a wrong meaning is picked, so only a first-try choice counts as
|
|
||||||
/// recognition evidence.
|
|
||||||
bool listeningMissed = false;
|
|
||||||
int previewIndex = 0;
|
int previewIndex = 0;
|
||||||
bool listeningAudioPlayed = false;
|
|
||||||
bool showWritingHelp = true;
|
bool showWritingHelp = true;
|
||||||
bool showIndependentHelp = false;
|
bool showIndependentHelp = false;
|
||||||
LessonSegment get segment {
|
LessonSegment get segment {
|
||||||
@@ -56,11 +52,9 @@ class _LessonFlowState extends State<LessonFlow> {
|
|||||||
return lesson.segments[widget.state.activeSegmentIndexFor(lesson.id)];
|
return lesson.segments[widget.state.activeSegmentIndexFor(lesson.id)];
|
||||||
}
|
}
|
||||||
|
|
||||||
LessonActivity get activity =>
|
LessonActivity get activity => activityBySegmentId(segment.id);
|
||||||
activityBySegmentId(segment.id);
|
|
||||||
|
|
||||||
List<VocabularyItem> get previewItems =>
|
List<VocabularyItem> get previewItems => vocabularyBySegmentId(segment.id);
|
||||||
vocabularyBySegmentId(segment.id);
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -122,17 +116,21 @@ class _LessonFlowState extends State<LessonFlow> {
|
|||||||
options: listeningOptions,
|
options: listeningOptions,
|
||||||
correctAnswer: activity.answers.first,
|
correctAnswer: activity.answers.first,
|
||||||
selectedAnswer: selectedAnswer,
|
selectedAnswer: selectedAnswer,
|
||||||
audioPlayed: listeningAudioPlayed,
|
audioPlayed: widget.state.listeningAudioPlayed,
|
||||||
missed: listeningMissed,
|
audioUnavailable: widget.state.listeningAudioUnavailable,
|
||||||
onSelected: (value) => setState(() {
|
support: widget.state.listeningSupport,
|
||||||
selectedAnswer = value;
|
missed: widget.state.listeningAttempts > 0,
|
||||||
|
onSelected: (value) {
|
||||||
|
setState(() => selectedAnswer = value);
|
||||||
if (listeningOptions[value] != activity.answers.first) {
|
if (listeningOptions[value] != activity.answers.first) {
|
||||||
listeningMissed = true;
|
widget.state.recordListeningMiss();
|
||||||
}
|
}
|
||||||
}),
|
},
|
||||||
onPlayed: () => setState(() => listeningAudioPlayed = true),
|
onPlayed: () => widget.state.recordListeningPlayback(),
|
||||||
|
onAudioFailed: widget.state.recordListeningAudioUnavailable,
|
||||||
|
onSlowPlayed: () => widget.state.recordListeningPlayback(slow: true),
|
||||||
onLookup: () {
|
onLookup: () {
|
||||||
setState(() => listeningMissed = true);
|
widget.state.revealListeningTranscript();
|
||||||
showLexiconLookup(
|
showLexiconLookup(
|
||||||
context,
|
context,
|
||||||
state: widget.state,
|
state: widget.state,
|
||||||
@@ -142,8 +140,7 @@ class _LessonFlowState extends State<LessonFlow> {
|
|||||||
onContinue:
|
onContinue:
|
||||||
selectedAnswer >= 0 &&
|
selectedAnswer >= 0 &&
|
||||||
listeningOptions[selectedAnswer] == activity.answers.first
|
listeningOptions[selectedAnswer] == activity.answers.first
|
||||||
? () =>
|
? () => widget.state.completeListening(recognized: true)
|
||||||
widget.state.completeListening(recognized: !listeningMissed)
|
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
case LessonStep.speaking:
|
case LessonStep.speaking:
|
||||||
@@ -152,8 +149,13 @@ class _LessonFlowState extends State<LessonFlow> {
|
|||||||
segmentId: segment.id,
|
segmentId: segment.id,
|
||||||
text: activity.speaking,
|
text: activity.speaking,
|
||||||
tip: activity.speakingTip,
|
tip: activity.speakingTip,
|
||||||
|
chunks: activity.speakingChunks,
|
||||||
|
examples: activity.speakingExamples,
|
||||||
|
buildIndex: widget.state.sentenceBuildIndex,
|
||||||
|
onBuildProgress: widget.state.recordSentenceBuildProgress,
|
||||||
keepRecording: widget.state.keepRecordings,
|
keepRecording: widget.state.keepRecordings,
|
||||||
onContinue: widget.state.completeSpeaking,
|
onContinue: (practiced) =>
|
||||||
|
widget.state.completeSpeaking(practiced: practiced),
|
||||||
);
|
);
|
||||||
case LessonStep.reading:
|
case LessonStep.reading:
|
||||||
content = _ReadingStep(
|
content = _ReadingStep(
|
||||||
@@ -191,8 +193,11 @@ class _LessonFlowState extends State<LessonFlow> {
|
|||||||
},
|
},
|
||||||
onToggleHelp: () =>
|
onToggleHelp: () =>
|
||||||
setState(() => showWritingHelp = !showWritingHelp),
|
setState(() => showWritingHelp = !showWritingHelp),
|
||||||
onContinue: (usedAiFeedback) => widget.state.completeWriting(
|
onContinue: (verified) => widget.state.completeWriting(
|
||||||
assisted: showWritingHelp || usedAiFeedback,
|
// The example is visible when this guided step opens. Hiding it
|
||||||
|
// later cannot turn the same answer into an independent attempt.
|
||||||
|
assisted: true,
|
||||||
|
verified: verified,
|
||||||
rawAnswer: writingController.text.trim(),
|
rawAnswer: writingController.text.trim(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -221,9 +226,10 @@ class _LessonFlowState extends State<LessonFlow> {
|
|||||||
initialText: activity.independentPrompt,
|
initialText: activity.independentPrompt,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onContinue: (spoken, recordingPath, aiCorrected) =>
|
onContinue: (spoken, recordingPath, aiCorrected, verified) =>
|
||||||
widget.state.completeIndependentAttempt(
|
widget.state.completeIndependentAttempt(
|
||||||
assisted: showIndependentHelp || aiCorrected,
|
assisted: showIndependentHelp || aiCorrected,
|
||||||
|
verified: verified,
|
||||||
spoken: spoken,
|
spoken: spoken,
|
||||||
rawAnswer: independentController.text.trim(),
|
rawAnswer: independentController.text.trim(),
|
||||||
recordingPath: recordingPath,
|
recordingPath: recordingPath,
|
||||||
@@ -234,6 +240,7 @@ class _LessonFlowState extends State<LessonFlow> {
|
|||||||
case LessonStep.complete:
|
case LessonStep.complete:
|
||||||
content = _CompletionStep(
|
content = _CompletionStep(
|
||||||
assisted: widget.state.independentAttemptAssisted,
|
assisted: widget.state.independentAttemptAssisted,
|
||||||
|
pending: widget.state.independentAttemptPending,
|
||||||
isFinalSegment:
|
isFinalSegment:
|
||||||
widget.state.activeSegmentIndexFor(lesson.id) ==
|
widget.state.activeSegmentIndexFor(lesson.id) ==
|
||||||
lesson.segments.length - 1,
|
lesson.segments.length - 1,
|
||||||
@@ -343,11 +350,13 @@ class _DialoguePendingStep extends StatelessWidget {
|
|||||||
class _CompletionStep extends StatelessWidget {
|
class _CompletionStep extends StatelessWidget {
|
||||||
const _CompletionStep({
|
const _CompletionStep({
|
||||||
required this.assisted,
|
required this.assisted,
|
||||||
|
required this.pending,
|
||||||
required this.isFinalSegment,
|
required this.isFinalSegment,
|
||||||
required this.nextSegmentNumber,
|
required this.nextSegmentNumber,
|
||||||
required this.onFinish,
|
required this.onFinish,
|
||||||
});
|
});
|
||||||
final bool assisted;
|
final bool assisted;
|
||||||
|
final bool pending;
|
||||||
final bool isFinalSegment;
|
final bool isFinalSegment;
|
||||||
final int nextSegmentNumber;
|
final int nextSegmentNumber;
|
||||||
final VoidCallback onFinish;
|
final VoidCallback onFinish;
|
||||||
@@ -362,7 +371,13 @@ class _CompletionStep extends StatelessWidget {
|
|||||||
isFinalSegment ? '你完成了这节课的练习。' : '你完成了当前小段的练习。',
|
isFinalSegment ? '你完成了这节课的练习。' : '你完成了当前小段的练习。',
|
||||||
style: Theme.of(context).textTheme.headlineMedium,
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
),
|
),
|
||||||
Text(assisted ? '独立尝试使用了帮助,系统会安排不同题再练一次。' : '这次独立尝试已记录,后续会在不同情境中复练。'),
|
Text(
|
||||||
|
pending
|
||||||
|
? '练习已保存,但答案未经 AI 核验,不计入独立掌握。后续会安排复练。'
|
||||||
|
: assisted
|
||||||
|
? '独立尝试使用了帮助,系统会安排不同题再练一次。'
|
||||||
|
: '这次独立尝试已记录,后续会在不同情境中复练。',
|
||||||
|
),
|
||||||
if (!isFinalSegment) Text('回到首页后,下次从第 $nextSegmentNumber 段继续。'),
|
if (!isFinalSegment) Text('回到首页后,下次从第 $nextSegmentNumber 段继续。'),
|
||||||
PrimaryButton(label: '回到首页', onPressed: onFinish),
|
PrimaryButton(label: '回到首页', onPressed: onFinish),
|
||||||
],
|
],
|
||||||
@@ -371,20 +386,28 @@ class _CompletionStep extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AudioRow extends StatelessWidget {
|
class _AudioRow extends StatelessWidget {
|
||||||
const _AudioRow({required this.label, this.speech, this.onPlayed});
|
const _AudioRow({
|
||||||
|
required this.label,
|
||||||
|
this.speech,
|
||||||
|
this.onPlayed,
|
||||||
|
this.onFailed,
|
||||||
|
this.showSlow = true,
|
||||||
|
});
|
||||||
final String label;
|
final String label;
|
||||||
final String? speech;
|
final String? speech;
|
||||||
final VoidCallback? onPlayed;
|
final VoidCallback? onPlayed;
|
||||||
|
final VoidCallback? onFailed;
|
||||||
|
final bool showSlow;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Row(
|
Widget build(BuildContext context) => Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton.filled(
|
IconButton.filled(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
try {
|
if (await VoiceService.instance.speak(speech ?? label)) {
|
||||||
await VoiceService.instance.speak(speech ?? label);
|
|
||||||
} finally {
|
|
||||||
onPlayed?.call();
|
onPlayed?.call();
|
||||||
|
} else {
|
||||||
|
onFailed?.call();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.play_arrow),
|
icon: const Icon(Icons.play_arrow),
|
||||||
@@ -393,10 +416,10 @@ class _AudioRow extends StatelessWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
try {
|
if (await VoiceService.instance.speak(speech ?? label)) {
|
||||||
await VoiceService.instance.speak(speech ?? label);
|
|
||||||
} finally {
|
|
||||||
onPlayed?.call();
|
onPlayed?.call();
|
||||||
|
} else {
|
||||||
|
onFailed?.call();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -405,11 +428,13 @@ class _AudioRow extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (showSlow)
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
try {
|
if (await VoiceService.instance.speak(
|
||||||
await VoiceService.instance.speak(speech ?? label, slow: true);
|
speech ?? label,
|
||||||
} finally {
|
slow: true,
|
||||||
|
)) {
|
||||||
onPlayed?.call();
|
onPlayed?.call();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,7 +27,12 @@ class _IndependentStep extends StatefulWidget {
|
|||||||
final VoidCallback onChanged;
|
final VoidCallback onChanged;
|
||||||
final VoidCallback onNeedHelp;
|
final VoidCallback onNeedHelp;
|
||||||
final VoidCallback onLookup;
|
final VoidCallback onLookup;
|
||||||
final void Function(bool spoken, String? recordingPath, bool aiCorrected)
|
final void Function(
|
||||||
|
bool spoken,
|
||||||
|
String? recordingPath,
|
||||||
|
bool aiCorrected,
|
||||||
|
bool verified,
|
||||||
|
)
|
||||||
onContinue;
|
onContinue;
|
||||||
final VoidCallback onLater;
|
final VoidCallback onLater;
|
||||||
|
|
||||||
@@ -57,7 +62,8 @@ class _IndependentStepState extends State<_IndependentStep>
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _submit() {
|
Future<void> _submit() async {
|
||||||
|
if (!widget.canContinue || checkingWithAi) return;
|
||||||
final shortfall = independentShortfall(
|
final shortfall = independentShortfall(
|
||||||
widget.segmentId,
|
widget.segmentId,
|
||||||
widget.controller.text,
|
widget.controller.text,
|
||||||
@@ -66,12 +72,20 @@ class _IndependentStepState extends State<_IndependentStep>
|
|||||||
setState(() => validationError = shortfall);
|
setState(() => validationError = shortfall);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (widget.state.aiProvider == AiProviderType.mock) {
|
||||||
|
_finish(verified: false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _checkWithAi();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (aiCheck?.verdict == 'accepted') _finish(verified: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _finish({required bool verified}) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
aiCheck?.verdict == 'accepted'
|
verified ? '通过:用上了本段要练的表达,AI 也没发现拼写或语法问题。' : '练习已保存;答案未经核验,不计入独立掌握。',
|
||||||
? '通过:用上了本段要练的表达,AI 也没发现拼写或语法问题。'
|
|
||||||
: '通过:用上了本段要练的表达。',
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -79,6 +93,7 @@ class _IndependentStepState extends State<_IndependentStep>
|
|||||||
usedVoice && !transcriptEdited,
|
usedVoice && !transcriptEdited,
|
||||||
widget.keepRecording ? recordingPath : null,
|
widget.keepRecording ? recordingPath : null,
|
||||||
aiCorrected,
|
aiCorrected,
|
||||||
|
verified,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,13 +105,9 @@ class _IndependentStepState extends State<_IndependentStep>
|
|||||||
|
|
||||||
Future<void> _checkWithAi() async {
|
Future<void> _checkWithAi() async {
|
||||||
final answer = widget.controller.text.trim();
|
final answer = widget.controller.text.trim();
|
||||||
if (answer.isEmpty || checkingWithAi) return;
|
|
||||||
if (widget.state.aiProvider == AiProviderType.mock) {
|
|
||||||
setState(() => aiCheckError = '请先在“我的”配置 AI 服务;本地检查仍可继续学习。');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() {
|
setState(() {
|
||||||
checkingWithAi = true;
|
checkingWithAi = true;
|
||||||
|
aiCheck = null;
|
||||||
aiCheckError = null;
|
aiCheckError = null;
|
||||||
});
|
});
|
||||||
final feedback = await AiService.instance.capabilities.evaluation
|
final feedback = await AiService.instance.capabilities.evaluation
|
||||||
@@ -119,9 +130,11 @@ class _IndependentStepState extends State<_IndependentStep>
|
|||||||
checkingWithAi = false;
|
checkingWithAi = false;
|
||||||
aiCheck = feedback;
|
aiCheck = feedback;
|
||||||
aiCheckError = feedback == null
|
aiCheckError = feedback == null
|
||||||
? '暂时无法获得 AI 检查结果。你的答案保留在这里,可稍后重试或直接提交。'
|
? '暂时无法核验答案。可重试,或仅保存练习继续。'
|
||||||
|
: feedback.verdict == 'uncertain'
|
||||||
|
? 'AI 无法确定答案是否正确。可重试,或仅保存练习继续。'
|
||||||
: null;
|
: null;
|
||||||
if (feedback != null && feedback.verdict != 'accepted') {
|
if (feedback?.verdict == 'rewrite') {
|
||||||
aiCorrected = true;
|
aiCorrected = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -191,6 +204,11 @@ class _IndependentStepState extends State<_IndependentStep>
|
|||||||
const Eyebrow('试着自己写 / 说一次 · 约 1 分钟'),
|
const Eyebrow('试着自己写 / 说一次 · 约 1 分钟'),
|
||||||
Text('现在不看句框。', style: Theme.of(context).textTheme.headlineMedium),
|
Text('现在不看句框。', style: Theme.of(context).textTheme.headlineMedium),
|
||||||
Text(widget.activity.independentPrompt),
|
Text(widget.activity.independentPrompt),
|
||||||
|
if (widget.state.aiProvider == AiProviderType.mock)
|
||||||
|
SectionCard(
|
||||||
|
tint: AppColors.warm,
|
||||||
|
child: const Text('尚未配置 AI 服务。本次可继续练习,但开放回答不会计入独立掌握。'),
|
||||||
|
),
|
||||||
if (widget.showHelp)
|
if (widget.showHelp)
|
||||||
SectionCard(
|
SectionCard(
|
||||||
tint: AppColors.warm,
|
tint: AppColors.warm,
|
||||||
@@ -255,19 +273,6 @@ class _IndependentStepState extends State<_IndependentStep>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: widget.canContinue && !checkingWithAi
|
|
||||||
? _checkWithAi
|
|
||||||
: null,
|
|
||||||
icon: checkingWithAi
|
|
||||||
? const SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.spellcheck),
|
|
||||||
label: Text(checkingWithAi ? '正在检查…' : '提交前 AI 检查语法和拼写(可选)'),
|
|
||||||
),
|
|
||||||
if (aiCheck != null)
|
if (aiCheck != null)
|
||||||
SectionCard(
|
SectionCard(
|
||||||
tint: aiCheck!.verdict == 'accepted'
|
tint: aiCheck!.verdict == 'accepted'
|
||||||
@@ -282,8 +287,10 @@ class _IndependentStepState extends State<_IndependentStep>
|
|||||||
Text('参考改正:${aiCheck!.suggestion}'),
|
Text('参考改正:${aiCheck!.suggestion}'),
|
||||||
Text(
|
Text(
|
||||||
aiCheck!.verdict == 'accepted'
|
aiCheck!.verdict == 'accepted'
|
||||||
? 'AI 仅作参考;是否完成仍由本地检查判断。'
|
? '本地目标与 AI 检查均通过。'
|
||||||
: '看过改正后再提交,本次会记为带提示完成。',
|
: aiCheck!.verdict == 'rewrite'
|
||||||
|
? '修改后再次提交;看过改正,本次会记为辅助练习。'
|
||||||
|
: '答案尚未核验,可重试或仅保存练习。',
|
||||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -322,8 +329,13 @@ class _IndependentStepState extends State<_IndependentStep>
|
|||||||
else
|
else
|
||||||
TextButton(onPressed: widget.onLookup, child: const Text('查词或短语')),
|
TextButton(onPressed: widget.onLookup, child: const Text('查词或短语')),
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
label: widget.showHelp || aiCorrected ? '带帮助完成' : '独立完成',
|
label: checkingWithAi ? '正在自动检查…' : '提交独立尝试',
|
||||||
onPressed: widget.canContinue ? _submit : null,
|
onPressed: widget.canContinue && !checkingWithAi ? _submit : null,
|
||||||
|
),
|
||||||
|
if (aiCheckError != null)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => _finish(verified: false),
|
||||||
|
child: const Text('未核验,保存练习并继续'),
|
||||||
),
|
),
|
||||||
TextButton(onPressed: widget.onLater, child: const Text('稍后继续')),
|
TextButton(onPressed: widget.onLater, child: const Text('稍后继续')),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -8,9 +8,13 @@ class _ListeningStep extends StatelessWidget {
|
|||||||
required this.correctAnswer,
|
required this.correctAnswer,
|
||||||
required this.selectedAnswer,
|
required this.selectedAnswer,
|
||||||
required this.audioPlayed,
|
required this.audioPlayed,
|
||||||
|
required this.audioUnavailable,
|
||||||
|
required this.support,
|
||||||
required this.missed,
|
required this.missed,
|
||||||
required this.onSelected,
|
required this.onSelected,
|
||||||
required this.onPlayed,
|
required this.onPlayed,
|
||||||
|
required this.onAudioFailed,
|
||||||
|
required this.onSlowPlayed,
|
||||||
required this.onLookup,
|
required this.onLookup,
|
||||||
required this.onContinue,
|
required this.onContinue,
|
||||||
});
|
});
|
||||||
@@ -22,11 +26,15 @@ class _ListeningStep extends StatelessWidget {
|
|||||||
|
|
||||||
final int selectedAnswer;
|
final int selectedAnswer;
|
||||||
final bool audioPlayed;
|
final bool audioPlayed;
|
||||||
|
final bool audioUnavailable;
|
||||||
|
final ListeningSupport support;
|
||||||
|
|
||||||
/// 选错过或查看过原文;此后可以看原文,但本题不再算独立听辨。
|
/// 选错过或查看过原文;此后可以看原文,但本题不再算独立听辨。
|
||||||
final bool missed;
|
final bool missed;
|
||||||
final ValueChanged<int> onSelected;
|
final ValueChanged<int> onSelected;
|
||||||
final VoidCallback onPlayed;
|
final VoidCallback onPlayed;
|
||||||
|
final VoidCallback onAudioFailed;
|
||||||
|
final VoidCallback onSlowPlayed;
|
||||||
final VoidCallback onLookup;
|
final VoidCallback onLookup;
|
||||||
final VoidCallback? onContinue;
|
final VoidCallback? onContinue;
|
||||||
|
|
||||||
@@ -35,7 +43,8 @@ class _ListeningStep extends StatelessWidget {
|
|||||||
final answers = options;
|
final answers = options;
|
||||||
final answeredCorrectly =
|
final answeredCorrectly =
|
||||||
selectedAnswer >= 0 && answers[selectedAnswer] == correctAnswer;
|
selectedAnswer >= 0 && answers[selectedAnswer] == correctAnswer;
|
||||||
final revealText = answeredCorrectly || missed;
|
final revealText =
|
||||||
|
answeredCorrectly || support == ListeningSupport.transcript;
|
||||||
return _LessonScaffold(
|
return _LessonScaffold(
|
||||||
step: 2,
|
step: 2,
|
||||||
child: SpacedColumn(
|
child: SpacedColumn(
|
||||||
@@ -52,13 +61,32 @@ class _ListeningStep extends StatelessWidget {
|
|||||||
label: audioPlayed ? '再播放一次' : '播放问题',
|
label: audioPlayed ? '再播放一次' : '播放问题',
|
||||||
speech: activity.listening,
|
speech: activity.listening,
|
||||||
onPlayed: onPlayed,
|
onPlayed: onPlayed,
|
||||||
|
onFailed: onAudioFailed,
|
||||||
|
showSlow: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!audioPlayed)
|
if (audioPlayed && !revealText)
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
final played = await VoiceService.instance.speak(
|
||||||
|
activity.listening,
|
||||||
|
slow: true,
|
||||||
|
);
|
||||||
|
if (played) onSlowPlayed();
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.slow_motion_video),
|
||||||
|
label: Text(support == ListeningSupport.slow ? '再慢听一次' : '慢一点再听'),
|
||||||
|
),
|
||||||
|
if (!audioPlayed && !audioUnavailable)
|
||||||
Text(
|
Text(
|
||||||
'先播放音频,听完再选择。原文会在作答后显示。',
|
'先播放音频,听完再选择。原文会在作答后显示。',
|
||||||
style: TextStyle(color: AppColors.muted),
|
style: TextStyle(color: AppColors.muted),
|
||||||
),
|
),
|
||||||
|
if (audioUnavailable)
|
||||||
|
SectionCard(
|
||||||
|
tint: AppColors.warm,
|
||||||
|
child: const Text('当前设备无法播放语音。下面显示原文,你仍可完成理解练习;本题不会计为独立听辨。'),
|
||||||
|
),
|
||||||
if (revealText)
|
if (revealText)
|
||||||
SectionCard(child: LexiconText(activity.listening, state: state))
|
SectionCard(child: LexiconText(activity.listening, state: state))
|
||||||
else if (audioPlayed)
|
else if (audioPlayed)
|
||||||
@@ -70,7 +98,9 @@ class _ListeningStep extends StatelessWidget {
|
|||||||
for (var index = 0; index < answers.length; index++)
|
for (var index = 0; index < answers.length; index++)
|
||||||
SectionCard(
|
SectionCard(
|
||||||
tint: selectedAnswer == index ? AppColors.softGreen : null,
|
tint: selectedAnswer == index ? AppColors.softGreen : null,
|
||||||
onTap: audioPlayed ? () => onSelected(index) : null,
|
onTap: audioPlayed || audioUnavailable
|
||||||
|
? () => onSelected(index)
|
||||||
|
: null,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
@@ -84,7 +114,7 @@ class _ListeningStep extends StatelessWidget {
|
|||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Text(
|
Text(
|
||||||
answers[index],
|
answers[index],
|
||||||
style: audioPlayed
|
style: audioPlayed || audioUnavailable
|
||||||
? null
|
? null
|
||||||
: TextStyle(color: AppColors.muted),
|
: TextStyle(color: AppColors.muted),
|
||||||
),
|
),
|
||||||
@@ -94,14 +124,11 @@ class _ListeningStep extends StatelessWidget {
|
|||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
label: selectedAnswer >= 0
|
label: selectedAnswer >= 0
|
||||||
? '检查并继续'
|
? '检查并继续'
|
||||||
: (audioPlayed ? '请选择答案' : '先播放音频'),
|
: (audioPlayed || audioUnavailable ? '请选择答案' : '先播放音频'),
|
||||||
onPressed: onContinue,
|
onPressed: onContinue,
|
||||||
),
|
),
|
||||||
if (selectedAnswer >= 0 && answers[selectedAnswer] != correctAnswer)
|
if (selectedAnswer >= 0 && answers[selectedAnswer] != correctAnswer)
|
||||||
Text(
|
Text('再听一次,选择正确答案。', style: TextStyle(color: AppColors.warmInk)),
|
||||||
'再听一次,选择正确答案。',
|
|
||||||
style: TextStyle(color: AppColors.warmInk),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,15 +6,23 @@ class _SpeakingStep extends StatefulWidget {
|
|||||||
required this.segmentId,
|
required this.segmentId,
|
||||||
required this.text,
|
required this.text,
|
||||||
this.tip = '',
|
this.tip = '',
|
||||||
|
this.chunks = const [],
|
||||||
|
this.examples = const [],
|
||||||
|
required this.buildIndex,
|
||||||
|
required this.onBuildProgress,
|
||||||
required this.keepRecording,
|
required this.keepRecording,
|
||||||
required this.onContinue,
|
required this.onContinue,
|
||||||
});
|
});
|
||||||
final String segmentId;
|
final String segmentId;
|
||||||
final String text;
|
final String text;
|
||||||
final String tip;
|
final String tip;
|
||||||
|
final List<String> chunks;
|
||||||
|
final List<String> examples;
|
||||||
|
final int buildIndex;
|
||||||
|
final ValueChanged<int> onBuildProgress;
|
||||||
final AppState state;
|
final AppState state;
|
||||||
final bool keepRecording;
|
final bool keepRecording;
|
||||||
final VoidCallback onContinue;
|
final ValueChanged<bool> onContinue;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_SpeakingStep> createState() => _SpeakingStepState();
|
State<_SpeakingStep> createState() => _SpeakingStepState();
|
||||||
@@ -89,12 +97,57 @@ class _SpeakingStepState extends State<_SpeakingStep>
|
|||||||
if (widget.tip.isNotEmpty)
|
if (widget.tip.isNotEmpty)
|
||||||
SectionCard(
|
SectionCard(
|
||||||
tint: AppColors.warm,
|
tint: AppColors.warm,
|
||||||
child: Text(
|
child: Text(widget.tip, style: TextStyle(color: AppColors.warmInk)),
|
||||||
widget.tip,
|
|
||||||
style: TextStyle(color: AppColors.warmInk),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
SegmentUsage(segmentId: widget.segmentId, state: widget.state),
|
SegmentUsage(segmentId: widget.segmentId, state: widget.state),
|
||||||
|
if (widget.chunks.length >= 2)
|
||||||
|
SectionCard(
|
||||||
|
tint: AppColors.surfaceMuted,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('拆开练(可选)'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
widget.buildIndex == 0
|
||||||
|
? '先听词块,再逐步合成整句。'
|
||||||
|
: widget.chunks.take(widget.buildIndex).join(' '),
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () async {
|
||||||
|
final next = widget.buildIndex < widget.chunks.length
|
||||||
|
? widget.buildIndex + 1
|
||||||
|
: 1;
|
||||||
|
await VoiceService.instance.speak(
|
||||||
|
widget.chunks.take(next).join(' '),
|
||||||
|
);
|
||||||
|
widget.onBuildProgress(next);
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.play_arrow),
|
||||||
|
label: Text(
|
||||||
|
widget.buildIndex == widget.chunks.length
|
||||||
|
? '从头再练'
|
||||||
|
: '听下一段并跟读',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.examples.isNotEmpty)
|
||||||
|
ExpansionTile(
|
||||||
|
title: Text('更多示范句(${widget.examples.length})'),
|
||||||
|
children: [
|
||||||
|
for (final example in widget.examples)
|
||||||
|
ListTile(
|
||||||
|
title: LexiconText(example, state: widget.state),
|
||||||
|
trailing: IconButton(
|
||||||
|
tooltip: '播放示范句',
|
||||||
|
icon: const Icon(Icons.volume_up_outlined),
|
||||||
|
onPressed: () => VoiceService.instance.speak(example),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
SecondaryButton(
|
SecondaryButton(
|
||||||
label: transcribing
|
label: transcribing
|
||||||
? '正在 AI 识别发音…'
|
? '正在 AI 识别发音…'
|
||||||
@@ -146,8 +199,10 @@ class _SpeakingStepState extends State<_SpeakingStep>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
label: transcript.isEmpty ? '我已跟读,继续' : '确认并继续',
|
label: recordingPath == null ? '跳过跟读,继续' : '录音完成,继续',
|
||||||
onPressed: widget.onContinue,
|
onPressed: (listening || transcribing)
|
||||||
|
? null
|
||||||
|
: () => widget.onContinue(recordingPath != null),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -34,23 +34,18 @@ class _WritingStepState extends State<_WritingStep> {
|
|||||||
String? aiFeedbackError;
|
String? aiFeedbackError;
|
||||||
bool requestingAiFeedback = false;
|
bool requestingAiFeedback = false;
|
||||||
|
|
||||||
void _checkOrContinue() {
|
Future<void> _submit() async {
|
||||||
if (result?.complete == true) {
|
if (!widget.canContinue || requestingAiFeedback) return;
|
||||||
widget.onContinue(aiFeedback != null);
|
final answer = widget.controller.text.trim();
|
||||||
return;
|
final local = WritingFeedback.check(widget.segmentId, answer);
|
||||||
}
|
setState(() {
|
||||||
setState(
|
result = local;
|
||||||
() => result = WritingFeedback.check(
|
aiFeedback = null;
|
||||||
widget.segmentId,
|
aiFeedbackError = null;
|
||||||
widget.controller.text,
|
});
|
||||||
),
|
if (!local.complete) return;
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _requestAiFeedback() async {
|
|
||||||
if (widget.controller.text.trim().isEmpty || requestingAiFeedback) return;
|
|
||||||
if (widget.state.aiProvider == AiProviderType.mock) {
|
if (widget.state.aiProvider == AiProviderType.mock) {
|
||||||
setState(() => aiFeedbackError = '请先在“我的”配置 AI 服务;本地检查仍可继续学习。');
|
widget.onContinue(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -64,19 +59,28 @@ class _WritingStepState extends State<_WritingStep> {
|
|||||||
model: widget.state.aiModel,
|
model: widget.state.aiModel,
|
||||||
lessonId: widget.lessonId,
|
lessonId: widget.lessonId,
|
||||||
taskPrompt: widget.activity.writingPrompt,
|
taskPrompt: widget.activity.writingPrompt,
|
||||||
answer: widget.controller.text.trim(),
|
answer: answer,
|
||||||
level: lessonLevel(widget.lessonId),
|
level: lessonLevel(widget.lessonId),
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted || widget.controller.text.trim() != answer) {
|
||||||
|
if (mounted) setState(() => requestingAiFeedback = false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (feedback?.verdict == 'accepted') {
|
||||||
|
widget.onContinue(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
requestingAiFeedback = false;
|
requestingAiFeedback = false;
|
||||||
aiFeedback = feedback;
|
aiFeedback = feedback;
|
||||||
aiFeedbackError = feedback == null
|
aiFeedbackError = feedback == null || feedback.verdict == 'uncertain'
|
||||||
? '暂时无法获得 AI 反馈。你的答案保留在这里,可稍后重试或继续本地练习。'
|
? '答案暂时无法核验。可以重试,或仅保存这次练习继续学习。'
|
||||||
: null;
|
: null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _continueUnverified() => widget.onContinue(false);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => _LessonScaffold(
|
Widget build(BuildContext context) => _LessonScaffold(
|
||||||
step: 5,
|
step: 5,
|
||||||
@@ -87,6 +91,11 @@ class _WritingStepState extends State<_WritingStep> {
|
|||||||
widget.activity.writingPrompt,
|
widget.activity.writingPrompt,
|
||||||
style: Theme.of(context).textTheme.headlineMedium,
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
),
|
),
|
||||||
|
if (widget.state.aiProvider == AiProviderType.mock)
|
||||||
|
SectionCard(
|
||||||
|
tint: AppColors.warm,
|
||||||
|
child: const Text('尚未配置 AI 服务。本次可继续练习,但开放回答不会计入独立掌握。'),
|
||||||
|
),
|
||||||
SectionCard(
|
SectionCard(
|
||||||
tint: AppColors.surfaceMuted,
|
tint: AppColors.surfaceMuted,
|
||||||
child: Text('小提示:${grammarNoteForSegment(widget.segmentId)}'),
|
child: Text('小提示:${grammarNoteForSegment(widget.segmentId)}'),
|
||||||
@@ -126,19 +135,6 @@ class _WritingStepState extends State<_WritingStep> {
|
|||||||
onPressed: widget.onToggleHelp,
|
onPressed: widget.onToggleHelp,
|
||||||
child: Text(widget.showHelp ? '收起示例,自己试一次' : '需要帮助,查看示例'),
|
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)
|
if (aiFeedback != null)
|
||||||
SectionCard(
|
SectionCard(
|
||||||
tint: aiFeedback!.verdict == 'accepted'
|
tint: aiFeedback!.verdict == 'accepted'
|
||||||
@@ -153,7 +149,9 @@ class _WritingStepState extends State<_WritingStep> {
|
|||||||
if (aiFeedback!.suggestion != null)
|
if (aiFeedback!.suggestion != null)
|
||||||
Text('可参考改写:${aiFeedback!.suggestion}'),
|
Text('可参考改写:${aiFeedback!.suggestion}'),
|
||||||
Text(
|
Text(
|
||||||
'这是学习帮助;请按自己的意思重写后再检查,系统不会仅凭 AI 建议记为掌握。',
|
aiFeedback!.verdict == 'rewrite'
|
||||||
|
? '请按自己的意思修改,再提交检查。看过改正后,本次只算辅助练习。'
|
||||||
|
: '答案尚未核验;可以重试检查或仅保存练习。',
|
||||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -167,19 +165,24 @@ class _WritingStepState extends State<_WritingStep> {
|
|||||||
style: TextStyle(color: AppColors.warmInk),
|
style: TextStyle(color: AppColors.warmInk),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (result != null)
|
if (result != null && !result!.complete)
|
||||||
SectionCard(
|
SectionCard(
|
||||||
tint: result!.complete ? AppColors.softGreen : AppColors.warm,
|
tint: AppColors.warm,
|
||||||
child: Text(
|
child: Text(
|
||||||
result!.message,
|
result!.message,
|
||||||
style: TextStyle(
|
style: TextStyle(color: AppColors.warmInk),
|
||||||
color: result!.complete ? AppColors.green : AppColors.warmInk,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
label: result?.complete == true ? '进入课程对话' : '检查句子',
|
label: requestingAiFeedback ? '正在自动检查…' : '提交并继续',
|
||||||
onPressed: widget.canContinue ? _checkOrContinue : null,
|
onPressed: widget.canContinue && !requestingAiFeedback
|
||||||
|
? _submit
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
if (aiFeedbackError != null)
|
||||||
|
TextButton(
|
||||||
|
onPressed: _continueUnverified,
|
||||||
|
child: const Text('未核验,保存练习并继续'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -147,13 +147,21 @@ class _PlacementPageState extends State<PlacementPage>
|
|||||||
bool get _answeredName =>
|
bool get _answeredName =>
|
||||||
coreItemUsedIn('A0-P01', answerController.text.trim());
|
coreItemUsedIn('A0-P01', answerController.text.trim());
|
||||||
|
|
||||||
/// Placement only picks an A0 starting point until A1 content is frozen, and
|
/// Self report only moves the starting point when both short checks pass.
|
||||||
/// it never marks a lesson as passed.
|
/// Placement never marks the earlier lessons as completed.
|
||||||
String get _suggestedLessonId =>
|
String get _suggestedLessonId {
|
||||||
_understoodGreeting && _answeredName ? 'a0-02' : 'a0-01';
|
if (!_understoodGreeting || !_answeredName) return 'a0-01';
|
||||||
|
return switch (selected) {
|
||||||
|
PlacementLevel.beginner => 'a0-02',
|
||||||
|
PlacementLevel.someBasics => 'a0-03',
|
||||||
|
PlacementLevel.simpleConversation => 'a0-04',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
String get _reason => _understoodGreeting && _answeredName
|
String get _reason => _understoodGreeting && _answeredName
|
||||||
|
? selected == PlacementLevel.beginner
|
||||||
? '你已经能听懂问候,也能说出自己的名字,可以从拼写名字开始。'
|
? '你已经能听懂问候,也能说出自己的名字,可以从拼写名字开始。'
|
||||||
|
: '你通过了两项基础检查,结合自选基础,建议从这课试学;前面的课随时可以返回。'
|
||||||
: _understoodGreeting
|
: _understoodGreeting
|
||||||
? '你能听懂问候,先把自我介绍说完整会更稳。'
|
? '你能听懂问候,先把自我介绍说完整会更稳。'
|
||||||
: '先从问候和介绍名字开始,打好第一句的基础。';
|
: '先从问候和介绍名字开始,打好第一句的基础。';
|
||||||
|
|||||||
@@ -30,6 +30,18 @@ class _LearningRecordsViewState extends State<LearningRecordsView> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final progress = <String>{};
|
||||||
|
final improved = <String, AttemptEvidence>{};
|
||||||
|
for (final entry in widget.state.attemptEvidence) {
|
||||||
|
final key = '${entry.itemId}|${entry.skill}';
|
||||||
|
if (entry.outcome == EvidenceKind.assisted ||
|
||||||
|
entry.outcome == EvidenceKind.languageError) {
|
||||||
|
progress.add(key);
|
||||||
|
} else if (entry.outcome == EvidenceKind.independentSuccess &&
|
||||||
|
progress.contains(key)) {
|
||||||
|
improved[key] = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
final records = widget.state.attemptEvidence.reversed
|
final records = widget.state.attemptEvidence.reversed
|
||||||
.where(_matches)
|
.where(_matches)
|
||||||
.take(_limit)
|
.take(_limit)
|
||||||
@@ -41,6 +53,21 @@ class _LearningRecordsViewState extends State<LearningRecordsView> {
|
|||||||
return SpacedColumn(
|
return SpacedColumn(
|
||||||
spacing: 16,
|
spacing: 16,
|
||||||
children: [
|
children: [
|
||||||
|
if (improved.isNotEmpty)
|
||||||
|
SectionCard(
|
||||||
|
tint: AppColors.softGreen,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('从需要帮助到独立完成'),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
for (final entry in improved.values.toList().reversed.take(3))
|
||||||
|
Text(
|
||||||
|
'${widget.state.mastery[entry.itemId]?.label ?? entry.itemId} · ${entry.skill}',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: SegmentedButton<_RecordFilter>(
|
child: SegmentedButton<_RecordFilter>(
|
||||||
@@ -100,7 +127,7 @@ class _RecordTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
EvidenceKind.assisted => ('带提示', AppColors.warmInk, AppColors.warm),
|
EvidenceKind.assisted => ('带提示', AppColors.warmInk, AppColors.warm),
|
||||||
EvidenceKind.languageError => ('需要复核', AppColors.warmInk, AppColors.warm),
|
EvidenceKind.languageError => ('需要复核', AppColors.warmInk, AppColors.warm),
|
||||||
EvidenceKind.pending => ('稍后完成', AppColors.muted, AppColors.surfaceMuted),
|
EvidenceKind.pending => ('待确认', AppColors.muted, AppColors.surfaceMuted),
|
||||||
EvidenceKind.exposure => ('已查看', AppColors.muted, AppColors.surfaceMuted),
|
EvidenceKind.exposure => ('已查看', AppColors.muted, AppColors.surfaceMuted),
|
||||||
};
|
};
|
||||||
return ListTile(
|
return ListTile(
|
||||||
@@ -110,7 +137,19 @@ class _RecordTile extends StatelessWidget {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
subtitle: Text('${evidence.skill} · ${relativeTime(evidence.createdAt)}'),
|
subtitle: Text(
|
||||||
|
[
|
||||||
|
evidence.skill,
|
||||||
|
relativeTime(evidence.createdAt),
|
||||||
|
if (evidence.listeningSupport == ListeningSupport.slow) '慢听后完成',
|
||||||
|
if (evidence.listeningSupport == ListeningSupport.transcript)
|
||||||
|
'看原文后完成',
|
||||||
|
if (evidence.firstTry == true) '首次答对',
|
||||||
|
if (evidence.attemptCount != null && evidence.attemptCount! > 1)
|
||||||
|
'尝试 ${evidence.attemptCount} 次',
|
||||||
|
if (evidence.usedSentenceBuild) '练过句子拆段',
|
||||||
|
].join(' · '),
|
||||||
|
),
|
||||||
trailing: Container(
|
trailing: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ class LevelProgressView extends StatelessWidget {
|
|||||||
style: Theme.of(context).textTheme.headlineMedium,
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
state.a0Passed ? 'A1 主线内容尚未提供,可继续进行 A0 巩固。' : '进度来自掌握证据,不是上完固定课数就升级。',
|
state.a0Passed ? 'A1 课程待教学审校开放,可继续进行 A0 巩固。' : '进度来自掌握证据,不是上完固定课数就升级。',
|
||||||
),
|
),
|
||||||
MenuGroup(
|
MenuGroup(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -306,7 +306,7 @@ class _StageCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
missing.isEmpty
|
missing.isEmpty
|
||||||
? 'A0 已通过。A1 主线内容尚未提供,可继续进行 A0 巩固。'
|
? 'A0 已通过。A1 课程待教学审校开放,可继续进行 A0 巩固。'
|
||||||
: '还需:${missing.join(' · ')}',
|
: '还需:${missing.join(' · ')}',
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class _ReviewPageState extends State<ReviewPage>
|
|||||||
final controller = TextEditingController();
|
final controller = TextEditingController();
|
||||||
bool showHint = false;
|
bool showHint = false;
|
||||||
bool usedHelp = false;
|
bool usedHelp = false;
|
||||||
|
int answerChecks = 0;
|
||||||
String? validationMessage;
|
String? validationMessage;
|
||||||
bool generatingVariant = false;
|
bool generatingVariant = false;
|
||||||
bool generatingLesson = false;
|
bool generatingLesson = false;
|
||||||
@@ -50,6 +51,7 @@ class _ReviewPageState extends State<ReviewPage>
|
|||||||
/// The answer just accepted, shown before moving to the next item.
|
/// The answer just accepted, shown before moving to the next item.
|
||||||
({String answer, String reference, String? audio, bool assisted})? lastResult;
|
({String answer, String reference, String? audio, bool assisted})? lastResult;
|
||||||
bool dictationPlayed = false;
|
bool dictationPlayed = false;
|
||||||
|
ListeningSupport dictationSupport = ListeningSupport.none;
|
||||||
|
|
||||||
/// Items whose recognition question has been answered in this session. The
|
/// Items whose recognition question has been answered in this session. The
|
||||||
/// gate is asked once per item; the production task follows immediately.
|
/// gate is asked once per item; the production task follows immediately.
|
||||||
@@ -77,21 +79,23 @@ class _ReviewPageState extends State<ReviewPage>
|
|||||||
VoiceService.instance.deleteRecording(recordingPath);
|
VoiceService.instance.deleteRecording(recordingPath);
|
||||||
showHint = false;
|
showHint = false;
|
||||||
usedHelp = false;
|
usedHelp = false;
|
||||||
|
answerChecks = 0;
|
||||||
validationMessage = null;
|
validationMessage = null;
|
||||||
dictationDiff = null;
|
dictationDiff = null;
|
||||||
transcript = null;
|
transcript = null;
|
||||||
recordingPath = null;
|
recordingPath = null;
|
||||||
dictationPlayed = false;
|
dictationPlayed = false;
|
||||||
|
dictationSupport = ListeningSupport.none;
|
||||||
aiFeedback = null;
|
aiFeedback = null;
|
||||||
aiFeedbackError = null;
|
aiFeedbackError = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _checkWithAi(ReviewItem item) async {
|
Future<WritingAiFeedback?> _checkWithAi(ReviewItem item) async {
|
||||||
final answer = controller.text.trim();
|
final answer = controller.text.trim();
|
||||||
if (answer.isEmpty || checkingWithAi) return;
|
if (answer.isEmpty || checkingWithAi) return null;
|
||||||
if (widget.state.aiProvider == AiProviderType.mock) {
|
if (widget.state.aiProvider == AiProviderType.mock) {
|
||||||
setState(() => aiFeedbackError = '请先在“我的”配置 AI 服务;本地检查仍可继续复习。');
|
setState(() => aiFeedbackError = '未配置 AI 服务;本次只记为练习,不增加独立掌握。');
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
checkingWithAi = true;
|
checkingWithAi = true;
|
||||||
@@ -111,28 +115,46 @@ class _ReviewPageState extends State<ReviewPage>
|
|||||||
// The learner may have moved on or kept typing while the request ran.
|
// The learner may have moved on or kept typing while the request ran.
|
||||||
if (!mounted || controller.text.trim() != answer) {
|
if (!mounted || controller.text.trim() != answer) {
|
||||||
if (mounted) setState(() => checkingWithAi = false);
|
if (mounted) setState(() => checkingWithAi = false);
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
checkingWithAi = false;
|
checkingWithAi = false;
|
||||||
aiFeedback = feedback;
|
aiFeedback = feedback;
|
||||||
aiFeedbackError = feedback == null
|
aiFeedbackError = feedback == null
|
||||||
? '暂时无法获得 AI 检查结果。你的答案保留在这里,可稍后重试或直接提交。'
|
? '暂时无法获得 AI 检查结果;本次只记为练习,不增加独立掌握。'
|
||||||
: null;
|
: null;
|
||||||
// A shown correction is help: finishing afterwards counts as assisted.
|
// A shown correction is help: finishing afterwards counts as assisted.
|
||||||
if (feedback != null && feedback.verdict != 'accepted') usedHelp = true;
|
if (feedback != null && feedback.verdict != 'accepted') usedHelp = true;
|
||||||
});
|
});
|
||||||
|
return feedback;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _next(ReviewItem item, {required bool assisted}) {
|
Future<void> _next(ReviewItem item, {required bool assisted}) async {
|
||||||
|
if (checkingWithAi) return;
|
||||||
|
answerChecks++;
|
||||||
final result = ReviewFeedback.check(item, controller.text);
|
final result = ReviewFeedback.check(item, controller.text);
|
||||||
if (!result.complete) {
|
if (!result.complete) {
|
||||||
setState(() {
|
setState(() {
|
||||||
|
usedHelp = true;
|
||||||
validationMessage = result.message;
|
validationMessage = result.message;
|
||||||
dictationDiff = result.dictation;
|
dictationDiff = result.dictation;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (item.skill != dictationSkill) {
|
||||||
|
final answerBeforeCheck = controller.text.trim();
|
||||||
|
final feedback = await _checkWithAi(item);
|
||||||
|
if (!mounted || controller.text.trim() != answerBeforeCheck) return;
|
||||||
|
if (feedback?.verdict == 'rewrite') {
|
||||||
|
setState(() {
|
||||||
|
usedHelp = true;
|
||||||
|
validationMessage = '请根据 AI 反馈修改句子,再提交。';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (feedback?.verdict != 'accepted') assisted = true;
|
||||||
|
}
|
||||||
|
assisted = assisted || usedHelp || answerChecks > 1;
|
||||||
final answer = controller.text.trim();
|
final answer = controller.text.trim();
|
||||||
final dictation = item.skill == dictationSkill
|
final dictation = item.skill == dictationSkill
|
||||||
? coreDictationSentences[item.id]
|
? coreDictationSentences[item.id]
|
||||||
@@ -142,6 +164,9 @@ class _ReviewPageState extends State<ReviewPage>
|
|||||||
widget.state.completeReview(
|
widget.state.completeReview(
|
||||||
item,
|
item,
|
||||||
assisted: assisted,
|
assisted: assisted,
|
||||||
|
firstTry: answerChecks == 1,
|
||||||
|
attemptCount: answerChecks,
|
||||||
|
listeningSupport: dictation == null ? null : dictationSupport,
|
||||||
rawAnswer: answer,
|
rawAnswer: answer,
|
||||||
inputMode: spoken && transcript == answer ? 'speechToText' : 'text',
|
inputMode: spoken && transcript == answer ? 'speechToText' : 'text',
|
||||||
originalTranscript: transcript,
|
originalTranscript: transcript,
|
||||||
@@ -269,7 +294,11 @@ class _ReviewPageState extends State<ReviewPage>
|
|||||||
// Being told the answer is the moment to say what the item is for. A
|
// Being told the answer is the moment to say what the item is for. A
|
||||||
// learner who could not produce `What's your name?` often knows the words
|
// learner who could not produce `What's your name?` often knows the words
|
||||||
// and not the situation.
|
// and not the situation.
|
||||||
if (coreContrastQuestion(item.id, isTaught: widget.state.mastery.containsKey) != null) {
|
if (coreContrastQuestion(
|
||||||
|
item.id,
|
||||||
|
isTaught: widget.state.mastery.containsKey,
|
||||||
|
) !=
|
||||||
|
null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_resetAnswer();
|
_resetAnswer();
|
||||||
contrastFor = item;
|
contrastFor = item;
|
||||||
@@ -298,14 +327,14 @@ class _ReviewPageState extends State<ReviewPage>
|
|||||||
child: SpacedColumn(
|
child: SpacedColumn(
|
||||||
children: [
|
children: [
|
||||||
const Eyebrow('先弄清楚什么时候用'),
|
const Eyebrow('先弄清楚什么时候用'),
|
||||||
Text(
|
Text('这句话用在这种情况:', style: Theme.of(context).textTheme.headlineMedium),
|
||||||
'这句话用在这种情况:',
|
|
||||||
style: Theme.of(context).textTheme.headlineMedium,
|
|
||||||
),
|
|
||||||
UsageCard(itemId: item.id, state: widget.state),
|
UsageCard(itemId: item.id, state: widget.state),
|
||||||
SectionCard(
|
SectionCard(
|
||||||
tint: AppColors.surfaceMuted,
|
tint: AppColors.surfaceMuted,
|
||||||
child: Text(question.situation, style: const TextStyle(fontSize: 17)),
|
child: Text(
|
||||||
|
question.situation,
|
||||||
|
style: const TextStyle(fontSize: 17),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const Text('这时候该说哪一句?'),
|
const Text('这时候该说哪一句?'),
|
||||||
for (final option in question.options)
|
for (final option in question.options)
|
||||||
@@ -378,7 +407,7 @@ class _ReviewPageState extends State<ReviewPage>
|
|||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
result.assisted ? '带提示完成' : '答对了!',
|
result.assisted ? '完成练习(未计独立掌握)' : '答对了!',
|
||||||
style: Theme.of(context).textTheme.headlineMedium,
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -439,11 +468,8 @@ class _ReviewPageState extends State<ReviewPage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _play(String text, {bool slow = false}) async {
|
Future<void> _play(String text, {bool slow = false}) async {
|
||||||
try {
|
final played = await VoiceService.instance.speak(text, slow: slow);
|
||||||
await VoiceService.instance.speak(text, slow: slow);
|
if (played && mounted) setState(() => dictationPlayed = true);
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => dictationPlayed = true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _generateVariant(ReviewItem item) async {
|
Future<void> _generateVariant(ReviewItem item) async {
|
||||||
@@ -629,7 +655,21 @@ class _ReviewPageState extends State<ReviewPage>
|
|||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(child: Text(dictationPlayed ? '再听一次' : '播放句子')),
|
Expanded(child: Text(dictationPlayed ? '再听一次' : '播放句子')),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => _play(dictation.sentence, slow: true),
|
onPressed: dictationPlayed
|
||||||
|
? () async {
|
||||||
|
final played = await VoiceService.instance
|
||||||
|
.speak(dictation.sentence, slow: true);
|
||||||
|
if (played && mounted) {
|
||||||
|
setState(() {
|
||||||
|
if (dictationSupport !=
|
||||||
|
ListeningSupport.transcript) {
|
||||||
|
dictationSupport = ListeningSupport.slow;
|
||||||
|
}
|
||||||
|
usedHelp = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: null,
|
||||||
child: const Text('慢速'),
|
child: const Text('慢速'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -682,6 +722,9 @@ class _ReviewPageState extends State<ReviewPage>
|
|||||||
onPressed: () => setState(() {
|
onPressed: () => setState(() {
|
||||||
showHint = true;
|
showHint = true;
|
||||||
usedHelp = true;
|
usedHelp = true;
|
||||||
|
if (dictation != null) {
|
||||||
|
dictationSupport = ListeningSupport.transcript;
|
||||||
|
}
|
||||||
validationMessage = null;
|
validationMessage = null;
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -729,6 +772,7 @@ class _ReviewPageState extends State<ReviewPage>
|
|||||||
: '参考:${item.hint}\n目标:${item.target}',
|
: '参考:${item.hint}\n目标:${item.target}',
|
||||||
style: TextStyle(color: AppColors.warmInk),
|
style: TextStyle(color: AppColors.warmInk),
|
||||||
),
|
),
|
||||||
|
if (dictation != null) Text('原文:${dictation.sentence}'),
|
||||||
if (audioText != null && dictation == null)
|
if (audioText != null && dictation == null)
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: () => _play(audioText),
|
onPressed: () => _play(audioText),
|
||||||
@@ -739,20 +783,12 @@ class _ReviewPageState extends State<ReviewPage>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Dictation is already compared word by word against the sentence.
|
if (checkingWithAi) const Center(child: CircularProgressIndicator()),
|
||||||
if (dictation == null)
|
if (dictation == null &&
|
||||||
OutlinedButton.icon(
|
widget.state.aiProvider == AiProviderType.mock)
|
||||||
onPressed: controller.text.trim().isEmpty || checkingWithAi
|
SectionCard(
|
||||||
? null
|
tint: AppColors.warm,
|
||||||
: () => _checkWithAi(item),
|
child: const Text('尚未配置 AI 服务。可继续复习,但开放回答不会计入独立掌握。'),
|
||||||
icon: checkingWithAi
|
|
||||||
? const SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.spellcheck),
|
|
||||||
label: Text(checkingWithAi ? '正在检查…' : 'AI 检查语法和拼写(可选)'),
|
|
||||||
),
|
),
|
||||||
if (aiFeedback != null)
|
if (aiFeedback != null)
|
||||||
SectionCard(
|
SectionCard(
|
||||||
@@ -768,7 +804,7 @@ class _ReviewPageState extends State<ReviewPage>
|
|||||||
Text('参考改正:${aiFeedback!.suggestion}'),
|
Text('参考改正:${aiFeedback!.suggestion}'),
|
||||||
Text(
|
Text(
|
||||||
aiFeedback!.verdict == 'accepted'
|
aiFeedback!.verdict == 'accepted'
|
||||||
? 'AI 仅作参考;是否完成仍由本地检查判断。'
|
? 'AI 已检查;本地规则也会检查目标表达。'
|
||||||
: '看过改正后再提交,本次会记为带提示完成。',
|
: '看过改正后再提交,本次会记为带提示完成。',
|
||||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||||
),
|
),
|
||||||
@@ -790,8 +826,8 @@ class _ReviewPageState extends State<ReviewPage>
|
|||||||
),
|
),
|
||||||
if (dictationDiff case final diff?) _DictationDiffCard(diff: diff),
|
if (dictationDiff case final diff?) _DictationDiffCard(diff: diff),
|
||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
label: usedHelp ? '带提示完成' : '我能独立回答',
|
label: usedHelp ? '带提示完成' : '检查并提交',
|
||||||
onPressed: controller.text.trim().isEmpty
|
onPressed: controller.text.trim().isEmpty || checkingWithAi
|
||||||
? null
|
? null
|
||||||
: () => _next(item, assisted: usedHelp),
|
: () => _next(item, assisted: usedHelp),
|
||||||
),
|
),
|
||||||
@@ -872,6 +908,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
|||||||
/// The answer passed the local check; the next tap records it.
|
/// The answer passed the local check; the next tap records it.
|
||||||
bool answerAccepted = false;
|
bool answerAccepted = false;
|
||||||
bool checkingWithAi = false;
|
bool checkingWithAi = false;
|
||||||
|
bool aiVerified = false;
|
||||||
WritingAiFeedback? aiCheck;
|
WritingAiFeedback? aiCheck;
|
||||||
String? aiCheckError;
|
String? aiCheckError;
|
||||||
|
|
||||||
@@ -925,7 +962,10 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _advance(GeneratedLesson lesson, GeneratedLessonTask task) {
|
Future<void> _advance(
|
||||||
|
GeneratedLesson lesson,
|
||||||
|
GeneratedLessonTask task,
|
||||||
|
) async {
|
||||||
final shortfall = adaptiveAnswerShortfall(task, controller.text);
|
final shortfall = adaptiveAnswerShortfall(task, controller.text);
|
||||||
final correct = shortfall == null;
|
final correct = shortfall == null;
|
||||||
if (!showReference && !correct) {
|
if (!showReference && !correct) {
|
||||||
@@ -934,6 +974,18 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
|||||||
}
|
}
|
||||||
// Show the verdict before moving on, so the learner sees it.
|
// Show the verdict before moving on, so the learner sees it.
|
||||||
if (!answerAccepted) {
|
if (!answerAccepted) {
|
||||||
|
if (correct &&
|
||||||
|
!showReference &&
|
||||||
|
(task.skill == 'writing' || task.skill == 'speaking')) {
|
||||||
|
final answerBeforeCheck = controller.text.trim();
|
||||||
|
final feedback = await _checkWithAi(task);
|
||||||
|
if (!mounted || controller.text.trim() != answerBeforeCheck) return;
|
||||||
|
if (feedback?.verdict == 'rewrite') {
|
||||||
|
setState(() => answerFeedback = '请根据 AI 反馈修改句子,再检查。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
aiVerified = feedback?.verdict == 'accepted';
|
||||||
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
answerAccepted = true;
|
answerAccepted = true;
|
||||||
answerFeedback = correct ? null : '$shortfall 已查看参考,本项按教学练习继续。';
|
answerFeedback = correct ? null : '$shortfall 已查看参考,本项按教学练习继续。';
|
||||||
@@ -944,7 +996,11 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
|||||||
lesson: lesson,
|
lesson: lesson,
|
||||||
task: task,
|
task: task,
|
||||||
rawAnswer: controller.text.trim(),
|
rawAnswer: controller.text.trim(),
|
||||||
assisted: showReference || aiCorrected,
|
assisted:
|
||||||
|
showReference ||
|
||||||
|
aiCorrected ||
|
||||||
|
((task.skill == 'writing' || task.skill == 'speaking') &&
|
||||||
|
!aiVerified),
|
||||||
correct: correct,
|
correct: correct,
|
||||||
inputMode: usedVoice && !transcriptEdited && transcriptConfirmed
|
inputMode: usedVoice && !transcriptEdited && transcriptConfirmed
|
||||||
? 'speechToText'
|
? 'speechToText'
|
||||||
@@ -988,16 +1044,17 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
|||||||
|
|
||||||
void _clearChecks() {
|
void _clearChecks() {
|
||||||
answerAccepted = false;
|
answerAccepted = false;
|
||||||
|
aiVerified = false;
|
||||||
aiCheck = null;
|
aiCheck = null;
|
||||||
aiCheckError = null;
|
aiCheckError = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _checkWithAi(GeneratedLessonTask task) async {
|
Future<WritingAiFeedback?> _checkWithAi(GeneratedLessonTask task) async {
|
||||||
final answer = controller.text.trim();
|
final answer = controller.text.trim();
|
||||||
if (answer.isEmpty || checkingWithAi) return;
|
if (answer.isEmpty || checkingWithAi) return null;
|
||||||
if (widget.state.aiProvider == AiProviderType.mock) {
|
if (widget.state.aiProvider == AiProviderType.mock) {
|
||||||
setState(() => aiCheckError = '请先在“我的”配置 AI 服务;本地检查仍可继续补练。');
|
setState(() => aiCheckError = '未配置 AI 服务;本次仅作教学练习。');
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
checkingWithAi = true;
|
checkingWithAi = true;
|
||||||
@@ -1016,18 +1073,17 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
|||||||
// The learner may have kept typing or moved on while the request ran.
|
// The learner may have kept typing or moved on while the request ran.
|
||||||
if (!mounted || controller.text.trim() != answer) {
|
if (!mounted || controller.text.trim() != answer) {
|
||||||
if (mounted) setState(() => checkingWithAi = false);
|
if (mounted) setState(() => checkingWithAi = false);
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
checkingWithAi = false;
|
checkingWithAi = false;
|
||||||
aiCheck = feedback;
|
aiCheck = feedback;
|
||||||
aiCheckError = feedback == null
|
aiCheckError = feedback == null ? '暂时无法获得 AI 检查结果;本次仅作教学练习。' : null;
|
||||||
? '暂时无法获得 AI 检查结果。你的答案保留在这里,可稍后重试或直接继续。'
|
|
||||||
: null;
|
|
||||||
if (feedback != null && feedback.verdict != 'accepted') {
|
if (feedback != null && feedback.verdict != 'accepted') {
|
||||||
aiCorrected = true;
|
aiCorrected = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return feedback;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -1248,20 +1304,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (task.skill == 'writing' || task.skill == 'speaking')
|
if (checkingWithAi) const Center(child: CircularProgressIndicator()),
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: controller.text.trim().isEmpty || checkingWithAi
|
|
||||||
? null
|
|
||||||
: () => _checkWithAi(task),
|
|
||||||
icon: checkingWithAi
|
|
||||||
? const SizedBox(
|
|
||||||
width: 16,
|
|
||||||
height: 16,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.spellcheck),
|
|
||||||
label: Text(checkingWithAi ? '正在检查…' : 'AI 检查语法和拼写(可选)'),
|
|
||||||
),
|
|
||||||
if (aiCheck != null)
|
if (aiCheck != null)
|
||||||
SectionCard(
|
SectionCard(
|
||||||
tint: aiCheck!.verdict == 'accepted'
|
tint: aiCheck!.verdict == 'accepted'
|
||||||
@@ -1276,7 +1319,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
|||||||
Text('参考改正:${aiCheck!.suggestion}'),
|
Text('参考改正:${aiCheck!.suggestion}'),
|
||||||
Text(
|
Text(
|
||||||
aiCheck!.verdict == 'accepted'
|
aiCheck!.verdict == 'accepted'
|
||||||
? 'AI 仅作参考;是否正确仍由本地检查判断。'
|
? 'AI 已检查;本地规则也会检查目标表达。'
|
||||||
: '看过改正后再继续,本项会记为带提示完成。',
|
: '看过改正后再继续,本项会记为带提示完成。',
|
||||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||||
),
|
),
|
||||||
@@ -1304,7 +1347,13 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
|||||||
Icon(Icons.check_circle, color: AppColors.green),
|
Icon(Icons.check_circle, color: AppColors.green),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
showReference || aiCorrected ? '正确(带提示完成)' : '答对了!',
|
showReference ||
|
||||||
|
aiCorrected ||
|
||||||
|
((task.skill == 'writing' ||
|
||||||
|
task.skill == 'speaking') &&
|
||||||
|
!aiVerified)
|
||||||
|
? '完成练习(不计独立掌握)'
|
||||||
|
: '答对了!',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.green,
|
color: AppColors.green,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -1346,7 +1395,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
|
|||||||
: index == lesson.tasks.length - 1
|
: index == lesson.tasks.length - 1
|
||||||
? '完成补练'
|
? '完成补练'
|
||||||
: '下一项',
|
: '下一项',
|
||||||
onPressed: controller.text.trim().isNotEmpty
|
onPressed: controller.text.trim().isNotEmpty && !checkingWithAi
|
||||||
? () => _advance(lesson, task)
|
? () => _advance(lesson, task)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -223,8 +223,9 @@ class _LearningShellState extends State<LearningShell> {
|
|||||||
return HomePage(
|
return HomePage(
|
||||||
state: widget.state,
|
state: widget.state,
|
||||||
onStartLesson: () {
|
onStartLesson: () {
|
||||||
if (widget.state.completedLessonIds
|
if (widget.state.completedLessonIds.contains(
|
||||||
.contains(widget.state.activeLessonId)) {
|
widget.state.activeLessonId,
|
||||||
|
)) {
|
||||||
final next = widget.state.nextIncompleteLessonId;
|
final next = widget.state.nextIncompleteLessonId;
|
||||||
if (next != null) widget.state.openLesson(next);
|
if (next != null) widget.state.openLesson(next);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,7 +179,8 @@ class _WordsPageState extends State<WordsPage> {
|
|||||||
),
|
),
|
||||||
// The sentence is the point of the feedback: a gloss alone is
|
// The sentence is the point of the feedback: a gloss alone is
|
||||||
// what did not stick the first time.
|
// what did not stick the first time.
|
||||||
if (WordBank.instance.exampleOf(question.id) case final sample?) ...[
|
if (WordBank.instance.exampleOf(question.id)
|
||||||
|
case final sample?) ...[
|
||||||
Text(
|
Text(
|
||||||
sample.en,
|
sample.en,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -278,7 +279,8 @@ class _WordsPageState extends State<WordsPage> {
|
|||||||
tint: AppColors.warm,
|
tint: AppColors.warm,
|
||||||
child: SpacedColumn(
|
child: SpacedColumn(
|
||||||
children: [
|
children: [
|
||||||
for (final id in shaky.take(8)) _WordRow(id: id, state: state),
|
for (final id in shaky.take(8))
|
||||||
|
_WordRow(id: id, state: state),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -334,7 +336,10 @@ class _WordRow extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
wordEnglish(id),
|
wordEnglish(id),
|
||||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
wordIpa(id).isEmpty
|
wordIpa(id).isEmpty
|
||||||
@@ -345,14 +350,11 @@ class _WordRow extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(switch (status) {
|
||||||
switch (status) {
|
|
||||||
WordStatus.newWord => '新学',
|
WordStatus.newWord => '新学',
|
||||||
WordStatus.recognized => '认识',
|
WordStatus.recognized => '认识',
|
||||||
WordStatus.familiar => '熟悉',
|
WordStatus.familiar => '熟悉',
|
||||||
},
|
}, style: Theme.of(context).textTheme.bodySmall),
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.volume_up_outlined, size: 20),
|
icon: const Icon(Icons.volume_up_outlined, size: 20),
|
||||||
onPressed: () => VoiceService.instance.speak(wordEnglish(id)),
|
onPressed: () => VoiceService.instance.speak(wordEnglish(id)),
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ import 'features/shell/learning_shell.dart';
|
|||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await SystemChrome.setPreferredOrientations([
|
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
|
||||||
DeviceOrientation.portraitUp,
|
|
||||||
]);
|
|
||||||
// A0 is the offline baseline; the later levels load with the app state.
|
// A0 is the offline baseline; the later levels load with the app state.
|
||||||
await CourseRepository.instance.load(levels: {'A0'});
|
await CourseRepository.instance.load(levels: {'A0'});
|
||||||
runApp(const KouyuEnglishApp());
|
runApp(const KouyuEnglishApp());
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ class UsageCard extends StatelessWidget {
|
|||||||
if (usage == null) return const SizedBox.shrink();
|
if (usage == null) return const SizedBox.shrink();
|
||||||
final lines = <({String label, String text})>[
|
final lines = <({String label, String text})>[
|
||||||
if (usage.when.isNotEmpty) (label: '什么时候用', text: usage.when),
|
if (usage.when.isNotEmpty) (label: '什么时候用', text: usage.when),
|
||||||
if (!compact && usage.reply.isNotEmpty) (label: '对方会怎么答', text: usage.reply),
|
if (!compact && usage.reply.isNotEmpty)
|
||||||
|
(label: '对方会怎么答', text: usage.reply),
|
||||||
if (!compact && usage.swap.isNotEmpty) (label: '换个说法', text: usage.swap),
|
if (!compact && usage.swap.isNotEmpty) (label: '换个说法', text: usage.swap),
|
||||||
// Mid-exercise only the nearest confusion is worth a line; the full
|
// Mid-exercise only the nearest confusion is worth a line; the full
|
||||||
// list belongs on the preview and lookup cards.
|
// list belongs on the preview and lookup cards.
|
||||||
@@ -57,10 +58,9 @@ class UsageCard extends StatelessWidget {
|
|||||||
for (final line in lines)
|
for (final line in lines)
|
||||||
RichText(
|
RichText(
|
||||||
text: TextSpan(
|
text: TextSpan(
|
||||||
style: DefaultTextStyle.of(context).style.copyWith(
|
style: DefaultTextStyle.of(
|
||||||
color: AppColors.warmInk,
|
context,
|
||||||
height: 1.45,
|
).style.copyWith(color: AppColors.warmInk, height: 1.45),
|
||||||
),
|
|
||||||
children: [
|
children: [
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: '${line.label}:',
|
text: '${line.label}:',
|
||||||
@@ -94,7 +94,10 @@ class SegmentUsage extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final ids = findLessonSegment(segmentId)?.targetItemIds ?? const <String>[];
|
final ids = findLessonSegment(segmentId)?.targetItemIds ?? const <String>[];
|
||||||
final explained = [for (final id in ids) if (coreUsage(id) != null) id];
|
final explained = [
|
||||||
|
for (final id in ids)
|
||||||
|
if (coreUsage(id) != null) id,
|
||||||
|
];
|
||||||
if (explained.isEmpty) return const SizedBox.shrink();
|
if (explained.isEmpty) return const SizedBox.shrink();
|
||||||
return SpacedColumn(
|
return SpacedColumn(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
|
|||||||
@@ -128,12 +128,12 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.enterText(find.byType(TextField), 'I am from Hong Kong.');
|
await tester.enterText(find.byType(TextField), 'I am from Hong Kong.');
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
final submit = find.text('我能独立回答');
|
final submit = find.text('检查并提交');
|
||||||
await tester.ensureVisible(submit);
|
await tester.ensureVisible(submit);
|
||||||
await tester.tap(submit);
|
await tester.tap(submit);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.text('答对了!'), findsOneWidget);
|
expect(find.text('完成练习(未计独立掌握)'), findsOneWidget);
|
||||||
expect(find.text('你的回答:I am from Hong Kong.'), findsOneWidget);
|
expect(find.text('你的回答:I am from Hong Kong.'), findsOneWidget);
|
||||||
expect(find.textContaining("参考说法:I'm from …"), findsOneWidget);
|
expect(find.textContaining("参考说法:I'm from …"), findsOneWidget);
|
||||||
await tester.tap(find.text('完成今天的复习'));
|
await tester.tap(find.text('完成今天的复习'));
|
||||||
@@ -208,7 +208,7 @@ void main() {
|
|||||||
await tester.ensureVisible(check);
|
await tester.ensureVisible(check);
|
||||||
await tester.tap(check);
|
await tester.tap(check);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.text('答对了!'), findsOneWidget);
|
expect(find.text('完成练习(不计独立掌握)'), findsOneWidget);
|
||||||
expect(find.text('AI 补练 · 1/4'), findsOneWidget);
|
expect(find.text('AI 补练 · 1/4'), findsOneWidget);
|
||||||
|
|
||||||
final next = find.text('下一项');
|
final next = find.text('下一项');
|
||||||
|
|||||||
@@ -757,13 +757,16 @@ void main() {
|
|||||||
isTrue,
|
isTrue,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
state.completeSpeaking();
|
state.completeSpeaking(practiced: true);
|
||||||
final speaking = state.attemptEvidence.where(
|
final speaking = state.attemptEvidence.where(
|
||||||
(entry) => entry.taskId == 'lesson-$segmentId-speaking',
|
(entry) => entry.taskId == 'lesson-$segmentId-speaking',
|
||||||
);
|
);
|
||||||
// Follow-reading is assisted practice for every target, never
|
// Follow-reading only credits core items contained in the model line.
|
||||||
// independent evidence for one of them.
|
expect(speaking.map((entry) => entry.itemId).toSet(), {
|
||||||
expect(speaking.map((entry) => entry.itemId), targets);
|
'A0-W36',
|
||||||
|
'A0-P01',
|
||||||
|
'A0-P03',
|
||||||
|
});
|
||||||
expect(
|
expect(
|
||||||
speaking.every((entry) => entry.outcome == EvidenceKind.assisted),
|
speaking.every((entry) => entry.outcome == EvidenceKind.assisted),
|
||||||
isTrue,
|
isTrue,
|
||||||
@@ -780,13 +783,44 @@ void main() {
|
|||||||
test('a first-try listening choice is recognition evidence', () {
|
test('a first-try listening choice is recognition evidence', () {
|
||||||
final state = AppState();
|
final state = AppState();
|
||||||
state.completePreview();
|
state.completePreview();
|
||||||
|
state.recordListeningPlayback();
|
||||||
state.completeListening(recognized: true);
|
state.completeListening(recognized: true);
|
||||||
|
|
||||||
for (final id in lessonById('a0-01').targetItemIds) {
|
expect(state.mastery['A0-P01']!.status, MasteryStatus.recognize);
|
||||||
expect(state.mastery[id]!.status, MasteryStatus.recognize);
|
expect(state.mastery['A0-P02']!.status, MasteryStatus.newItem);
|
||||||
|
expect(state.mastery['A0-P03']!.status, MasteryStatus.newItem);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('segment comprehension does not award unrelated core items', () {
|
||||||
|
final state = AppState()..setPlacementStartLesson('a0-09');
|
||||||
|
state.completePreview();
|
||||||
|
state.recordListeningPlayback();
|
||||||
|
state.completeListening(recognized: true);
|
||||||
|
|
||||||
|
final evidence = state.attemptEvidence.where(
|
||||||
|
(entry) => entry.taskId == 'lesson-a0-09-a-listening-check',
|
||||||
|
);
|
||||||
|
expect(evidence.single.itemId, 'segment:a0-09-a');
|
||||||
|
expect(evidence.single.skill, '听力理解');
|
||||||
|
for (final id in lessonById('a0-09').targetItemIds) {
|
||||||
|
expect(state.mastery[id]!.status, MasteryStatus.newItem);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a model sentence without a core link stays segment evidence', () {
|
||||||
|
final state = AppState()..setPlacementStartLesson('a0-02');
|
||||||
|
state.completePreview();
|
||||||
|
state.completeListening();
|
||||||
|
state.completeSpeaking(practiced: true);
|
||||||
|
|
||||||
|
final evidence = state.attemptEvidence.where(
|
||||||
|
(entry) => entry.taskId == 'lesson-a0-02-a-speaking',
|
||||||
|
);
|
||||||
|
expect(evidence.single.itemId, 'segment:a0-02-a');
|
||||||
|
expect(evidence.single.skill, '句子跟读');
|
||||||
|
expect(state.mastery['A0-P04']!.status, MasteryStatus.newItem);
|
||||||
|
});
|
||||||
|
|
||||||
test('scene dialogue credits taught items the learner actually used', () {
|
test('scene dialogue credits taught items the learner actually used', () {
|
||||||
final state = AppState();
|
final state = AppState();
|
||||||
state.completePreview();
|
state.completePreview();
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:kouyu_english/core/courses/courses.dart';
|
import 'package:kouyu_english/core/courses/courses.dart';
|
||||||
|
import 'package:kouyu_english/core/courses/course_pack.dart';
|
||||||
|
|
||||||
import '../tool/courses/course_validator.dart';
|
import '../tool/courses/course_validator.dart';
|
||||||
|
|
||||||
@@ -56,4 +58,16 @@ void main() {
|
|||||||
).validate();
|
).validate();
|
||||||
expect(report.errors.map((issue) => '$issue'), isEmpty);
|
expect(report.errors.map((issue) => '$issue'), isEmpty);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the course parser keeps item links and extra model sentences', () {
|
||||||
|
final unit =
|
||||||
|
jsonDecode(File('assets/courses/A1/A1-U01.json').readAsStringSync())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
final segment = PackSegment.fromJson(
|
||||||
|
(unit['segments'] as List).first as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
expect(segment.speakingItemIds, ['A1-U01-P01', 'A1-U01-P04']);
|
||||||
|
expect(segment.speakingExamples, isNotEmpty);
|
||||||
|
expect(segment.speakingChunks.join(' '), segment.speakingText);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,18 @@ void main() {
|
|||||||
expect(decision.validationSource, DialogueValidationSource.unavailable);
|
expect(decision.validationSource, DialogueValidationSource.unavailable);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('local-only free scene cannot grant independent mastery', () {
|
||||||
|
final decision = resolveDialogueTurnDecision(
|
||||||
|
local: LocalDialogueVerdict.accepted,
|
||||||
|
ai: null,
|
||||||
|
aiWasAttempted: false,
|
||||||
|
isFreeScene: true,
|
||||||
|
supportLevel: DialogueSupportLevel.none,
|
||||||
|
);
|
||||||
|
expect(decision.canAdvance, isTrue);
|
||||||
|
expect(decision.evidenceOutcome, EvidenceKind.pending);
|
||||||
|
});
|
||||||
|
|
||||||
test('explicit AI correction stops a local false positive', () {
|
test('explicit AI correction stops a local false positive', () {
|
||||||
const correction = DialogueAiIntervention(
|
const correction = DialogueAiIntervention(
|
||||||
accepted: false,
|
accepted: false,
|
||||||
|
|||||||
@@ -117,33 +117,49 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.text('独立完成'), findsOneWidget);
|
expect(find.text('提交独立尝试'), findsOneWidget);
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
find.byType(TextField),
|
find.byType(TextField),
|
||||||
'My name is Alex. Nice to meet you. I is happy.',
|
'My name is Alex. Nice to meet you. I is happy.',
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
final aiButton = find.text('提交前 AI 检查语法和拼写(可选)');
|
final submit = find.text('提交独立尝试');
|
||||||
await tester.ensureVisible(aiButton);
|
await tester.ensureVisible(submit);
|
||||||
await tester.tap(aiButton);
|
await tester.tap(submit);
|
||||||
await tester.runAsync(
|
await tester.runAsync(
|
||||||
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
expect(find.text('· I is 应为 I am'), findsOneWidget);
|
expect(find.text('· I is 应为 I am'), findsOneWidget);
|
||||||
final finish = find.text('带帮助完成');
|
expect(state.lessonStep, LessonStep.independent);
|
||||||
await tester.ensureVisible(finish);
|
await tester.enterText(
|
||||||
await tester.tap(finish);
|
find.byType(TextField),
|
||||||
|
'My name is Alex. Nice to meet you. I am happy.',
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.ensureVisible(submit);
|
||||||
|
await tester.tap(submit);
|
||||||
|
await tester.runAsync(
|
||||||
|
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||||
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
},
|
},
|
||||||
() => MockClient((request) async {
|
() => MockClient((request) async {
|
||||||
prompts.add(request.body);
|
prompts.add(request.body);
|
||||||
|
final accepted = jsonEncode({
|
||||||
|
'schemaVersion': 'writing-feedback-1',
|
||||||
|
'verdict': 'accepted',
|
||||||
|
'feedback': '表达正确。',
|
||||||
|
'suggestion': null,
|
||||||
|
'missing': <String>[],
|
||||||
|
'lessonId': segmentId,
|
||||||
|
});
|
||||||
return http.Response(
|
return http.Response(
|
||||||
jsonEncode({
|
jsonEncode({
|
||||||
'choices': [
|
'choices': [
|
||||||
{
|
{
|
||||||
'message': {'content': check},
|
'message': {'content': prompts.length == 1 ? check : accepted},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
@@ -153,8 +169,221 @@ void main() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(prompts.single, contains('I is happy'));
|
expect(prompts, hasLength(2));
|
||||||
|
expect(prompts.first, contains('I is happy'));
|
||||||
expect(state.lessonStep, LessonStep.complete);
|
expect(state.lessonStep, LessonStep.complete);
|
||||||
expect(state.independentAttemptAssisted, isTrue);
|
expect(state.independentAttemptAssisted, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('writing submission runs AI check without a separate button', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
AiService.instance.setFallbackApiKey('test-key');
|
||||||
|
final state = AppState()
|
||||||
|
..aiProvider = AiProviderType.compatible
|
||||||
|
..aiEndpoint = 'https://api.deepseek.com'
|
||||||
|
..aiModel = 'deepseek-flash'
|
||||||
|
..lessonStep = LessonStep.writing;
|
||||||
|
var requests = 0;
|
||||||
|
await http.runWithClient(
|
||||||
|
() async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: LessonFlow(
|
||||||
|
state: state,
|
||||||
|
onOpenDialogue: () {},
|
||||||
|
onFinish: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('收起示例,自己试一次'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.enterText(find.byType(TextField), 'Hello. I am Alex.');
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('获取 AI 写作建议(可选)'), findsNothing);
|
||||||
|
final submit = find.text('提交并继续');
|
||||||
|
await tester.ensureVisible(submit);
|
||||||
|
await tester.tap(submit);
|
||||||
|
await tester.runAsync(
|
||||||
|
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
},
|
||||||
|
() => MockClient((request) async {
|
||||||
|
requests++;
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'choices': [
|
||||||
|
{
|
||||||
|
'message': {
|
||||||
|
'content': jsonEncode({
|
||||||
|
'schemaVersion': 'writing-feedback-1',
|
||||||
|
'verdict': 'accepted',
|
||||||
|
'feedback': '表达正确。',
|
||||||
|
'suggestion': null,
|
||||||
|
'missing': <String>[],
|
||||||
|
'lessonId': 'a0-01',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(requests, 1);
|
||||||
|
expect(state.lessonStep, LessonStep.dialogue);
|
||||||
|
expect(
|
||||||
|
state.attemptEvidence
|
||||||
|
.where((entry) => entry.taskId == 'lesson-a0-01-a-writing')
|
||||||
|
.every((entry) => entry.outcome == EvidenceKind.assisted),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a locally matching bad sentence waits for AI correction', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
AiService.instance.setFallbackApiKey('test-key');
|
||||||
|
final state = AppState()
|
||||||
|
..aiProvider = AiProviderType.compatible
|
||||||
|
..aiEndpoint = 'https://api.deepseek.com'
|
||||||
|
..aiModel = 'deepseek-flash'
|
||||||
|
..lessonStep = LessonStep.writing;
|
||||||
|
await http.runWithClient(
|
||||||
|
() async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: LessonFlow(
|
||||||
|
state: state,
|
||||||
|
onOpenDialogue: () {},
|
||||||
|
onFinish: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.enterText(find.byType(TextField), 'Hello. I am is Alex.');
|
||||||
|
await tester.pump();
|
||||||
|
final submit = find.text('提交并继续');
|
||||||
|
await tester.ensureVisible(submit);
|
||||||
|
await tester.tap(submit);
|
||||||
|
await tester.runAsync(
|
||||||
|
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
},
|
||||||
|
() => MockClient(
|
||||||
|
(_) async => http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'choices': [
|
||||||
|
{
|
||||||
|
'message': {
|
||||||
|
'content': jsonEncode({
|
||||||
|
'schemaVersion': 'writing-feedback-1',
|
||||||
|
'verdict': 'rewrite',
|
||||||
|
'feedback': '这里多了 is。',
|
||||||
|
'suggestion': 'Hello. I am Alex.',
|
||||||
|
'missing': ['删去多余的 is'],
|
||||||
|
'lessonId': 'a0-01',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(state.lessonStep, LessonStep.writing);
|
||||||
|
expect(find.text('AI 建议:这里多了 is。'), findsOneWidget);
|
||||||
|
expect(find.text('请按自己的意思修改,再提交检查。看过改正后,本次只算辅助练习。'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('independent submission without AI stays unverified', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final state = AppState()..lessonStep = LessonStep.independent;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: LessonFlow(state: state, onOpenDialogue: () {}, onFinish: () {}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byType(TextField),
|
||||||
|
'My name is Alex. Nice to meet you.',
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
final submit = find.text('提交独立尝试');
|
||||||
|
await tester.ensureVisible(submit);
|
||||||
|
await tester.tap(submit);
|
||||||
|
await tester.pump();
|
||||||
|
expect(state.lessonStep, LessonStep.complete);
|
||||||
|
expect(state.independentAttemptPending, isTrue);
|
||||||
|
expect(
|
||||||
|
state.attemptEvidence
|
||||||
|
.where((entry) => entry.taskId == 'lesson-a0-01-a-independent')
|
||||||
|
.every((entry) => entry.outcome == EvidenceKind.pending),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(find.textContaining('未经 AI 核验'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unverified completion survives a local snapshot', () {
|
||||||
|
final first = AppState()
|
||||||
|
..completeIndependentAttempt(
|
||||||
|
assisted: false,
|
||||||
|
verified: false,
|
||||||
|
rawAnswer: 'My name is Alex. Nice to meet you.',
|
||||||
|
);
|
||||||
|
final resumed = AppState()..restoreForTest(first.snapshotForTest());
|
||||||
|
expect(resumed.independentAttemptPending, isTrue);
|
||||||
|
expect(resumed.independentAttemptComplete, isTrue);
|
||||||
|
expect(
|
||||||
|
resumed.attemptEvidence
|
||||||
|
.where((entry) => entry.taskId == 'lesson-a0-01-a-independent')
|
||||||
|
.every((entry) => entry.outcome == EvidenceKind.pending),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('AI outage offers an unverified continuation', (tester) async {
|
||||||
|
AiService.instance.setFallbackApiKey('test-key');
|
||||||
|
final state = AppState()
|
||||||
|
..aiProvider = AiProviderType.compatible
|
||||||
|
..aiEndpoint = 'https://api.deepseek.com'
|
||||||
|
..aiModel = 'deepseek-flash'
|
||||||
|
..lessonStep = LessonStep.independent;
|
||||||
|
await http.runWithClient(() async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: LessonFlow(
|
||||||
|
state: state,
|
||||||
|
onOpenDialogue: () {},
|
||||||
|
onFinish: () {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.enterText(
|
||||||
|
find.byType(TextField),
|
||||||
|
'My name is Alex. Nice to meet you.',
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
final submit = find.text('提交独立尝试');
|
||||||
|
await tester.ensureVisible(submit);
|
||||||
|
await tester.tap(submit);
|
||||||
|
await tester.runAsync(
|
||||||
|
() => Future<void>.delayed(const Duration(milliseconds: 50)),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(state.lessonStep, LessonStep.independent);
|
||||||
|
final fallback = find.text('未核验,保存练习并继续');
|
||||||
|
await tester.ensureVisible(fallback);
|
||||||
|
await tester.tap(fallback);
|
||||||
|
await tester.pump();
|
||||||
|
}, () => MockClient((_) async => http.Response('{}', 503)));
|
||||||
|
expect(state.lessonStep, LessonStep.complete);
|
||||||
|
expect(state.independentAttemptPending, isTrue);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:kouyu_english/core/app_state.dart';
|
||||||
|
import 'package:kouyu_english/core/models.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test(
|
||||||
|
'listening help survives resume and cannot earn independent evidence',
|
||||||
|
() {
|
||||||
|
final first = AppState()..lessonStep = LessonStep.listening;
|
||||||
|
first.recordListeningPlayback(slow: true);
|
||||||
|
first.revealListeningTranscript();
|
||||||
|
first.recordListeningMiss();
|
||||||
|
|
||||||
|
final resumed = AppState()..restoreForTest(first.snapshotForTest());
|
||||||
|
expect(resumed.listeningAudioPlayed, isTrue);
|
||||||
|
expect(resumed.listeningSupport, ListeningSupport.transcript);
|
||||||
|
expect(resumed.listeningAttempts, 1);
|
||||||
|
|
||||||
|
resumed.completeListening(recognized: true);
|
||||||
|
final evidence = resumed.attemptEvidence
|
||||||
|
.where((item) => item.taskId.endsWith('listening-check'))
|
||||||
|
.toList();
|
||||||
|
expect(evidence, isNotEmpty);
|
||||||
|
expect(
|
||||||
|
evidence.every((item) => item.outcome == EvidenceKind.assisted),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(evidence.first.listeningSupport, ListeningSupport.transcript);
|
||||||
|
expect(evidence.first.firstTry, isFalse);
|
||||||
|
expect(evidence.first.attemptCount, 2);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('unaided first listening answer earns recognition evidence', () {
|
||||||
|
final state = AppState()..lessonStep = LessonStep.listening;
|
||||||
|
state.recordListeningPlayback();
|
||||||
|
state.completeListening(recognized: true);
|
||||||
|
final evidence = state.attemptEvidence
|
||||||
|
.where((item) => item.taskId.endsWith('listening-check'))
|
||||||
|
.toList();
|
||||||
|
expect(evidence, isNotEmpty);
|
||||||
|
expect(
|
||||||
|
evidence.every((item) => item.outcome == EvidenceKind.independentSuccess),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(evidence.first.firstTry, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a choice without playable audio cannot earn listening recognition', () {
|
||||||
|
final state = AppState()..lessonStep = LessonStep.listening;
|
||||||
|
state.completeListening(recognized: true);
|
||||||
|
final evidence = state.attemptEvidence
|
||||||
|
.where((item) => item.taskId.endsWith('listening-check'))
|
||||||
|
.toList();
|
||||||
|
expect(
|
||||||
|
evidence.every((item) => item.outcome == EvidenceKind.assisted),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'TTS failure survives resume and gives only assisted listening credit',
|
||||||
|
() {
|
||||||
|
final state = AppState()..lessonStep = LessonStep.listening;
|
||||||
|
state.recordListeningAudioUnavailable();
|
||||||
|
final resumed = AppState()..restoreForTest(state.snapshotForTest());
|
||||||
|
expect(resumed.listeningAudioUnavailable, isTrue);
|
||||||
|
expect(resumed.listeningSupport, ListeningSupport.transcript);
|
||||||
|
resumed.completeListening(recognized: true);
|
||||||
|
expect(resumed.lessonStep, LessonStep.speaking);
|
||||||
|
expect(resumed.listeningAudioUnavailable, isFalse);
|
||||||
|
expect(
|
||||||
|
resumed.attemptEvidence
|
||||||
|
.where((entry) => entry.taskId.endsWith('listening-check'))
|
||||||
|
.every((entry) => entry.outcome == EvidenceKind.assisted),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('skipping follow-reading does not create speaking evidence', () {
|
||||||
|
final state = AppState()..lessonStep = LessonStep.speaking;
|
||||||
|
state.completeSpeaking();
|
||||||
|
expect(state.lessonStep, LessonStep.reading);
|
||||||
|
expect(
|
||||||
|
state.attemptEvidence.where(
|
||||||
|
(entry) => entry.taskId.endsWith('-speaking'),
|
||||||
|
),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the fourth checkpoint keeps its 30-day audit interval', () {
|
||||||
|
final state = AppState();
|
||||||
|
state.reviewQueue.add(
|
||||||
|
ReviewItem(
|
||||||
|
id: 'A0-P02',
|
||||||
|
target: "What's your name?",
|
||||||
|
prompt: '问名字',
|
||||||
|
hint: "What's your name?",
|
||||||
|
dueAt: DateTime.now().subtract(const Duration(days: 1)),
|
||||||
|
skill: '回忆表达',
|
||||||
|
successfulReviews: 3,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
state.completeReview(
|
||||||
|
state.reviewQueue.single,
|
||||||
|
assisted: false,
|
||||||
|
firstTry: true,
|
||||||
|
attemptCount: 1,
|
||||||
|
rawAnswer: "What's your name?",
|
||||||
|
);
|
||||||
|
final next = state.reviewQueue.single;
|
||||||
|
expect(next.successfulReviews, 4);
|
||||||
|
expect(
|
||||||
|
next.dueAt.difference(DateTime.now()).inDays,
|
||||||
|
inInclusiveRange(29, 30),
|
||||||
|
);
|
||||||
|
expect(state.attemptEvidence.last.firstTry, isTrue);
|
||||||
|
expect(state.attemptEvidence.last.attemptCount, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('later clean mastery audits lengthen to 45 days', () {
|
||||||
|
final state = AppState();
|
||||||
|
state.reviewQueue.add(
|
||||||
|
ReviewItem(
|
||||||
|
id: 'A0-P02',
|
||||||
|
target: "What's your name?",
|
||||||
|
prompt: '问名字',
|
||||||
|
hint: "What's your name?",
|
||||||
|
dueAt: DateTime.now().subtract(const Duration(days: 1)),
|
||||||
|
skill: '回忆表达',
|
||||||
|
successfulReviews: 4,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
state.completeReview(
|
||||||
|
state.reviewQueue.single,
|
||||||
|
assisted: false,
|
||||||
|
firstTry: true,
|
||||||
|
attemptCount: 1,
|
||||||
|
rawAnswer: "What's your name?",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
state.reviewQueue.single.dueAt.difference(DateTime.now()).inDays,
|
||||||
|
inInclusiveRange(44, 45),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'slow dictation cannot advance a checkpoint even if caller says independent',
|
||||||
|
() {
|
||||||
|
final state = AppState();
|
||||||
|
state.reviewQueue.add(
|
||||||
|
ReviewItem(
|
||||||
|
id: 'A0-P02',
|
||||||
|
target: "What's your name?",
|
||||||
|
prompt: '听写',
|
||||||
|
hint: "What's your name?",
|
||||||
|
dueAt: DateTime.now().subtract(const Duration(days: 1)),
|
||||||
|
skill: '听写',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
state.completeReview(
|
||||||
|
state.reviewQueue.single,
|
||||||
|
assisted: false,
|
||||||
|
firstTry: true,
|
||||||
|
attemptCount: 1,
|
||||||
|
listeningSupport: ListeningSupport.slow,
|
||||||
|
rawAnswer: "What's your name?",
|
||||||
|
);
|
||||||
|
expect(state.reviewQueue.single.successfulReviews, 0);
|
||||||
|
expect(state.attemptEvidence.last.outcome, EvidenceKind.assisted);
|
||||||
|
expect(
|
||||||
|
state.attemptEvidence.last.listeningSupport,
|
||||||
|
ListeningSupport.slow,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user