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/a0_core.dart'; import '../../core/voice_service.dart'; import '../../widgets/app_widgets.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 { final controller = TextEditingController(); bool showHint = false; bool usedHelp = false; String? validationMessage; bool generatingVariant = false; bool generatingLesson = false; @override void dispose() { controller.dispose(); super.dispose(); } void _next(ReviewItem item, {required bool assisted}) { final result = ReviewFeedback.check(item, controller.text); if (!result.complete) { setState(() => validationMessage = result.message); return; } widget.state.completeReview( item, assisted: assisted, rawAnswer: controller.text.trim(), ); controller.clear(); setState(() { showHint = false; usedHelp = false; validationMessage = null; }); } Future _generateVariant(ReviewItem item) async { setState(() => generatingVariant = true); final variant = await AiService.instance.generateReviewVariant( 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 { final label = a0CoreItems[item.id]; if (label == null) return; setState(() => generatingLesson = true); final lesson = await AiService.instance.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.auditGeneratedLesson( 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) { 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 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('不看答案,试着回答。', style: Theme.of(context).textTheme.headlineMedium), Text( '目标技能:${item.skill}', style: Theme.of(context).textTheme.bodyMedium, ), Text( checkpointLabel, style: const TextStyle(color: AppColors.green, fontSize: 13), ), if (item.isAiGenerated) Row( children: [ const Expanded( child: Text( 'AI 生成题面 · 已通过客户端结构审核', style: TextStyle(color: AppColors.muted, fontSize: 12), ), ), TextButton( onPressed: () { widget.state.reportGeneratedReviewVariant(item); controller.clear(); setState(() { showHint = false; usedHelp = false; validationMessage = null; }); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('已隔离该 AI 题面,并换回本地审核题。')), ); }, child: const Text('内容有问题'), ), ], ), SectionCard( tint: AppColors.softGreen, child: SpacedColumn( children: [ const Text( '情境', style: TextStyle( color: AppColors.green, fontWeight: FontWeight.w600, ), ), Text(item.prompt, style: const TextStyle(fontSize: 19)), ], ), ), TextField( controller: controller, minLines: 2, maxLines: 4, onChanged: (_) => setState(() {}), decoration: const InputDecoration( hintText: '输入你会怎么回答', filled: true, fillColor: AppColors.surface, border: OutlineInputBorder(), ), ), 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(() {}); }, ), ActionChip( label: const Text('暂时想不起来'), onPressed: () { widget.state.reportReviewFailure(item); controller.clear(); setState(() { showHint = false; usedHelp = false; validationMessage = null; }); }, ), ActionChip( label: Text(generatingVariant ? '正在生成…' : '生成变式'), onPressed: generatingVariant ? null : () => _generateVariant(item), ), if (a0CoreItems.containsKey(item.id)) ActionChip( label: Text(generatingLesson ? '审核补练中…' : '生成四技能补练'), onPressed: generatingLesson ? null : () => _generateAdaptiveLesson(item), ), ], ), if (showHint) SectionCard( tint: AppColors.warm, child: Text( '参考:${item.hint}\n目标:${item.target}', style: const TextStyle(color: AppColors.warmInk), ), ), if (validationMessage != null) Text( validationMessage!, style: const TextStyle(color: AppColors.warmInk), ), PrimaryButton( label: usedHelp ? '带提示完成' : '我能独立回答', onPressed: controller.text.trim().isEmpty ? null : () => _next(item, assisted: usedHelp), ), const Text( '提示后完成会在明天换题复练;第一次想不起来先复核,连续两次才会降低当前检查点。', style: TextStyle(fontSize: 12, color: AppColors.muted), ), ], ), ); } } /// 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 { final controller = TextEditingController(); int index = 0; bool showReference = false; String? answerFeedback; bool listening = false; bool transcribing = false; bool aiVoiceRecording = false; bool usedVoice = false; bool transcriptEdited = false; bool transcriptConfirmed = false; String lastTranscript = ''; bool recording = false; bool playingRecording = false; String? recordingPath; @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.stopListening(); VoiceService.instance.stopRecordingPlayback(); if (!widget.state.keepRecordings) { VoiceService.instance.deleteRecording(recordingPath); } 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 correct = matchesAdaptiveLessonAnswer(task, controller.text); if (!showReference && !correct) { setState(() => answerFeedback = '还缺少目标表达中的关键信息。可以重试,或查看参考后以教学模式继续。'); return; } widget.state.recordAdaptiveLessonTask( lesson: lesson, task: task, rawAnswer: controller.text.trim(), assisted: showReference, 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; usedVoice = false; transcriptEdited = false; transcriptConfirmed = false; lastTranscript = ''; recordingPath = null; index = nextIndex; }); } Future _toggleListening() async { if (aiVoiceRecording) { final path = await VoiceService.instance.stopRecording(); if (!mounted) return; setState(() { aiVoiceRecording = false; listening = false; transcribing = true; }); if (path != null) { final config = widget.state.aiConfig; final transcribed = await AiService.instance.transcribeAudio( filePath: path, provider: config.provider, endpoint: config.endpoint, model: config.model, ); if (mounted) { setState(() { transcribing = false; if (transcribed != null && transcribed.trim().isNotEmpty) { controller.text = transcribed.trim(); usedVoice = true; transcriptEdited = false; transcriptConfirmed = false; lastTranscript = transcribed.trim(); } }); final lesson = widget.state.cachedAdaptiveLesson; if (lesson != null) _saveDraft(lesson); if (transcribed == null || transcribed.trim().isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('未识别到清晰语音,请再试一次或输入文本。')), ); } } } else { if (mounted) setState(() => transcribing = false); } return; } if (listening) { await VoiceService.instance.stopListening(); if (mounted) setState(() => listening = false); return; } final ready = await VoiceService.instance.startListening( (text, _) { if (!mounted) return; setState(() { controller.text = text; usedVoice = true; transcriptEdited = false; transcriptConfirmed = false; lastTranscript = text; }); final lesson = widget.state.cachedAdaptiveLesson; if (lesson != null) _saveDraft(lesson); }, onStatus: (status) { if (mounted && (status == 'notListening' || status == 'done')) { setState(() => listening = false); } }, onError: (err) { if (mounted) { setState(() => listening = false); } }, ); if (!ready) { final recordStarted = await VoiceService.instance.startRecording(); if (mounted) { setState(() { aiVoiceRecording = recordStarted; listening = recordStarted; }); if (recordStarted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('已启动麦克风录音,说完后再次点击,AI 将自动转写为英文。')), ); } else { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('无法访问麦克风,请检查录音权限。你仍可输入英文完成补练。')), ); } } return; } if (!mounted) return; setState(() => listening = ready); } Future _toggleRecording() async { if (recording) { final path = await VoiceService.instance.stopRecording(); if (mounted) { setState(() { recording = false; recordingPath = path; }); } return; } await VoiceService.instance.deleteRecording(recordingPath); final ready = await VoiceService.instance.startRecording(); if (!mounted) return; setState(() { recording = ready; if (ready) recordingPath = null; }); if (!ready) { ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('无法使用麦克风录音;请检查系统权限。'))); } } Future _playRecording() async { final path = recordingPath; if (path == null) return; setState(() => playingRecording = true); await VoiceService.instance.playRecording( path, onComplete: () { if (mounted) setState(() => playingRecording = false); }, ); } Future _deleteRecording() async { await VoiceService.instance.deleteRecording(recordingPath); if (mounted) setState(() => recordingPath = null); } @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: const 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 const 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('慢放'), ), ), ], ), TextField( controller: controller, minLines: 2, maxLines: 4, onChanged: (_) { if (usedVoice && controller.text != lastTranscript) { transcriptEdited = true; transcriptConfirmed = false; } _saveDraft(lesson); setState(() {}); }, 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。' : '可录音回听;离开本页后会自动删除。', ), Row( children: [ Expanded( child: OutlinedButton.icon( onPressed: listening ? null : _toggleRecording, icon: Icon( recording ? Icons.stop_circle_outlined : Icons.fiber_manual_record, ), label: Text(recording ? '停止录音' : '录音回听'), ), ), if (recordingPath != null) ...[ const SizedBox(width: 8), IconButton( tooltip: playingRecording ? '正在播放' : '回听录音', onPressed: playingRecording ? null : _playRecording, icon: const Icon(Icons.play_arrow), ), IconButton( tooltip: '删除录音', onPressed: _deleteRecording, icon: const Icon(Icons.delete_outline), ), ], ], ), ], ), ), 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 (answerFeedback != null) Text( answerFeedback!, style: const TextStyle(color: AppColors.warmInk), ), 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: 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')}'; }