feat: 独立词库、三向认词与当日快闪复习
## 独立词库 理解词原先只能跟着课程单元走,学完 A0 十课词汇量只增加约 22 个实词, 不足以解决"记不住单词"。新增一份独立词库 assets/words/wordbank.json (2748 词,A1–B1),挂进 receptiveWordRegistry 的合成单元 bank-A1/A2/B1, 完全复用理解词已有的状态机,不依赖课程进度,第一天就能用。 数据来源、许可与合成规则记在 tool/words/DATA-NOTE.md:CEFR-J 定等级、 公开词书提供音标、AI 重写全部释义并生成例句、OpenSubtitles 提供口语词频。 词书部分为 CC BY-NC-SA 4.0 且上游权利不明,仅供个人非商用; 若要分发或上架,须替换音标那一列。 ## 背单词机制 - 间隔阶梯 1/3/7/15/30/60/120 天,连续答对上一级,答错回第一级。 原先首次答对后要等 7 天才复习,正是"第二天就忘"的成因。 - 每日新词上限(10 分钟 8 个 / 20 分钟 15 个 / 30 分钟 20 个)。 阶梯第一级是次日,今天引入的新词就是明天的工作量。 - 新词按口语频率发放,不再按字母序 —— A1 从 a.m./ability 变成 no/not/know/just。 - 三个方向按层级轮转:看词(英→中)→ 听词(音→中)→ 想词(中→英)。 想词题仍是选择题,不要求产出,理解词定位不变,不进升级分母。 - 单词页独立成 tab,首页今日任务卡下方给一张认词入口卡。 ## 用法对照 课程 JSON 增加 usage 字段(when/reply/swap/confuse):一个句型用在什么场合、 对方通常怎么答、还能怎么说、跟哪个学过的句型容易混。 知道 How are you? 的意思,不等于知道它不是用来问名字的。 ## 复习流 - 当日快闪(recap)独立成队列,不占复习预算,也不计入积压。 - 只发放当日预算内的量,其余保持到期状态等下次,不悄悄丢弃或改期。 - 答错的项隔几题后回来,而不是立刻重问。 测试 296 通过,flutter analyze 干净。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import '../../core/courses/courses.dart';
|
||||
import '../../core/speech_compare.dart';
|
||||
import '../../core/voice_service.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
import '../../widgets/usage_card.dart';
|
||||
import '../../widgets/voice_answer.dart';
|
||||
|
||||
class ReviewPage extends StatefulWidget {
|
||||
@@ -50,6 +51,15 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
({String answer, String reference, String? audio, bool assisted})? lastResult;
|
||||
bool dictationPlayed = false;
|
||||
|
||||
/// Items whose recognition question has been answered in this session. The
|
||||
/// gate is asked once per item; the production task follows immediately.
|
||||
final Set<String> gateCleared = {};
|
||||
|
||||
/// The item whose contrast question is being shown after a miss, and the
|
||||
/// option picked, if any. Set only while the remediation is on screen.
|
||||
ReviewItem? contrastFor;
|
||||
String? contrastPicked;
|
||||
|
||||
@override
|
||||
AppState get voiceState => widget.state;
|
||||
|
||||
@@ -156,10 +166,204 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
});
|
||||
}
|
||||
|
||||
/// Options for the recognition question: the meaning plus distractors from
|
||||
/// the same unit. The answer's position is stable per item so it does not
|
||||
/// move while the learner reads, but it is not always first.
|
||||
List<String> _recognitionOptions(String id, String answer) {
|
||||
final options = [answer, ...recognitionDistractors(id)];
|
||||
final offset = id.hashCode.abs() % options.length;
|
||||
return [...options.skip(offset), ...options.take(offset)];
|
||||
}
|
||||
|
||||
void _answerGate(ReviewItem item, {required bool correct}) {
|
||||
widget.state.recordRecognitionGate(item, correct: correct);
|
||||
setState(() {
|
||||
gateCleared.add(item.id);
|
||||
// Being shown the meaning is help, so finishing the production step
|
||||
// afterwards is recorded as assisted.
|
||||
if (!correct) usedHelp = true;
|
||||
});
|
||||
if (!correct) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'它的意思是:${coreRecognitionQuestion(item.id).answer}。下一步试着写出英文。',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Step one of an item's first checkpoint: hear or read it and pick what it
|
||||
/// means. Recall of the English comes right after. Learning engine 3.1 puts
|
||||
/// 「认识」 before 「可回忆」, so the day after a lesson opens with a question
|
||||
/// the learner can answer instead of the hardest one.
|
||||
Widget _recognitionView(ReviewItem item) {
|
||||
final question = coreRecognitionQuestion(item.id);
|
||||
final answer = question.answer;
|
||||
final english = question.shown;
|
||||
final audio = english;
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Eyebrow('今天复习 · ${widget.state.reviewSession.length} 项待完成'),
|
||||
Text(
|
||||
'先认出来:它是什么意思?',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
Text(
|
||||
'第 1 步,共 2 步 · 认出来 → 说出来',
|
||||
style: TextStyle(color: AppColors.green, fontSize: 13),
|
||||
),
|
||||
SectionCard(
|
||||
tint: AppColors.softGreen,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Text(english, style: const TextStyle(fontSize: 22)),
|
||||
Row(
|
||||
children: [
|
||||
IconButton.filled(
|
||||
tooltip: '播放',
|
||||
onPressed: () => _play(audio),
|
||||
icon: const Icon(Icons.volume_up_outlined),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
TextButton(
|
||||
onPressed: () => _play(audio, slow: true),
|
||||
child: const Text('慢速'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final option in _recognitionOptions(item.id, answer))
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: () => _answerGate(item, correct: option == answer),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Text(option, style: const TextStyle(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _answerGate(item, correct: false),
|
||||
child: const Text('不认识'),
|
||||
),
|
||||
Text(
|
||||
'选错或选「不认识」都不算答错,会直接告诉你意思,再一起练说出来。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _reportMiss(ReviewItem item) {
|
||||
widget.state.reportReviewFailure(item);
|
||||
// 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
|
||||
// and not the situation.
|
||||
if (coreContrastQuestion(item.id, isTaught: widget.state.mastery.containsKey) != null) {
|
||||
setState(() {
|
||||
_resetAnswer();
|
||||
contrastFor = item;
|
||||
contrastPicked = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(_resetAnswer);
|
||||
}
|
||||
|
||||
void _answerContrast(ReviewItem item, String picked, String answer) {
|
||||
widget.state.recordContrastAnswer(item, correct: picked == answer);
|
||||
setState(() => contrastPicked = picked);
|
||||
}
|
||||
|
||||
/// The remediation after a miss: the usage note, then the situation with the
|
||||
/// taught sentences to choose between. It is teaching, not a checkpoint —
|
||||
/// the miss is already recorded and answering here cannot undo it.
|
||||
Widget _contrastView(ReviewItem item) {
|
||||
final question = coreContrastQuestion(
|
||||
item.id,
|
||||
isTaught: widget.state.mastery.containsKey,
|
||||
)!;
|
||||
final picked = contrastPicked;
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Eyebrow('先弄清楚什么时候用'),
|
||||
Text(
|
||||
'这句话用在这种情况:',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
UsageCard(itemId: item.id, state: widget.state),
|
||||
SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
child: Text(question.situation, style: const TextStyle(fontSize: 17)),
|
||||
),
|
||||
const Text('这时候该说哪一句?'),
|
||||
for (final option in question.options)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: picked == null
|
||||
? () => _answerContrast(item, option, question.answer)
|
||||
: null,
|
||||
style: picked == null
|
||||
? null
|
||||
: OutlinedButton.styleFrom(
|
||||
backgroundColor: option == question.answer
|
||||
? AppColors.softGreen
|
||||
: null,
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Text(option, style: const TextStyle(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (picked != null) ...[
|
||||
SectionCard(
|
||||
tint: picked == question.answer
|
||||
? AppColors.softGreen
|
||||
: AppColors.warm,
|
||||
child: Text(
|
||||
picked == question.answer
|
||||
? '对,就是这句。${question.note}'
|
||||
: '这里要说 ${question.answer}。${question.note}',
|
||||
style: TextStyle(color: AppColors.warmInk, height: 1.45),
|
||||
),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: '继续复习',
|
||||
onPressed: () => setState(() {
|
||||
contrastFor = null;
|
||||
contrastPicked = null;
|
||||
}),
|
||||
),
|
||||
],
|
||||
Text(
|
||||
'这一步不算检查点,也不会影响刚才那道题的结果。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _resultView(
|
||||
({String answer, String reference, String? audio, bool assisted}) result,
|
||||
) {
|
||||
final remaining = widget.state.dueReviews.length;
|
||||
final remaining = widget.state.reviewSession.length;
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
@@ -311,11 +515,12 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (contrastFor case final item?) return _contrastView(item);
|
||||
if (lastResult case final result?) return _resultView(result);
|
||||
final item = widget.state.dueReviews.isEmpty
|
||||
? null
|
||||
: widget.state.dueReviews.first;
|
||||
final session = widget.state.reviewSession;
|
||||
final item = session.isEmpty ? null : session.first;
|
||||
if (item == null) {
|
||||
final postponed = widget.state.postponedReviewCount;
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
@@ -324,12 +529,20 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
'到期项目已安排下次复练。',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const Text('记住不是一次答对就结束;系统会在不同间隔再次确认你仍能用出来。'),
|
||||
Text(
|
||||
postponed > 0
|
||||
? '今天的复习时间用完了,还有 $postponed 项到期,明天接着做,没有被丢掉。'
|
||||
: '记住不是一次答对就结束;系统会在不同间隔再次确认你仍能用出来。',
|
||||
),
|
||||
PrimaryButton(label: '回到首页', onPressed: widget.onFinished),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (widget.state.needsRecognitionGate(item) &&
|
||||
!gateCleared.contains(item.id)) {
|
||||
return _recognitionView(item);
|
||||
}
|
||||
final dictation = item.skill == dictationSkill
|
||||
? coreDictationSentences[item.id]
|
||||
: null;
|
||||
@@ -337,17 +550,33 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
final audioText = reviewAudioText(item.id, item.target);
|
||||
final checkpoint =
|
||||
widget.state.mastery[item.id]?.checkpoint ?? item.successfulReviews;
|
||||
final checkpointLabel = checkpoint >= 4
|
||||
final isRecap = item.kind == ReviewKind.recap;
|
||||
final checkpointLabel = isRecap
|
||||
? '不计检查点 · 只是趁热再想一遍'
|
||||
: checkpoint >= 4
|
||||
? '30 天抽查'
|
||||
: '第 ${checkpoint + 1} / 4 个间隔检查点';
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Eyebrow('今天复习 · ${widget.state.dueReviewCount} 项待完成'),
|
||||
Eyebrow(
|
||||
isRecap
|
||||
? '再想一遍 · 还有 ${widget.state.reviewSession.length} 项'
|
||||
: '今天复习 · ${widget.state.reviewSession.length} 项待完成',
|
||||
),
|
||||
Text(
|
||||
dictation != null ? '听一听,写下来。' : '不看答案,试着回答。',
|
||||
dictation != null
|
||||
? '听一听,写下来。'
|
||||
: isRecap
|
||||
? '刚学过的,现在还想得起来吗?'
|
||||
: '不看答案,试着回答。',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
if (isRecap)
|
||||
Text(
|
||||
'答不出也不算错,看一眼答案就好——这一次只是帮你记住,真正的检查在明天。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
Text(
|
||||
'目标技能:${item.skill}',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
@@ -465,10 +694,7 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
),
|
||||
ActionChip(
|
||||
label: const Text('暂时想不起来'),
|
||||
onPressed: () {
|
||||
widget.state.reportReviewFailure(item);
|
||||
setState(_resetAnswer);
|
||||
},
|
||||
onPressed: () => _reportMiss(item),
|
||||
),
|
||||
ActionChip(
|
||||
label: Text(generatingVariant ? '正在生成…' : '生成变式'),
|
||||
@@ -509,6 +735,7 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
icon: const Icon(Icons.volume_up_outlined),
|
||||
label: Text('听示范:$audioText'),
|
||||
),
|
||||
UsageCard(itemId: item.id, state: widget.state),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user