Files
English/kouyu_english/lib/features/assessment/assessment_page.dart
T
shenleiandClaude Opus 5 6c0aeec628 refactor: 抽出语音作答与录音回听的公共逻辑
对话页、AI 补练页、课程跟读步骤、独立表达步骤和评估页各自复制了一套
"录音 → AI 转写 → 填入答案"的流程,其中 3 页还复制了"录音回听/播放/删除"
的按钮和方法。现在统一到 lib/widgets/voice_answer.dart:
- VoiceAnswerMixin:startVoiceInput / finishVoiceInput / toggleRecording /
  playRecording / deleteRecording / disposeVoiceAnswer,状态字段名沿用各页原名,
  页面的 build 代码基本不动;
- RecordingControls:录音回听按钮行。

用户可见的变化:
- "未识别到语音"提示统一为「未识别到清晰语音,请再试一次或直接输入文字。」
  (对话页、补练页、独立表达步骤、评估页原本各有不同说法;跟读步骤保留
  原来提示"播放示范音"的文案)。麦克风不可用、开始录音的提示保持各页原样。
- 对话页的录音回听按钮与播放按钮之间增加 8px 间距,与其他页面一致。
- 删除录音时先停止正在播放的录音(原来只有跟读步骤这样做)。
- 离开页面时,如果还在录音或设备识别中,会先停止;不保留录音时一并删除
  这段录音文件。原来课程两个步骤离开时不停止录音,各页离开时也不停止
  "录音回听"的录音。

flutter analyze 无问题,flutter test 142 个测试通过;现有测试未覆盖麦克风流程。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:18:45 +09:00

451 lines
15 KiB
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';
import '../../widgets/voice_answer.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<AssessmentPreparationPage> createState() =>
_AssessmentPreparationPageState();
}
class _AssessmentPreparationPageState extends State<AssessmentPreparationPage> {
bool? microphoneReady;
bool checkingMicrophone = false;
Future<void> _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<AssessmentPack> onStartReplacement;
@override
State<AssessmentPage> createState() => _AssessmentPageState();
}
class _AssessmentPageState extends State<AssessmentPage>
with VoiceAnswerMixin<AssessmentPage> {
final controller = TextEditingController();
final Map<String, bool> results = {};
int index = 0;
bool usedMic = false;
bool transcriptEdited = false;
String lastTranscript = '';
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() {
VoiceService.instance.stopSpeaking();
VoiceService.instance.stopListening();
disposeVoiceAnswer(keepRecording: true);
controller.dispose();
super.dispose();
}
Future<void> _play() async {
try {
await VoiceService.instance.speak(task.audio!);
} catch (_) {}
if (mounted) setState(() => audioPlayed = true);
}
@override
AppState get voiceState => widget.state;
Future<void> _mic() async {
if (aiVoiceRecording) {
await finishVoiceInput(
keepAudio: false,
onTranscript: (text) {
controller.text = text;
usedMic = true;
lastTranscript = text;
transcriptEdited = false;
speakingUnavailable = false;
},
);
return;
}
final recordStarted = await startVoiceInput(
unavailableMessage: '无法访问麦克风,口语可稍后补测。',
);
if (!mounted) return;
setState(() => speakingUnavailable = !recordStarted);
if (recordStarted) {
showVoiceMessage('已启动麦克风录音,回答后再次点击,将自动转写为英文。');
}
}
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<AssessmentSkill> 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 => '写作',
};
}