Files
English/kouyu_english/lib/features/lesson/lesson_flow.dart
T
shenleiandClaude Opus 5 6b42b7abc3 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>
2026-09-20 23:54:17 +09:00

421 lines
13 KiB
Dart

import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/ai_service.dart';
import '../../core/app_theme.dart';
import '../../core/models.dart';
import '../../core/courses/courses.dart';
import '../../core/courses/course_pack.dart';
import '../../core/courses/course_repository.dart';
import '../../core/speech_compare.dart';
import '../../core/voice_service.dart';
import '../../core/writing_feedback.dart';
import '../../widgets/app_widgets.dart';
import '../../widgets/lexicon_lookup.dart';
import '../../widgets/usage_card.dart';
import '../../widgets/voice_answer.dart';
part 'steps/independent_step.dart';
part 'steps/listening_step.dart';
part 'steps/material_step.dart';
part 'steps/preview_step.dart';
part 'steps/reading_step.dart';
part 'steps/speaking_step.dart';
part 'steps/writing_step.dart';
class LessonFlow extends StatefulWidget {
const LessonFlow({
super.key,
required this.state,
required this.onOpenDialogue,
required this.onFinish,
});
final AppState state;
final VoidCallback onOpenDialogue;
final VoidCallback onFinish;
@override
State<LessonFlow> createState() => _LessonFlowState();
}
class _LessonFlowState extends State<LessonFlow> {
final writingController = TextEditingController();
final independentController = TextEditingController();
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;
bool listeningAudioPlayed = false;
bool showWritingHelp = true;
bool showIndependentHelp = false;
LessonSegment get segment {
final lesson = lessonById(widget.state.activeLessonId);
return lesson.segments[widget.state.activeSegmentIndexFor(lesson.id)];
}
LessonActivity get activity =>
activityBySegmentId(segment.id);
List<VocabularyItem> get previewItems =>
vocabularyBySegmentId(segment.id);
@override
void initState() {
super.initState();
widget.state.addListener(_onStateChange);
writingController.text = widget.state.lessonWritingDraft;
independentController.text = widget.state.independentAttemptDraft;
previewIndex = widget.state.previewIndex
.clamp(0, previewItems.length - 1)
.toInt();
}
@override
void dispose() {
widget.state.removeListener(_onStateChange);
writingController.dispose();
independentController.dispose();
super.dispose();
}
void _onStateChange() {
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
final lesson = lessonById(widget.state.activeLessonId);
final Widget content;
switch (widget.state.lessonStep) {
case LessonStep.preview:
content = _PreviewStep(
state: widget.state,
item: previewItems[previewIndex],
position: previewIndex + 1,
total: previewItems.length,
onLookup: () => showLexiconLookup(
context,
state: widget.state,
initialText: previewItems[previewIndex].word,
),
onNext: () {
if (previewIndex < previewItems.length - 1) {
setState(() => previewIndex += 1);
widget.state.setPreviewIndex(previewIndex);
} else {
widget.state.completePreview();
}
},
onSkip: widget.state.completePreview,
);
case LessonStep.listening:
final listeningOptions = shuffledOptions(
activity.answers,
'${activity.listening}-listening',
);
content = _ListeningStep(
state: widget.state,
activity: activity,
options: listeningOptions,
correctAnswer: activity.answers.first,
selectedAnswer: selectedAnswer,
audioPlayed: listeningAudioPlayed,
missed: listeningMissed,
onSelected: (value) => setState(() {
selectedAnswer = value;
if (listeningOptions[value] != activity.answers.first) {
listeningMissed = true;
}
}),
onPlayed: () => setState(() => listeningAudioPlayed = true),
onLookup: () {
setState(() => listeningMissed = true);
showLexiconLookup(
context,
state: widget.state,
initialText: activity.listening,
);
},
onContinue:
selectedAnswer >= 0 &&
listeningOptions[selectedAnswer] == activity.answers.first
? () =>
widget.state.completeListening(recognized: !listeningMissed)
: null,
);
case LessonStep.speaking:
content = _SpeakingStep(
state: widget.state,
segmentId: segment.id,
text: activity.speaking,
tip: activity.speakingTip,
keepRecording: widget.state.keepRecordings,
onContinue: widget.state.completeSpeaking,
);
case LessonStep.reading:
content = _ReadingStep(
state: widget.state,
activity: activity,
onLookup: () => showLexiconLookup(
context,
state: widget.state,
initialText: activity.reading,
),
onContinue: widget.state.completeReading,
);
case LessonStep.material:
content = _MaterialStep(
state: widget.state,
materials:
CourseRepository.instance
.unitById(widget.state.activeLessonId)
?.materials ??
const [],
onContinue: widget.state.completeMaterial,
);
case LessonStep.writing:
content = _WritingStep(
state: widget.state,
lessonId: widget.state.activeLessonId,
segmentId: segment.id,
activity: activity,
controller: writingController,
showHelp: showWritingHelp,
canContinue: writingController.text.trim().isNotEmpty,
onChanged: () {
widget.state.setLessonWritingDraft(writingController.text);
setState(() {});
},
onToggleHelp: () =>
setState(() => showWritingHelp = !showWritingHelp),
onContinue: (usedAiFeedback) => widget.state.completeWriting(
assisted: showWritingHelp || usedAiFeedback,
rawAnswer: writingController.text.trim(),
),
);
case LessonStep.dialogue:
content = _DialoguePendingStep(onOpenDialogue: widget.onOpenDialogue);
case LessonStep.independent:
content = _IndependentStep(
state: widget.state,
lessonId: lesson.id,
segmentId: segment.id,
keepRecording: widget.state.keepRecordings,
activity: activity,
controller: independentController,
showHelp: showIndependentHelp,
canContinue: independentController.text.trim().isNotEmpty,
onChanged: () {
widget.state.setIndependentAttemptDraft(independentController.text);
setState(() {});
},
onNeedHelp: () => setState(() => showIndependentHelp = true),
onLookup: () {
setState(() => showIndependentHelp = true);
showLexiconLookup(
context,
state: widget.state,
initialText: activity.independentPrompt,
);
},
onContinue: (spoken, recordingPath, aiCorrected) =>
widget.state.completeIndependentAttempt(
assisted: showIndependentHelp || aiCorrected,
spoken: spoken,
rawAnswer: independentController.text.trim(),
recordingPath: recordingPath,
),
onLater: () =>
widget.state.completeIndependentAttempt(assisted: true),
);
case LessonStep.complete:
content = _CompletionStep(
assisted: widget.state.independentAttemptAssisted,
isFinalSegment:
widget.state.activeSegmentIndexFor(lesson.id) ==
lesson.segments.length - 1,
nextSegmentNumber: widget.state.activeSegmentIndexFor(lesson.id) + 2,
onFinish: _finishOrExit,
);
}
return _LessonScope(
title:
'第 ${lesson.number} 课 · ${lesson.title} · 第 ${widget.state.activeSegmentIndexFor(lesson.id) + 1}/${lesson.segments.length} 段',
onExit: _finishOrExit,
child: content,
);
}
void _finishOrExit() {
if (widget.state.lessonStep == LessonStep.complete) {
widget.state.finishCurrentLessonSegment();
}
widget.onFinish();
}
}
class _LessonScaffold extends StatelessWidget {
const _LessonScaffold({required this.step, required this.child});
final int step;
final Widget child;
@override
Widget build(BuildContext context) => AppPage(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "退出课程",
onPressed: () {
final onExit = _LessonScope.exitOf(context);
if (onExit != null) {
onExit();
} else if (Navigator.canPop(context)) {
Navigator.pop(context);
}
},
),
title: Text(_LessonScope.of(context)),
),
child: SpacedColumn(
spacing: 16,
children: [
Row(
children: List.generate(
6,
(index) => Expanded(
child: Container(
height: 6,
margin: EdgeInsets.only(right: index == 5 ? 0 : 5),
decoration: BoxDecoration(
color: index < step ? AppColors.green : AppColors.line,
borderRadius: BorderRadius.circular(20),
),
),
),
),
),
child,
],
),
);
}
class _LessonScope extends InheritedWidget {
const _LessonScope({required this.title, this.onExit, required super.child});
final String title;
final VoidCallback? onExit;
static String of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_LessonScope>()?.title ??
'A0 课程练习';
static VoidCallback? exitOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_LessonScope>()?.onExit;
@override
bool updateShouldNotify(_LessonScope oldWidget) =>
title != oldWidget.title || onExit != oldWidget.onExit;
}
class _DialoguePendingStep extends StatelessWidget {
const _DialoguePendingStep({required this.onOpenDialogue});
final VoidCallback onOpenDialogue;
@override
Widget build(BuildContext context) => _LessonScaffold(
step: 6,
child: SpacedColumn(
children: [
const Eyebrow('课程对话'),
Text('在情境中用上刚学的句子。', style: Theme.of(context).textTheme.headlineMedium),
const Text('完成姓名、地点、一个状态或喜好,并反问对方。'),
PrimaryButton(label: '开始文字对话', onPressed: onOpenDialogue),
],
),
);
}
class _CompletionStep extends StatelessWidget {
const _CompletionStep({
required this.assisted,
required this.isFinalSegment,
required this.nextSegmentNumber,
required this.onFinish,
});
final bool assisted;
final bool isFinalSegment;
final int nextSegmentNumber;
final VoidCallback onFinish;
@override
Widget build(BuildContext context) => _LessonScaffold(
step: 6,
child: SpacedColumn(
children: [
const Eyebrow('本段已保存'),
Text(
isFinalSegment ? '你完成了这节课的练习。' : '你完成了当前小段的练习。',
style: Theme.of(context).textTheme.headlineMedium,
),
Text(assisted ? '独立尝试使用了帮助,系统会安排不同题再练一次。' : '这次独立尝试已记录,后续会在不同情境中复练。'),
if (!isFinalSegment) Text('回到首页后,下次从第 $nextSegmentNumber 段继续。'),
PrimaryButton(label: '回到首页', onPressed: onFinish),
],
),
);
}
class _AudioRow extends StatelessWidget {
const _AudioRow({required this.label, this.speech, this.onPlayed});
final String label;
final String? speech;
final VoidCallback? onPlayed;
@override
Widget build(BuildContext context) => Row(
children: [
IconButton.filled(
onPressed: () async {
try {
await VoiceService.instance.speak(speech ?? label);
} finally {
onPlayed?.call();
}
},
icon: const Icon(Icons.play_arrow),
),
const SizedBox(width: 10),
Expanded(
child: InkWell(
onTap: () async {
try {
await VoiceService.instance.speak(speech ?? label);
} finally {
onPlayed?.call();
}
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(label),
),
),
),
TextButton(
onPressed: () async {
try {
await VoiceService.instance.speak(speech ?? label, slow: true);
} finally {
onPlayed?.call();
}
},
child: const Text('慢速'),
),
],
);
}