517 lines
17 KiB
Dart
517 lines
17 KiB
Dart
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<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> {
|
|
final controller = TextEditingController();
|
|
final Map<String, bool> 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<void> _play() async {
|
|
try {
|
|
await VoiceService.instance.speak(task.audio!);
|
|
} catch (_) {}
|
|
if (mounted) setState(() => audioPlayed = true);
|
|
}
|
|
|
|
Future<void> _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<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 => '写作',
|
|
};
|
|
}
|