import '../../core/ai_service.dart'; import 'package:flutter/material.dart'; import '../../core/app_state.dart'; import '../../core/app_theme.dart'; import '../../core/assessment_bank.dart'; import '../../core/models.dart'; import '../../core/voice_service.dart'; import '../../widgets/app_widgets.dart'; class AssessmentPreparationPage extends StatefulWidget { const AssessmentPreparationPage({ super.key, required this.state, required this.pack, required this.onStart, required this.onBack, }); final AppState state; final AssessmentPack pack; final VoidCallback onStart; final VoidCallback onBack; @override State createState() => _AssessmentPreparationPageState(); } class _AssessmentPreparationPageState extends State { bool? microphoneReady; bool checkingMicrophone = false; Future _checkMicrophone() async { setState(() => checkingMicrophone = true); final sttReady = await VoiceService.instance.initializeSpeech(); final recReady = await VoiceService.instance.hasRecordPermission(); if (!mounted) return; setState(() { microphoneReady = sttReady || recReady; checkingMicrophone = false; }); } @override Widget build(BuildContext context) { final draft = widget.state.assessmentDraft; final canResume = draft != null && draft.packId == widget.pack.id; final pack = widget.pack; return AppPage( appBar: AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back), tooltip: "返回", onPressed: widget.onBack, ), title: const Text('评估准备'), ), child: SpacedColumn( children: [ Eyebrow('A0 阶段评估 · ${pack.id}'), Text('先确认评估方式', style: Theme.of(context).textTheme.headlineMedium), const Text('这不是日常练习:它用于确认你能在没有提示时完成基础交流。'), SectionCard( tint: AppColors.softGreen, child: Text( '本题组包含:听力 ${pack.forSkill(AssessmentSkill.listening).length} 题、' '阅读 ${pack.forSkill(AssessmentSkill.reading).length} 题、' '写作 ${pack.forSkill(AssessmentSkill.writing).length} 题、' '口语 ${pack.forSkill(AssessmentSkill.speaking).length} 题。', ), ), SectionCard( child: Text( '当前核心项:${widget.state.coreUsableCount} 项可用,' '${widget.state.coreMasteredCount} 项已掌握。\n' '这些数字帮助你判断准备程度,但不会替代本次评估。', ), ), const SectionCard( tint: AppColors.warm, child: Text( '评估规则\n' '• 不提供翻译、查词、句框或答案。\n' '• 听力题必须先播放音频。\n' '• 口语题必须用麦克风回答,且不可编辑转写。\n' '• 麦克风不可用时可保留口语待评估;不会算作语言错误。', ), ), SecondaryButton( label: checkingMicrophone ? '正在检查麦克风…' : microphoneReady == true ? '麦克风可用' : microphoneReady == false ? '麦克风暂不可用,重新检查' : '检查麦克风', onPressed: checkingMicrophone ? null : _checkMicrophone, ), if (canResume) SectionCard( tint: AppColors.warm, child: Text('会从上次中断的第 ${draft.taskIndex + 1} 题继续,已完成答案会保留。'), ), PrimaryButton( label: canResume ? '继续评估' : '开始评估', onPressed: widget.onStart, ), SecondaryButton(label: '返回阶段进度', onPressed: widget.onBack), ], ), ); } } class AssessmentPage extends StatefulWidget { const AssessmentPage({ super.key, required this.state, required this.pack, required this.onFinished, required this.onStartReplacement, }); final AppState state; final AssessmentPack pack; final VoidCallback onFinished; final ValueChanged onStartReplacement; @override State createState() => _AssessmentPageState(); } class _AssessmentPageState extends State { final controller = TextEditingController(); final Map results = {}; int index = 0; bool usedMic = false; bool transcriptEdited = false; String lastTranscript = ''; bool listening = false; bool transcribing = false; bool aiVoiceRecording = false; bool audioPlayed = false; bool speakingUnavailable = false; AssessmentRecord? completedRecord; AssessmentTask get task => widget.pack.tasks[index]; @override void initState() { super.initState(); final draft = widget.state.assessmentDraft; if (draft != null && draft.packId == widget.pack.id && draft.taskIndex < widget.pack.tasks.length) { index = draft.taskIndex; results.addAll(draft.results); } } @override void dispose() { controller.dispose(); super.dispose(); } Future _play() async { try { await VoiceService.instance.speak(task.audio!); } catch (_) {} if (mounted) setState(() => audioPlayed = true); } Future _mic() 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(); usedMic = true; lastTranscript = transcribed.trim(); transcriptEdited = false; speakingUnavailable = false; } }); 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) { setState(() { controller.text = text; usedMic = true; lastTranscript = text; transcriptEdited = false; }); } }, 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; speakingUnavailable = !recordStarted; }); if (recordStarted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('已启动麦克风录音,回答后再次点击,AI 将自动转写为英文。')), ); } else { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('无法访问麦克风,口语可稍后补测。')), ); } } return; } if (mounted) { setState(() { listening = ready; speakingUnavailable = !ready; }); } } bool _openCorrect() { return checkOpenAssessmentAnswer(task, controller.text); } void _submit([int? answer]) { if (answer == null && task.skill == AssessmentSkill.speaking && (!usedMic || transcriptEdited)) { setState(() => speakingUnavailable = true); ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('请使用未编辑的语音转写,或将口语保留为待评估。'))); return; } final correct = answer != null ? answer == task.answerIndex && (task.skill != AssessmentSkill.listening || audioPlayed) : _openCorrect() && (task.skill != AssessmentSkill.speaking || (usedMic && !transcriptEdited)); results[task.id] = correct; widget.state.recordAssessmentAttempt( taskId: task.id, skill: _skillLabel(task.skill), correct: correct, rawAnswer: answer == null ? controller.text.trim() : task.choices[answer], spoken: task.skill == AssessmentSkill.speaking, ); if (index + 1 < widget.pack.tasks.length) { setState(() { index++; controller.clear(); usedMic = false; transcriptEdited = false; lastTranscript = ''; listening = false; audioPlayed = false; }); widget.state.saveAssessmentDraft( AssessmentDraft( packId: widget.pack.id, taskIndex: index, results: results, ), ); } else { _finish(); } } void _markSpeakingPending() { for (final speaking in widget.pack.forSkill(AssessmentSkill.speaking)) { if (!results.containsKey(speaking.id)) { results[speaking.id] = false; widget.state.recordAssessmentPending( taskId: speaking.id, skill: _skillLabel(AssessmentSkill.speaking), reason: '设备麦克风或语音识别不可用,等待补测。', ); } } _finish(pendingSkills: const {AssessmentSkill.speaking}); } void _finish({Set pendingSkills = const {}}) { bool passed(AssessmentSkill skill) { final items = widget.pack.forSkill(skill); final score = items.where((item) => results[item.id] == true).length; if (skill == AssessmentSkill.listening) { return score >= 8 && (results[items[3].id] == true || results[items[4].id] == true) && results[items[8].id] == true; } if (skill == AssessmentSkill.reading) { return score >= 4 && results[items[1].id] == true; } if (skill == AssessmentSkill.writing) { return score >= 4 && items.take(3).every((item) => results[item.id] == true); } return score == items.length; } final record = AssessmentRecord( packId: widget.pack.id, completedAt: DateTime.now(), results: { for (final skill in AssessmentSkill.values) skill: passed(skill), }, pendingSkills: pendingSkills, ); final merged = widget.state.recordAssessment(record); widget.state.clearAssessmentDraft(); setState(() => completedRecord = merged); } @override Widget build(BuildContext context) { final record = completedRecord; if (record != null) { return AppPage( appBar: AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back), tooltip: "返回", onPressed: widget.onFinished, ), title: Text(record.passed ? "阶段评估通过" : "阶段评估结果"), ), child: SpacedColumn( children: [ const Eyebrow('评估结果已保存'), Text( record.passed ? '本题组四技能通过。' : record.pendingSkills.isNotEmpty ? '已保存完成的技能;有技能待评估。' : '先补练,再使用替换题补测。', style: Theme.of(context).textTheme.headlineMedium, ), if (record.passed) const SectionCard( child: Text('已通过的技能在本题组 7 天有效窗口内保留。第二题组仍须使用不同题面。'), ), if (!record.passed) ...[ if (record.pendingSkills.isNotEmpty) SectionCard( tint: AppColors.warm, child: Text( '待评估:${record.pendingSkills.map(_skillLabel).join('、')}。技术问题不会计为语言错误。', ), ), if (record.failedSkills.isNotEmpty) const Text('建议补练:'), for (final skill in record.failedSkills) SectionCard(child: Text(_remediation(skill))), ], if (!record.passed && replacementFor(widget.pack.id) != null) SecondaryButton( label: '开始替换题补测', onPressed: () => widget.onStartReplacement(replacementFor(widget.pack.id)!), ), PrimaryButton(label: '回到阶段进度', onPressed: widget.onFinished), ], ), ); } return AppPage( appBar: AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back), tooltip: "退出评估", onPressed: widget.onFinished, ), title: Text( 'A0 评估 ${widget.pack.id} · ${index + 1}/${widget.pack.tasks.length}', ), ), child: SpacedColumn( children: [ Text( _skillLabel(task.skill), style: Theme.of(context).textTheme.headlineMedium, ), const Text('评估中不提供翻译、句框或答案。需要帮助请退出后先做补练。'), if (task.skill == AssessmentSkill.listening) ...[ PrimaryButton( label: audioPlayed ? '再播放一次' : '播放音频', onPressed: _play, ), const Text('请根据听到的内容选择答案。'), for (var i = 0; i < task.choices.length; i++) SectionCard( onTap: () { if (!audioPlayed) { _play(); } _submit(i); }, child: Text(task.choices[i]), ), ] else if (task.skill == AssessmentSkill.reading) ...[ SectionCard( child: Text( task.prompt, style: const TextStyle(fontSize: 17, height: 1.6), ), ), for (var i = 0; i < task.choices.length; i++) SectionCard( onTap: () => _submit(i), child: Text(task.choices[i]), ), ] else ...[ SectionCard( child: Text(task.prompt, style: const TextStyle(fontSize: 18)), ), TextField( controller: controller, onChanged: (value) => setState(() { if (task.skill == AssessmentSkill.speaking && usedMic && value != lastTranscript) { transcriptEdited = true; } }), minLines: 2, maxLines: 4, decoration: InputDecoration( hintText: task.skill == AssessmentSkill.speaking ? '使用麦克风说出答案;文字仅作待评估记录' : '输入英文答案', border: const OutlineInputBorder(), ), ), if (task.skill == AssessmentSkill.speaking) SecondaryButton( label: transcribing ? '正在 AI 识别…' : (listening ? '停止录音并识别' : '使用麦克风回答'), onPressed: transcribing ? null : _mic, ), if (task.skill == AssessmentSkill.speaking && speakingUnavailable) SecondaryButton( label: '将口语保留为待评估', onPressed: _markSpeakingPending, ), PrimaryButton( label: '提交', onPressed: controller.text.trim().isEmpty ? null : _submit, ), ], ], ), ); } String _remediation(AssessmentSkill skill) => switch (skill) { AssessmentSkill.listening => '听力:回到数字、星期/时间和场景听辨复习;下一次会使用不同音频。', AssessmentSkill.speaking => '口语:练姓名、号码、物品、家人、星期、整点和请求重复;确认未编辑转写后再补测。', AssessmentSkill.reading => '阅读:复习人物、地点、数字和时间信息定位,再阅读不同短对话。', AssessmentSkill.writing => '写作:分别练完整的姓名、地点、喜好、物品、星期/时间句,不使用句框。', }; String _skillLabel(AssessmentSkill skill) => switch (skill) { AssessmentSkill.listening => '听力', AssessmentSkill.speaking => '口语', AssessmentSkill.reading => '阅读', AssessmentSkill.writing => '写作', }; }