Files

1418 lines
50 KiB
Dart
Raw Permalink 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;
int answerChecks = 0;
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;
ListeningSupport dictationSupport = ListeningSupport.none;
/// 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;
answerChecks = 0;
validationMessage = null;
dictationDiff = null;
transcript = null;
recordingPath = null;
dictationPlayed = false;
dictationSupport = ListeningSupport.none;
aiFeedback = null;
aiFeedbackError = null;
}
Future<WritingAiFeedback?> _checkWithAi(ReviewItem item) async {
final answer = controller.text.trim();
if (answer.isEmpty || checkingWithAi) return null;
if (widget.state.aiProvider == AiProviderType.mock) {
setState(() => aiFeedbackError = '未配置 AI 服务;本次只记为练习,不增加独立掌握。');
return null;
}
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 null;
}
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;
});
return feedback;
}
Future<void> _next(ReviewItem item, {required bool assisted}) async {
if (checkingWithAi) return;
answerChecks++;
final result = ReviewFeedback.check(item, controller.text);
if (!result.complete) {
setState(() {
usedHelp = true;
validationMessage = result.message;
dictationDiff = result.dictation;
});
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 dictation = item.skill == dictationSkill
? coreDictationSentences[item.id]
: null;
final hint = item.hint.trim();
final spoken = transcript != null;
widget.state.completeReview(
item,
assisted: assisted,
firstTry: answerChecks == 1,
attemptCount: answerChecks,
listeningSupport: dictation == null ? null : dictationSupport,
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 {
final played = await VoiceService.instance.speak(text, slow: slow);
if (played && 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: 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('慢速'),
),
],
),
],
),
),
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;
if (dictation != null) {
dictationSupport = ListeningSupport.transcript;
}
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 (dictation != null) Text('原文:${dictation.sentence}'),
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),
],
),
),
if (checkingWithAi) const Center(child: CircularProgressIndicator()),
if (dictation == null &&
widget.state.aiProvider == AiProviderType.mock)
SectionCard(
tint: AppColors.warm,
child: const Text('尚未配置 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 || checkingWithAi
? 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;
bool aiVerified = 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,
);
}
Future<void> _advance(
GeneratedLesson lesson,
GeneratedLessonTask task,
) async {
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) {
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(() {
answerAccepted = true;
answerFeedback = correct ? null : '$shortfall 已查看参考,本项按教学练习继续。';
});
return;
}
widget.state.recordAdaptiveLessonTask(
lesson: lesson,
task: task,
rawAnswer: controller.text.trim(),
assisted:
showReference ||
aiCorrected ||
((task.skill == 'writing' || task.skill == 'speaking') &&
!aiVerified),
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;
aiVerified = false;
aiCheck = null;
aiCheckError = null;
}
Future<WritingAiFeedback?> _checkWithAi(GeneratedLessonTask task) async {
final answer = controller.text.trim();
if (answer.isEmpty || checkingWithAi) return null;
if (widget.state.aiProvider == AiProviderType.mock) {
setState(() => aiCheckError = '未配置 AI 服务;本次仅作教学练习。');
return null;
}
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 null;
}
setState(() {
checkingWithAi = false;
aiCheck = feedback;
aiCheckError = feedback == null ? '暂时无法获得 AI 检查结果;本次仅作教学练习。' : null;
if (feedback != null && feedback.verdict != 'accepted') {
aiCorrected = true;
}
});
return feedback;
}
@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 (checkingWithAi) const Center(child: CircularProgressIndicator()),
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 ||
((task.skill == 'writing' ||
task.skill == 'speaking') &&
!aiVerified)
? '完成练习(不计独立掌握)'
: '答对了!',
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 && !checkingWithAi
? () => _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')}';
}