feat: complete kouyu_english app codebase, A0 specifications and .gitignore
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
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 ready = await VoiceService.instance.initializeSpeech();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
microphoneReady = ready;
|
||||
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(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 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 {
|
||||
await VoiceService.instance.speak(task.audio!);
|
||||
if (mounted) setState(() => audioPlayed = true);
|
||||
}
|
||||
|
||||
Future<void> _mic() async {
|
||||
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;
|
||||
});
|
||||
}
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
listening = ready;
|
||||
speakingUnavailable = !ready;
|
||||
});
|
||||
}
|
||||
if (!ready && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('语音识别不可用;口语可稍后补测,不会判为语言错误。')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
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(
|
||||
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: audioPlayed ? () => _submit(i) : null,
|
||||
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,
|
||||
decoration: InputDecoration(
|
||||
hintText: task.skill == AssessmentSkill.speaking
|
||||
? '使用麦克风说出答案;文字仅作待评估记录'
|
||||
: '输入英文答案',
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
if (task.skill == AssessmentSkill.speaking)
|
||||
SecondaryButton(
|
||||
label: listening ? '停止录音' : '使用麦克风回答',
|
||||
onPressed: _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 => '写作',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user