Files
English/kouyu_english/lib/features/assessment/assessment_page.dart
T
shenleiandClaude Opus 4.8 febfd30f49 feat: A1 短材料复现本单元理解词,并加入黑夜模式
课程内容
- 重写全部 21 个 A1 单元的听读材料,把本单元理解词织进听/读文本,
  单元内理解词复现率从约 20% 提升到约 77%(各单元 56–96%)。
- 修正 U01 房间号与机场大巴同为 thirty 的撞车(改为 17/30/40)。
- 校验器容差按级别读取(A1 为 8%),materials 覆盖率、字数、
  选项子串等校验全部通过;course_content_test 通过。

黑夜模式
- app_theme 拆分明/暗两套调色板,AppColors 随亮度切换;
  main 用 theme/darkTheme/themeMode + builder 镜像已解析亮度;
  主题偏好持久化到快照;进度页新增“外观主题”切换。

文档
- COURSE-PACK-JSON.md 更新 A1 词池覆盖(840/933)与材料复现约定。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-18 11:16:09 +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'
'这些数字帮助你判断准备程度,但不会替代本次评估。',
),
),
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 => '写作',
};
}