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 createState() => _ReviewPageState(); } class _ReviewPageState extends State with VoiceAnswerMixin { 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 _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 _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 _play(String text, {bool slow = false}) async { try { await VoiceService.instance.speak(text, slow: slow); } finally { if (mounted) setState(() => dictationPlayed = true); } } Future _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 _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 createState() => _AdaptiveLessonPageState(); } class _AdaptiveLessonPageState extends State with VoiceAnswerMixin { 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 _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 _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')}'; }