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:
shenlei
2026-09-20 23:54:17 +09:00
co-authored by Claude Opus 5
parent 1a10ca88e0
commit 6b42b7abc3
52 changed files with 4044 additions and 82 deletions
@@ -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),
],
),
),