Files
English/kouyu_english/lib/features/review/review_page.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

1369 lines
48 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import '../../widgets/lexicon_lookup.dart';
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/ai_service.dart';
import '../../core/generated_content.dart';
import '../../core/models.dart';
import '../../core/review_feedback.dart';
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 {
const ReviewPage({
super.key,
required this.state,
required this.onFinished,
required this.onOpenAdaptiveLesson,
});
final AppState state;
final VoidCallback onFinished;
final VoidCallback onOpenAdaptiveLesson;
@override
State<ReviewPage> createState() => _ReviewPageState();
}
class _ReviewPageState extends State<ReviewPage>
with VoiceAnswerMixin<ReviewPage> {
final controller = TextEditingController();
bool showHint = false;
bool usedHelp = false;
String? validationMessage;
bool generatingVariant = false;
bool generatingLesson = false;
bool checkingWithAi = false;
WritingAiFeedback? aiFeedback;
String? aiFeedbackError;
/// The untouched transcript of a spoken answer; null for typed answers.
String? transcript;
/// The failed dictation check, shown word by word.
SpokenComparison? dictationDiff;
/// The answer just accepted, shown before moving to the next item.
({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;
@override
void dispose() {
VoiceService.instance.stopSpeaking();
disposeVoiceAnswer(keepRecording: widget.state.keepRecordings);
controller.dispose();
super.dispose();
}
void _resetAnswer() {
controller.clear();
// A recording still held here was never attached to evidence.
VoiceService.instance.deleteRecording(recordingPath);
showHint = false;
usedHelp = false;
validationMessage = null;
dictationDiff = null;
transcript = null;
recordingPath = null;
dictationPlayed = false;
aiFeedback = null;
aiFeedbackError = null;
}
Future<void> _checkWithAi(ReviewItem item) async {
final answer = controller.text.trim();
if (answer.isEmpty || checkingWithAi) return;
if (widget.state.aiProvider == AiProviderType.mock) {
setState(() => aiFeedbackError = '请先在“我的”配置 AI 服务;本地检查仍可继续复习。');
return;
}
setState(() {
checkingWithAi = true;
aiFeedbackError = null;
});
final feedback = await AiService.instance.capabilities.evaluation
.evaluateAnswer(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
answerId: item.id,
target: item.target,
taskPrompt: item.prompt,
answer: answer,
level: itemLevel(item.id),
);
// The learner may have moved on or kept typing while the request ran.
if (!mounted || controller.text.trim() != answer) {
if (mounted) setState(() => checkingWithAi = false);
return;
}
setState(() {
checkingWithAi = false;
aiFeedback = feedback;
aiFeedbackError = feedback == null
? '暂时无法获得 AI 检查结果。你的答案保留在这里,可稍后重试或直接提交。'
: null;
// A shown correction is help: finishing afterwards counts as assisted.
if (feedback != null && feedback.verdict != 'accepted') usedHelp = true;
});
}
void _next(ReviewItem item, {required bool assisted}) {
final result = ReviewFeedback.check(item, controller.text);
if (!result.complete) {
setState(() {
validationMessage = result.message;
dictationDiff = result.dictation;
});
return;
}
final answer = controller.text.trim();
final dictation = item.skill == dictationSkill
? coreDictationSentences[item.id]
: null;
final hint = item.hint.trim();
final spoken = transcript != null;
widget.state.completeReview(
item,
assisted: assisted,
rawAnswer: answer,
inputMode: spoken && transcript == answer ? 'speechToText' : 'text',
originalTranscript: transcript,
recordingPath: spoken && widget.state.keepRecordings
? recordingPath
: null,
);
// A kept recording now belongs to the evidence row; don't delete it.
if (spoken && widget.state.keepRecordings) recordingPath = null;
setState(() {
_resetAnswer();
lastResult = (
answer: answer,
reference:
dictation?.sentence ??
(hint.isNotEmpty && hint != item.target
? '${item.target}\n例:$hint'
: item.target),
audio: dictation?.sentence ?? reviewAudioText(item.id, item.target),
assisted: assisted,
);
});
}
/// 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.reviewSession.length;
return AppPage(
child: SpacedColumn(
children: [
Eyebrow(remaining > 0 ? '还有 $remaining 项待复习' : '今天的复习项目已全部完成'),
Row(
children: [
Icon(
result.assisted ? Icons.lightbulb_outline : Icons.check_circle,
color: result.assisted ? AppColors.warmInk : AppColors.green,
size: 32,
),
const SizedBox(width: 10),
Expanded(
child: Text(
result.assisted ? '带提示完成' : '答对了!',
style: Theme.of(context).textTheme.headlineMedium,
),
),
],
),
SectionCard(
child: SpacedColumn(
spacing: 8,
children: [
Text('你的回答:${result.answer}'),
Text(
'参考说法:${result.reference}',
style: TextStyle(
color: AppColors.green,
fontWeight: FontWeight.w600,
),
),
if (result.audio != null)
TextButton.icon(
onPressed: () => VoiceService.instance.speak(result.audio!),
icon: const Icon(Icons.volume_up_outlined),
label: const Text('听参考说法'),
),
],
),
),
Text(
result.assisted
? '用过提示的项目会在明天换题再练一次。'
: '本地检查确认用上了目标表达;下次会在更长的间隔后再检查。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
PrimaryButton(
label: remaining > 0 ? '下一题' : '完成今天的复习',
onPressed: () => setState(() => lastResult = null),
),
],
),
);
}
Future<void> _toggleVoice() async {
if (aiVoiceRecording) {
await finishVoiceInput(
keepAudio: widget.state.keepRecordings,
onTranscript: (text) {
controller.text = text;
transcript = text;
validationMessage = null;
aiFeedback = null;
aiFeedbackError = null;
},
);
return;
}
await VoiceService.instance.stopSpeaking();
await startVoiceInput(unavailableMessage: '无法访问麦克风,请检查录音权限。你仍可输入英文作答。');
}
Future<void> _play(String text, {bool slow = false}) async {
try {
await VoiceService.instance.speak(text, slow: slow);
} finally {
if (mounted) setState(() => dictationPlayed = true);
}
}
Future<void> _generateVariant(ReviewItem item) async {
setState(() => generatingVariant = true);
final variant = await AiService.instance.capabilities.review
.generateVariant(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
targetItemId: item.id,
basePrompt: item.prompt,
);
if (!mounted) return;
setState(() => generatingVariant = false);
if (variant == null) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('未获得合格变式,已继续使用本地审核题。')));
return;
}
widget.state.applyGeneratedReviewVariant(variant);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('已生成并审核新题面,核心学习目标不变。')));
}
Future<void> _generateAdaptiveLesson(ReviewItem item) async {
if (!isCoreItem(item.id)) return;
final label = coreItemEnglish(item.id);
setState(() => generatingLesson = true);
final lesson = await AiService.instance.capabilities.review
.generateAdaptiveLesson(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
targetItemId: item.id,
targetLabel: label,
);
if (lesson == null) {
if (mounted) setState(() => generatingLesson = false);
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('未生成合格补练,继续使用本地复习题。')));
}
return;
}
final approved = await AiService.instance.capabilities.review
.auditAdaptiveLesson(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
lesson: lesson,
);
if (!mounted) return;
setState(() => generatingLesson = false);
if (!approved) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('补练未通过独立审核,未保存。')));
return;
}
widget.state.cacheApprovedAdaptiveLesson(
lesson,
auditor: '${widget.state.aiProvider.name}:${widget.state.aiModel}',
);
widget.onOpenAdaptiveLesson();
}
@override
Widget build(BuildContext context) {
if (contrastFor case final item?) return _contrastView(item);
if (lastResult case final result?) return _resultView(result);
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: [
const Eyebrow('今日复习已完成'),
Text(
'到期项目已安排下次复练。',
style: Theme.of(context).textTheme.headlineMedium,
),
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;
final spokenTask = item.skill == spokenRecallSkill;
final audioText = reviewAudioText(item.id, item.target);
final checkpoint =
widget.state.mastery[item.id]?.checkpoint ?? item.successfulReviews;
final isRecap = item.kind == ReviewKind.recap;
final checkpointLabel = isRecap
? '不计检查点 · 只是趁热再想一遍'
: checkpoint >= 4
? '30 天抽查'
: '第 ${checkpoint + 1} / 4 个间隔检查点';
return AppPage(
child: SpacedColumn(
children: [
Eyebrow(
isRecap
? '再想一遍 · 还有 ${widget.state.reviewSession.length} 项'
: '今天复习 · ${widget.state.reviewSession.length} 项待完成',
),
Text(
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,
),
Text(
checkpointLabel,
style: TextStyle(color: AppColors.green, fontSize: 13),
),
if (item.isAiGenerated)
Row(
children: [
Expanded(
child: Text(
'AI 生成题面 · 已通过客户端结构审核',
style: TextStyle(color: AppColors.muted, fontSize: 12),
),
),
TextButton(
onPressed: () {
widget.state.reportGeneratedReviewVariant(item);
setState(_resetAnswer);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已隔离该 AI 题面,并换回本地审核题。')),
);
},
child: const Text('内容有问题'),
),
],
),
SectionCard(
tint: AppColors.softGreen,
child: SpacedColumn(
children: [
Text(
'情境',
style: TextStyle(
color: AppColors.green,
fontWeight: FontWeight.w600,
),
),
Text(item.prompt, style: const TextStyle(fontSize: 19)),
if (dictation != null)
Row(
children: [
IconButton.filled(
tooltip: '播放',
onPressed: () => _play(dictation.sentence),
icon: const Icon(Icons.play_arrow),
),
const SizedBox(width: 10),
Expanded(child: Text(dictationPlayed ? '再听一次' : '播放句子')),
TextButton(
onPressed: () => _play(dictation.sentence, slow: true),
child: const Text('慢速'),
),
],
),
],
),
),
TextField(
controller: controller,
minLines: 2,
maxLines: 4,
enabled: dictation == null || dictationPlayed,
onChanged: (_) => setState(() {
aiFeedback = null;
aiFeedbackError = null;
dictationDiff = null;
}),
decoration: InputDecoration(
hintText: dictation != null
? (dictationPlayed ? '写下你听到的英文' : '先播放句子')
: (spokenTask ? '点下方麦克风说出来,或输入英文' : '输入你会怎么回答'),
filled: true,
fillColor: AppColors.surface,
border: const OutlineInputBorder(),
),
),
if (dictation == null)
OutlinedButton.icon(
onPressed: transcribing ? null : _toggleVoice,
icon: Icon(listening ? Icons.stop : Icons.mic_none),
label: Text(
transcribing
? '正在识别…'
: listening
? '说完了,停止并识别'
: '用语音回答',
),
),
if (transcript != null && controller.text.trim() != transcript)
Text(
'你修改了语音转写,本次按文字作答记录。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ActionChip(
label: const Text('需要提示'),
onPressed: () => setState(() {
showHint = true;
usedHelp = true;
validationMessage = null;
}),
),
ActionChip(
label: const Text('稍后复习'),
onPressed: () {
widget.state.postponeReview(item);
setState(_resetAnswer);
},
),
ActionChip(
label: const Text('暂时想不起来'),
onPressed: () => _reportMiss(item),
),
ActionChip(
label: Text(generatingVariant ? '正在生成…' : '生成变式'),
onPressed: generatingVariant
? null
: () => _generateVariant(item),
),
if (isCoreItem(item.id))
ActionChip(
label: Text(generatingLesson ? '审核补练中…' : '生成四技能补练'),
onPressed: generatingLesson
? null
: () => _generateAdaptiveLesson(item),
),
ActionChip(
avatar: const Icon(Icons.search, size: 16),
label: const Text('查词查句'),
onPressed: () =>
showLexiconLookup(context, state: widget.state),
),
],
),
if (showHint)
SectionCard(
tint: AppColors.warm,
child: SpacedColumn(
spacing: 8,
children: [
Text(
dictation != null
? '句子意思:${item.hint}\n目标词句:${item.target}'
: '参考:${item.hint}\n目标:${item.target}',
style: TextStyle(color: AppColors.warmInk),
),
if (audioText != null && dictation == null)
TextButton.icon(
onPressed: () => _play(audioText),
icon: const Icon(Icons.volume_up_outlined),
label: Text('听示范:$audioText'),
),
UsageCard(itemId: item.id, state: widget.state),
],
),
),
// Dictation is already compared word by word against the sentence.
if (dictation == null)
OutlinedButton.icon(
onPressed: controller.text.trim().isEmpty || checkingWithAi
? null
: () => _checkWithAi(item),
icon: checkingWithAi
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.spellcheck),
label: Text(checkingWithAi ? '正在检查…' : 'AI 检查语法和拼写(可选)'),
),
if (aiFeedback != null)
SectionCard(
tint: aiFeedback!.verdict == 'accepted'
? AppColors.softGreen
: AppColors.warm,
child: SpacedColumn(
spacing: 6,
children: [
Text('AI 检查:${aiFeedback!.feedback}'),
for (final note in aiFeedback!.missing) Text('· $note'),
if (aiFeedback!.suggestion != null)
Text('参考改正:${aiFeedback!.suggestion}'),
Text(
aiFeedback!.verdict == 'accepted'
? 'AI 仅作参考;是否完成仍由本地检查判断。'
: '看过改正后再提交,本次会记为带提示完成。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
],
),
),
if (aiFeedbackError != null)
SectionCard(
tint: AppColors.warm,
child: Text(
aiFeedbackError!,
style: TextStyle(color: AppColors.warmInk),
),
),
if (validationMessage != null)
Text(
validationMessage!,
style: TextStyle(color: AppColors.warmInk),
),
if (dictationDiff case final diff?) _DictationDiffCard(diff: diff),
PrimaryButton(
label: usedHelp ? '带提示完成' : '我能独立回答',
onPressed: controller.text.trim().isEmpty
? null
: () => _next(item, assisted: usedHelp),
),
Text(
'提示后完成会在明天换题复练;第一次想不起来先复核,连续两次才会降低当前检查点。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
],
),
);
}
}
/// Shows which dictated words were caught without giving the missed ones
/// away: missed words become blanks and wrong words are listed.
class _DictationDiffCard extends StatelessWidget {
const _DictationDiffCard({required this.diff});
final SpokenComparison diff;
@override
Widget build(BuildContext context) => SectionCard(
tint: AppColors.warm,
child: SpacedColumn(
spacing: 8,
children: [
Text(
'写对 ${diff.heardCount}/${diff.total} 个词;空格处是漏写或写错的词:',
style: TextStyle(color: AppColors.warmInk),
),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final word in diff.words)
Text(
word.heard ? word.text : '__',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: word.heard ? AppColors.green : AppColors.warmInk,
),
),
],
),
if (diff.extraWords.isNotEmpty)
Text(
'句子里没有这些词(可能拼错了):${diff.extraWords.join('、')}',
style: TextStyle(color: AppColors.warmInk),
),
],
),
);
}
/// Displays an audited AI-authored reinforcement lesson. It remains a
/// teaching activity: the existing local review and assessment systems own
/// all mastery decisions.
class AdaptiveLessonPage extends StatefulWidget {
const AdaptiveLessonPage({
super.key,
required this.state,
required this.onFinished,
});
final AppState state;
final VoidCallback onFinished;
@override
State<AdaptiveLessonPage> createState() => _AdaptiveLessonPageState();
}
class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
with VoiceAnswerMixin<AdaptiveLessonPage> {
final controller = TextEditingController();
int index = 0;
bool showReference = false;
String? answerFeedback;
/// The answer passed the local check; the next tap records it.
bool answerAccepted = false;
bool checkingWithAi = false;
WritingAiFeedback? aiCheck;
String? aiCheckError;
/// A shown AI correction is help, so this task is recorded as assisted.
bool aiCorrected = false;
bool usedVoice = false;
bool transcriptEdited = false;
bool transcriptConfirmed = false;
String lastTranscript = '';
@override
void initState() {
super.initState();
final lesson = widget.state.cachedAdaptiveLesson;
if (lesson != null &&
widget.state.adaptiveLessonDraftId == lesson.lessonId) {
index = widget.state.adaptiveLessonDraftIndex.clamp(
0,
lesson.tasks.length,
);
controller.text = widget.state.adaptiveLessonDraftAnswer;
showReference = widget.state.adaptiveLessonDraftReferenceShown;
usedVoice = widget.state.adaptiveLessonDraftUsedVoice;
transcriptEdited = widget.state.adaptiveLessonDraftTranscriptEdited;
transcriptConfirmed = widget.state.adaptiveLessonDraftTranscriptConfirmed;
lastTranscript = widget.state.adaptiveLessonDraftOriginalTranscript;
recordingPath = widget.state.adaptiveLessonDraftRecordingPath;
}
}
@override
void dispose() {
VoiceService.instance.stopSpeaking();
VoiceService.instance.stopListening();
disposeVoiceAnswer(keepRecording: widget.state.keepRecordings);
controller.dispose();
super.dispose();
}
void _saveDraft(GeneratedLesson lesson) {
widget.state.saveAdaptiveLessonDraft(
lesson: lesson,
taskIndex: index,
answer: controller.text,
referenceShown: showReference,
usedVoice: usedVoice,
transcriptEdited: transcriptEdited,
transcriptConfirmed: transcriptConfirmed,
originalTranscript: lastTranscript,
recordingPath: widget.state.keepRecordings ? recordingPath : null,
);
}
void _advance(GeneratedLesson lesson, GeneratedLessonTask task) {
final shortfall = adaptiveAnswerShortfall(task, controller.text);
final correct = shortfall == null;
if (!showReference && !correct) {
setState(() => answerFeedback = '$shortfall 可以重试,或查看参考后以教学模式继续。');
return;
}
// Show the verdict before moving on, so the learner sees it.
if (!answerAccepted) {
setState(() {
answerAccepted = true;
answerFeedback = correct ? null : '$shortfall 已查看参考,本项按教学练习继续。';
});
return;
}
widget.state.recordAdaptiveLessonTask(
lesson: lesson,
task: task,
rawAnswer: controller.text.trim(),
assisted: showReference || aiCorrected,
correct: correct,
inputMode: usedVoice && !transcriptEdited && transcriptConfirmed
? 'speechToText'
: 'text',
recordingPath: widget.state.keepRecordings ? recordingPath : null,
originalTranscript: usedVoice ? lastTranscript : null,
transcriptConfirmed:
usedVoice && transcriptConfirmed && !transcriptEdited,
transcriptEdited: usedVoice && transcriptEdited,
);
if (!widget.state.keepRecordings) {
VoiceService.instance.deleteRecording(recordingPath);
}
final nextIndex = index + 1;
if (nextIndex >= lesson.tasks.length) {
widget.state.clearAdaptiveLessonDraft();
} else {
widget.state.saveAdaptiveLessonDraft(
lesson: lesson,
taskIndex: nextIndex,
answer: '',
referenceShown: false,
originalTranscript: '',
recordingPath: null,
);
}
setState(() {
controller.clear();
showReference = false;
answerFeedback = null;
_clearChecks();
aiCorrected = false;
usedVoice = false;
transcriptEdited = false;
transcriptConfirmed = false;
lastTranscript = '';
recordingPath = null;
index = nextIndex;
});
}
void _clearChecks() {
answerAccepted = false;
aiCheck = null;
aiCheckError = null;
}
Future<void> _checkWithAi(GeneratedLessonTask task) async {
final answer = controller.text.trim();
if (answer.isEmpty || checkingWithAi) return;
if (widget.state.aiProvider == AiProviderType.mock) {
setState(() => aiCheckError = '请先在“我的”配置 AI 服务;本地检查仍可继续补练。');
return;
}
setState(() {
checkingWithAi = true;
aiCheckError = null;
});
final feedback = await AiService.instance.capabilities.evaluation
.evaluateAnswer(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
answerId: task.taskId,
target: task.answer,
taskPrompt: '${task.prompt} ${task.stimulus}',
answer: answer,
);
// The learner may have kept typing or moved on while the request ran.
if (!mounted || controller.text.trim() != answer) {
if (mounted) setState(() => checkingWithAi = false);
return;
}
setState(() {
checkingWithAi = false;
aiCheck = feedback;
aiCheckError = feedback == null
? '暂时无法获得 AI 检查结果。你的答案保留在这里,可稍后重试或直接继续。'
: null;
if (feedback != null && feedback.verdict != 'accepted') {
aiCorrected = true;
}
});
}
@override
AppState get voiceState => widget.state;
Future<void> _toggleListening() async {
if (aiVoiceRecording) {
await finishVoiceInput(
keepAudio: false,
onTranscript: (text) {
controller.text = text;
usedVoice = true;
transcriptEdited = false;
transcriptConfirmed = false;
lastTranscript = text;
_clearChecks();
},
afterTranscribe: () {
final lesson = widget.state.cachedAdaptiveLesson;
if (lesson != null) _saveDraft(lesson);
},
);
return;
}
await startVoiceInput(unavailableMessage: '无法访问麦克风,请检查录音权限。你仍可输入英文完成补练。');
}
@override
Widget build(BuildContext context) {
final lesson = widget.state.cachedAdaptiveLesson;
if (lesson == null) {
return AppPage(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: widget.onFinished,
),
title: const Text("AI 四技能补练"),
),
child: SpacedColumn(
children: [
const Eyebrow('AI 四技能补练'),
const Text('没有可用的已审核补练。'),
PrimaryButton(label: '回到复习', onPressed: widget.onFinished),
],
),
);
}
final done = index >= lesson.tasks.length;
if (done) {
return AppPage(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: widget.onFinished,
),
title: const Text("补练完成"),
),
child: SpacedColumn(
children: [
const Eyebrow('补练已完成'),
Text(
'你完成了这组四技能教学补练。',
style: Theme.of(context).textTheme.headlineMedium,
),
const Text('这组 AI 内容只作教学复练;掌握度仍由本地复习与出口评估的有效证据决定。'),
PrimaryButton(label: '回到复习', onPressed: widget.onFinished),
],
),
);
}
final task = lesson.tasks[index];
final showStimulus = task.skill != 'listening';
return AppPage(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: widget.onFinished,
),
title: Text('AI 补练 · ${index + 1}/4'),
),
child: SpacedColumn(
children: [
Eyebrow('${_skillLabel(task.skill)} · 已审核教学内容'),
if (widget.state.cachedAdaptiveLessonAuditedAt != null)
Text(
'审核:${widget.state.cachedAdaptiveLessonAuditor ?? '已配置服务'} · '
'${_formatAuditTime(widget.state.cachedAdaptiveLessonAuditedAt!)}',
style: TextStyle(color: AppColors.muted, fontSize: 12),
),
Text(task.prompt, style: Theme.of(context).textTheme.headlineMedium),
if (showStimulus)
SectionCard(
tint: AppColors.softGreen,
child: Text(task.stimulus, style: const TextStyle(fontSize: 20)),
)
else
SectionCard(
tint: AppColors.surfaceMuted,
child: Text('先播放音频,再输入你听到的答案。'),
),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => VoiceService.instance.speak(task.stimulus),
icon: const Icon(Icons.volume_up_outlined),
label: const Text('播放'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton.icon(
onPressed: () =>
VoiceService.instance.speak(task.stimulus, slow: true),
icon: const Icon(Icons.slow_motion_video_outlined),
label: const Text('慢放'),
),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () => showLexiconLookup(
context,
state: widget.state,
initialText: task.stimulus,
),
icon: const Icon(Icons.psychology_alt_outlined),
label: const Text('解析'),
),
],
),
TextField(
controller: controller,
minLines: 2,
maxLines: 4,
onChanged: (_) {
if (usedVoice && controller.text != lastTranscript) {
transcriptEdited = true;
transcriptConfirmed = false;
}
_saveDraft(lesson);
setState(() {
_clearChecks();
answerFeedback = null;
});
},
decoration: InputDecoration(
hintText: '输入或说出你的答案后,再继续',
filled: true,
fillColor: AppColors.surface,
prefixIcon: IconButton(
tooltip: transcribing
? '正在 AI 识别…'
: (listening ? '停止录音并识别' : '语音输入'),
onPressed: _toggleListening,
icon: transcribing
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
listening ? Icons.stop_circle_outlined : Icons.mic_none,
color: listening ? AppColors.green : null,
),
),
border: OutlineInputBorder(),
),
),
if (task.skill == 'speaking')
SectionCard(
tint: AppColors.surfaceMuted,
child: SpacedColumn(
spacing: 8,
children: [
Text(
widget.state.keepRecordings
? '可录音回听并仅保存在本机;不会发送给 AI。'
: '可录音回听;离开本页后会自动删除。',
),
RecordingControls(
recording: recording,
playing: playingRecording,
hasRecording: recordingPath != null,
onToggleRecording: listening ? null : toggleRecording,
onPlay: playRecording,
onDelete: deleteRecording,
),
],
),
),
if (usedVoice)
SectionCard(
tint: transcriptEdited || !transcriptConfirmed
? AppColors.warm
: AppColors.softGreen,
child: SpacedColumn(
spacing: 6,
children: [
Text(
transcriptEdited
? '你修改了设备转写:本次将按文字教学练习保存。'
: transcriptConfirmed
? '已确认设备转写:会保留本次语音输入记录。'
: '请核对设备转写;确认前会按文字教学练习保存。',
),
if (!transcriptEdited && !transcriptConfirmed)
TextButton(
onPressed: () {
setState(() => transcriptConfirmed = true);
_saveDraft(lesson);
},
child: const Text('确认转写无误'),
),
],
),
),
if (task.skill == 'writing' || task.skill == 'speaking')
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)
SectionCard(
tint: aiCheck!.verdict == 'accepted'
? AppColors.softGreen
: AppColors.warm,
child: SpacedColumn(
spacing: 6,
children: [
Text('AI 检查:${aiCheck!.feedback}'),
for (final note in aiCheck!.missing) Text('· $note'),
if (aiCheck!.suggestion != null)
Text('参考改正:${aiCheck!.suggestion}'),
Text(
aiCheck!.verdict == 'accepted'
? 'AI 仅作参考;是否正确仍由本地检查判断。'
: '看过改正后再继续,本项会记为带提示完成。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
],
),
),
if (aiCheckError != null)
SectionCard(
tint: AppColors.warm,
child: Text(
aiCheckError!,
style: TextStyle(color: AppColors.warmInk),
),
),
if (answerFeedback != null)
Text(answerFeedback!, style: TextStyle(color: AppColors.warmInk)),
if (answerAccepted && answerFeedback == null)
SectionCard(
tint: AppColors.softGreen,
child: SpacedColumn(
spacing: 6,
children: [
Row(
children: [
Icon(Icons.check_circle, color: AppColors.green),
const SizedBox(width: 8),
Text(
showReference || aiCorrected ? '正确(带提示完成)' : '答对了!',
style: TextStyle(
color: AppColors.green,
fontWeight: FontWeight.w600,
),
),
],
),
Text('参考表达:${task.answer}'),
],
),
),
if (showReference)
SectionCard(
tint: AppColors.warm,
child: Text('参考表达:${task.answer}'),
)
else
TextButton(
onPressed: () {
setState(() => showReference = true);
_saveDraft(lesson);
},
child: const Text('需要帮助,查看参考表达'),
),
TextButton.icon(
onPressed: () {
widget.state.reportAdaptiveLesson(lesson);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已隔离这组 AI 补练内容,并返回复习。')),
);
widget.onFinished();
},
icon: const Icon(Icons.flag_outlined),
label: const Text('内容有问题'),
),
PrimaryButton(
label: !answerAccepted
? '检查答案'
: index == lesson.tasks.length - 1
? '完成补练'
: '下一项',
onPressed: controller.text.trim().isNotEmpty
? () => _advance(lesson, task)
: null,
),
],
),
);
}
String _skillLabel(String skill) => switch (skill) {
'listening' => '听力',
'speaking' => '口语',
'reading' => '阅读',
_ => '写作',
};
String _formatAuditTime(DateTime value) =>
'${value.year}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')} '
'${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}';
}