Files
English/kouyu_english/lib/features/review/review_page.dart
T

1142 lines
40 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/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;
@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,
);
});
}
Widget _resultView(
({String answer, String reference, String? audio, bool assisted}) result,
) {
final remaining = widget.state.dueReviews.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 (lastResult case final result?) return _resultView(result);
final item = widget.state.dueReviews.isEmpty
? null
: widget.state.dueReviews.first;
if (item == null) {
return AppPage(
child: SpacedColumn(
children: [
const Eyebrow('今日复习已完成'),
Text(
'到期项目已安排下次复练。',
style: Theme.of(context).textTheme.headlineMedium,
),
const Text('记住不是一次答对就结束;系统会在不同间隔再次确认你仍能用出来。'),
PrimaryButton(label: '回到首页', onPressed: widget.onFinished),
],
),
);
}
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 checkpointLabel = checkpoint >= 4
? '30 天抽查'
: '第 ${checkpoint + 1} / 4 个间隔检查点';
return AppPage(
child: SpacedColumn(
children: [
Eyebrow('今天复习 · ${widget.state.dueReviewCount} 项待完成'),
Text(
dictation != null ? '听一听,写下来。' : '不看答案,试着回答。',
style: Theme.of(context).textTheme.headlineMedium,
),
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: () {
widget.state.reportReviewFailure(item);
setState(_resetAnswer);
},
),
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'),
),
],
),
),
// 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')}';
}