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 => '写作',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,680 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/app_state.dart';
|
||||
import '../../core/ai_service.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/models.dart';
|
||||
import '../../core/seed_courses.dart';
|
||||
import '../../core/voice_service.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
import '../../widgets/lexicon_lookup.dart';
|
||||
|
||||
class DialogueScenePage extends StatelessWidget {
|
||||
const DialogueScenePage({super.key, required this.onStart});
|
||||
final VoidCallback onStart;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Eyebrow('按当前水平推荐'),
|
||||
Text('选一个场景,开口练习。', style: Theme.of(context).textTheme.headlineMedium),
|
||||
Text(
|
||||
'每次不超过 5 个回答轮,完成明确任务后结束。',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
SectionCard(
|
||||
tint: AppColors.softGreen,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'初次见面',
|
||||
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text('A0', style: TextStyle(color: AppColors.green)),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
'介绍姓名、地点、状态或喜好,并反问对方',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
PrimaryButton(label: '开始对话', onPressed: onStart),
|
||||
],
|
||||
),
|
||||
),
|
||||
const _LockedScene(title: '认识新同学', note: '完成当前场景后解锁'),
|
||||
const _LockedScene(title: '咖啡店', note: 'A1 · 尚未解锁'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _LockedScene extends StatelessWidget {
|
||||
const _LockedScene({required this.title, required this.note});
|
||||
final String title;
|
||||
final String note;
|
||||
@override
|
||||
Widget build(BuildContext context) => SectionCard(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.lock_outline, color: AppColors.muted),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text(note, style: Theme.of(context).textTheme.bodyMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class DialoguePage extends StatefulWidget {
|
||||
const DialoguePage({
|
||||
super.key,
|
||||
required this.state,
|
||||
required this.onFinished,
|
||||
this.isLessonDialogue = false,
|
||||
});
|
||||
final AppState state;
|
||||
final ValueChanged<DialogueSummaryData?> onFinished;
|
||||
final bool isLessonDialogue;
|
||||
@override
|
||||
State<DialoguePage> createState() => _DialoguePageState();
|
||||
}
|
||||
|
||||
class _DialoguePageState extends State<DialoguePage> {
|
||||
final controller = TextEditingController();
|
||||
final List<DialogueTurn> turns = [];
|
||||
int stage = 0;
|
||||
bool usedHelp = false;
|
||||
String? hint;
|
||||
bool listening = false;
|
||||
bool recording = false;
|
||||
bool playingRecording = false;
|
||||
bool usedVoice = false;
|
||||
bool transcriptEdited = false;
|
||||
String lastTranscript = '';
|
||||
String? recordingPath;
|
||||
bool waitingForReply = false;
|
||||
String? validationError;
|
||||
|
||||
LessonDialogue get script => widget.isLessonDialogue
|
||||
? dialogueBySegmentId(
|
||||
lessonById(widget.state.activeLessonId)
|
||||
.segments[widget.state.activeSegmentIndexFor(
|
||||
widget.state.activeLessonId,
|
||||
)]
|
||||
.id,
|
||||
widget.state.activeLessonId,
|
||||
)
|
||||
: const LessonDialogue(
|
||||
goal: '姓名、地点、状态或喜好,并反问',
|
||||
prompts: prompts,
|
||||
hints: hints,
|
||||
);
|
||||
|
||||
static const prompts = [
|
||||
'Hi! My name is Mia. What’s your name?',
|
||||
'Nice to meet you. Where are you from?',
|
||||
'Great! How are you today? Or what do you like?',
|
||||
'I’m good, thanks. Now ask me one question!',
|
||||
];
|
||||
static const hints = [
|
||||
'My name is Alex.',
|
||||
'I’m from Hong Kong.',
|
||||
'I’m good, thanks. / I like coffee.',
|
||||
'What’s your name? / Where are you from?',
|
||||
];
|
||||
static const translations = [
|
||||
'嗨!我叫 Mia。你叫什么名字?',
|
||||
'很高兴认识你。你来自哪里?',
|
||||
'很好!你今天怎么样?或者你喜欢什么?',
|
||||
'我很好,谢谢。现在请问我一个问题!',
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final draft = widget.state.dialogueDraft;
|
||||
final canRestore =
|
||||
widget.isLessonDialogue &&
|
||||
draft?.lessonId == widget.state.activeLessonId &&
|
||||
draft!.stage >= 0 &&
|
||||
draft.stage <= script.prompts.length &&
|
||||
draft.turns.isNotEmpty;
|
||||
if (canRestore) {
|
||||
stage = draft.stage;
|
||||
usedHelp = draft.usedHelp;
|
||||
turns.addAll(draft.turns);
|
||||
} else {
|
||||
turns.add(DialogueTurn(text: script.prompts.first, isLearner: false));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
VoiceService.instance.stopRecordingPlayback();
|
||||
if (!widget.state.keepRecordings) {
|
||||
VoiceService.instance.deleteRecording(recordingPath);
|
||||
}
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> send() async {
|
||||
final text = controller.text.trim();
|
||||
if (text.isEmpty || stage >= script.prompts.length || waitingForReply) {
|
||||
return;
|
||||
}
|
||||
if (!_matchesCurrentTask(text)) {
|
||||
setState(() => validationError = '这句还没有完成当前任务。可以查看提示后补充一次。');
|
||||
return;
|
||||
}
|
||||
widget.state.recordDialogueAttempt(
|
||||
taskId:
|
||||
'dialogue-${widget.isLessonDialogue ? _lessonSegmentId : 'a0-meet'}-$stage',
|
||||
rawAnswer: text,
|
||||
assisted: usedHelp,
|
||||
spoken: usedVoice && !transcriptEdited,
|
||||
recordingPath: widget.state.keepRecordings ? recordingPath : null,
|
||||
);
|
||||
if (!widget.state.keepRecordings) {
|
||||
VoiceService.instance.deleteRecording(recordingPath);
|
||||
recordingPath = null;
|
||||
}
|
||||
final nextStage = stage + 1;
|
||||
setState(() {
|
||||
turns.add(DialogueTurn(text: text, isLearner: true));
|
||||
controller.clear();
|
||||
stage = nextStage;
|
||||
waitingForReply = true;
|
||||
hint = null;
|
||||
validationError = null;
|
||||
});
|
||||
_saveDraft();
|
||||
final aiResponse = await AiService.instance.dialogueReply(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
requiredTask: nextStage < script.prompts.length
|
||||
? script.prompts[nextStage]
|
||||
: 'Say goodbye warmly after the learner asked a question.',
|
||||
history: turns
|
||||
.map(
|
||||
(turn) => <String, String>{
|
||||
'role': turn.isLearner ? 'user' : 'assistant',
|
||||
'content': turn.text,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
turns.add(
|
||||
DialogueTurn(
|
||||
text:
|
||||
aiResponse?.reply ??
|
||||
(nextStage < script.prompts.length
|
||||
? script.prompts[nextStage]
|
||||
: 'Wonderful — nice meeting you!'),
|
||||
isLearner: false,
|
||||
),
|
||||
);
|
||||
waitingForReply = false;
|
||||
});
|
||||
_saveDraft();
|
||||
}
|
||||
|
||||
void _saveDraft() {
|
||||
if (!widget.isLessonDialogue) return;
|
||||
widget.state.saveDialogueDraft(
|
||||
DialogueDraft(
|
||||
lessonId: widget.state.activeLessonId,
|
||||
stage: stage,
|
||||
turns: List.unmodifiable(turns),
|
||||
usedHelp: usedHelp,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _matchesCurrentTask(String response) {
|
||||
if (!widget.isLessonDialogue) {
|
||||
final text = response.toLowerCase();
|
||||
return switch (stage) {
|
||||
0 => RegExp(r"\b(i'?m|my name is)\s+[a-z]").hasMatch(text),
|
||||
1 => RegExp(r"\b(i'?m|i am)\s+from\s+[a-z]").hasMatch(text),
|
||||
2 => RegExp(
|
||||
r"\b(i'?m|i am)\s+(good|okay|tired)\b|\bi like\s+[a-z]",
|
||||
).hasMatch(text),
|
||||
_ => RegExp(r"\b(what('?s| is)|how are|do you like)\b").hasMatch(text),
|
||||
};
|
||||
}
|
||||
final text = response.toLowerCase();
|
||||
final lessonId = widget.state.activeLessonId;
|
||||
if (lessonById(lessonId).segments.length > 1) {
|
||||
return matchesSegmentDialogue(_lessonSegmentId, stage, text);
|
||||
}
|
||||
if (stage == 0) {
|
||||
return response.replaceAll(RegExp(r'[^a-zA-Z]'), '').length >= 2;
|
||||
}
|
||||
if (lessonId == 'a0-02' && stage == 1) {
|
||||
return RegExp(
|
||||
r'[a-z](?:[ -]?[a-z]){2,}',
|
||||
caseSensitive: false,
|
||||
).hasMatch(text);
|
||||
}
|
||||
final expected = switch (lessonId) {
|
||||
'a0-01' => ['nice', 'hello', 'what'],
|
||||
'a0-03' => ['how', 'good', 'okay', 'tired', 'bye'],
|
||||
'a0-04' => [
|
||||
'yes',
|
||||
'one',
|
||||
'two',
|
||||
'three',
|
||||
'four',
|
||||
'five',
|
||||
'six',
|
||||
'seven',
|
||||
'eight',
|
||||
'nine',
|
||||
'zero',
|
||||
'what',
|
||||
],
|
||||
'a0-05' => ['it', 'what', 'book', 'pen', 'bag', 'key'],
|
||||
'a0-06' => ['from', 'where', 'bye'],
|
||||
'a0-07' => ['this', 'who', 'mother', 'father', 'sister', 'brother'],
|
||||
'a0-08' => [
|
||||
'it',
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
'sunday',
|
||||
'clock',
|
||||
'what',
|
||||
],
|
||||
'a0-09' => ['like', 'yes', 'no', 'do'],
|
||||
'a0-10' => ['please', 'what', 'like'],
|
||||
_ => ['nice', 'how', 'what', 'from', 'like'],
|
||||
};
|
||||
return expected.any(text.contains);
|
||||
}
|
||||
|
||||
String get _lessonSegmentId => lessonById(widget.state.activeLessonId)
|
||||
.segments[widget.state.activeSegmentIndexFor(widget.state.activeLessonId)]
|
||||
.id;
|
||||
|
||||
void _finish() {
|
||||
if (widget.isLessonDialogue) {
|
||||
widget.state.clearDialogueDraft();
|
||||
widget.state.completeLessonDialogue();
|
||||
widget.onFinished(null);
|
||||
return;
|
||||
}
|
||||
final learnerTurns = turns.where((turn) => turn.isLearner).toList();
|
||||
final personalSentence = learnerTurns.isEmpty
|
||||
? 'My name is …'
|
||||
: learnerTurns.first.text;
|
||||
widget.state.addDialogueRecap(personalSentence);
|
||||
widget.onFinished(
|
||||
DialogueSummaryData(
|
||||
completedTasks: const ['介绍姓名', '说明来自哪里', '表达状态或喜好', '反问对方'],
|
||||
personalSentence: personalSentence,
|
||||
usedHelp: usedHelp,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _playLatestAi({required bool slow}) async {
|
||||
final latest = turns.where((turn) => !turn.isLearner).lastOrNull;
|
||||
if (latest == null) return;
|
||||
await VoiceService.instance.speak(latest.text, slow: slow);
|
||||
if (!slow || !mounted) return;
|
||||
setState(() => usedHelp = true);
|
||||
_saveDraft();
|
||||
}
|
||||
|
||||
Future<void> _toggleListening() async {
|
||||
if (listening) {
|
||||
await VoiceService.instance.stopListening();
|
||||
if (mounted) setState(() => listening = false);
|
||||
return;
|
||||
}
|
||||
final available = await VoiceService.instance.startListening((text, _) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
controller.text = text;
|
||||
usedVoice = true;
|
||||
lastTranscript = text;
|
||||
transcriptEdited = false;
|
||||
});
|
||||
});
|
||||
if (!mounted) return;
|
||||
setState(() => listening = available);
|
||||
if (!available) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('语音识别不可用;你仍可使用文字输入。')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleRecording() async {
|
||||
if (recording) {
|
||||
final path = await VoiceService.instance.stopRecording();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
recording = false;
|
||||
recordingPath = path;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
await VoiceService.instance.deleteRecording(recordingPath);
|
||||
final ready = await VoiceService.instance.startRecording();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
recording = ready;
|
||||
if (ready) recordingPath = null;
|
||||
});
|
||||
if (!ready) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('无法使用麦克风录音;请检查系统权限。')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playRecording() async {
|
||||
final path = recordingPath;
|
||||
if (path == null) return;
|
||||
setState(() => playingRecording = true);
|
||||
await VoiceService.instance.playRecording(path);
|
||||
if (mounted) setState(() => playingRecording = false);
|
||||
}
|
||||
|
||||
Future<void> _deleteRecording() async {
|
||||
await VoiceService.instance.deleteRecording(recordingPath);
|
||||
if (mounted) setState(() => recordingPath = null);
|
||||
}
|
||||
|
||||
void _showWord() => showLexiconLookup(
|
||||
context,
|
||||
state: widget.state,
|
||||
initialText: turns.where((turn) => !turn.isLearner).lastOrNull?.text ?? '',
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final finished = stage == script.prompts.length;
|
||||
return AppPage(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
'${widget.isLessonDialogue ? '课程对话' : '初次见面'} · ${finished ? 4 : stage + 1} / 4',
|
||||
),
|
||||
),
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Eyebrow('目标:${script.goal}'),
|
||||
Container(
|
||||
constraints: const BoxConstraints(minHeight: 250),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: turns.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, index) {
|
||||
final turn = turns[index];
|
||||
return Align(
|
||||
alignment: turn.isLearner
|
||||
? Alignment.centerRight
|
||||
: Alignment.centerLeft,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 290),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: turn.isLearner
|
||||
? AppColors.warm
|
||||
: AppColors.softGreen,
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
child: LexiconText(turn.text, state: widget.state),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (!finished) ...[
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_AssistChip(
|
||||
label: '提示',
|
||||
onTap: () {
|
||||
setState(() {
|
||||
usedHelp = true;
|
||||
hint = script.hints[stage];
|
||||
});
|
||||
_saveDraft();
|
||||
},
|
||||
),
|
||||
_AssistChip(
|
||||
label: '翻译',
|
||||
onTap: () {
|
||||
setState(() {
|
||||
usedHelp = true;
|
||||
hint = translations[stage];
|
||||
});
|
||||
_saveDraft();
|
||||
},
|
||||
),
|
||||
_AssistChip(
|
||||
label: '慢一点',
|
||||
onTap: () => _playLatestAi(slow: true),
|
||||
),
|
||||
_AssistChip(
|
||||
label: '重说',
|
||||
onTap: () => _playLatestAi(slow: false),
|
||||
),
|
||||
_AssistChip(label: '查词', onTap: _showWord),
|
||||
],
|
||||
),
|
||||
if (hint != null)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: Text(
|
||||
hint!,
|
||||
style: const TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
),
|
||||
if (validationError != null)
|
||||
Text(
|
||||
validationError!,
|
||||
style: const TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
TextField(
|
||||
controller: controller,
|
||||
onChanged: (value) => setState(() {
|
||||
if (usedVoice && value != lastTranscript) {
|
||||
transcriptEdited = true;
|
||||
}
|
||||
}),
|
||||
onSubmitted: (_) => send(),
|
||||
decoration: InputDecoration(
|
||||
hintText: '输入你的英文回答',
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
prefixIcon: IconButton(
|
||||
tooltip: listening ? '停止录音' : '语音输入',
|
||||
icon: Icon(
|
||||
listening ? Icons.stop_circle_outlined : Icons.mic_none,
|
||||
),
|
||||
onPressed: _toggleListening,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: waitingForReply ? null : send,
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
if (usedVoice)
|
||||
Text(
|
||||
transcriptEdited
|
||||
? '你修改了设备转写:这轮按文字作答保存。'
|
||||
: '这是设备转写;未修改提交后会作为语音尝试保存。',
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 12),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _toggleRecording,
|
||||
icon: Icon(
|
||||
recording
|
||||
? Icons.stop_circle_outlined
|
||||
: Icons.fiber_manual_record,
|
||||
),
|
||||
label: Text(recording ? '停止录音' : '录音回听'),
|
||||
),
|
||||
),
|
||||
if (recordingPath != null) ...[
|
||||
IconButton(
|
||||
tooltip: playingRecording ? '正在播放' : '回听录音',
|
||||
onPressed: playingRecording ? null : _playRecording,
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '删除录音',
|
||||
onPressed: _deleteRecording,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
Text(
|
||||
widget.state.keepRecordings
|
||||
? '录音只保存在本机,不会发送给 AI。'
|
||||
: '录音仅供本次回听,离开后自动删除。',
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 12),
|
||||
),
|
||||
const Text(
|
||||
'文字输入可完成教学对话;即使使用语音输入,本受控教学对话也不会单独记为独立口语证据。',
|
||||
style: TextStyle(color: AppColors.muted, fontSize: 12),
|
||||
),
|
||||
if (waitingForReply) const LinearProgressIndicator(),
|
||||
] else ...[
|
||||
SectionCard(
|
||||
tint: usedHelp ? AppColors.warm : AppColors.softGreen,
|
||||
child: Text(
|
||||
usedHelp
|
||||
? '本次使用过提示,课程会把关键表达安排到后续复习。'
|
||||
: '你完成了 4 个交际任务,接下来试着不看帮助独立表达。',
|
||||
),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: widget.isLessonDialogue ? '进入独立尝试' : '查看总结',
|
||||
onPressed: _finish,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssistChip extends StatelessWidget {
|
||||
const _AssistChip({required this.label, required this.onTap});
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
@override
|
||||
Widget build(BuildContext context) => ActionChip(
|
||||
label: Text(label),
|
||||
backgroundColor: AppColors.surface,
|
||||
side: const BorderSide(color: AppColors.line),
|
||||
onPressed: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
class DialogueSummaryPage extends StatelessWidget {
|
||||
const DialogueSummaryPage({
|
||||
super.key,
|
||||
required this.summary,
|
||||
required this.onHome,
|
||||
required this.onLesson,
|
||||
required this.onRetry,
|
||||
});
|
||||
final DialogueSummaryData summary;
|
||||
final VoidCallback onHome;
|
||||
final VoidCallback onLesson;
|
||||
final VoidCallback onRetry;
|
||||
@override
|
||||
Widget build(BuildContext context) => AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Eyebrow('对话完成'),
|
||||
Text('你完成了自我介绍!', style: Theme.of(context).textTheme.headlineMedium),
|
||||
Text('你完成了 ${summary.completedTasks.join('、')}。'),
|
||||
SectionCard(
|
||||
child: _SummaryLine(
|
||||
icon: Icons.check_circle_outline,
|
||||
title: '你的个人复习卡(明天出现)',
|
||||
sentence: summary.personalSentence,
|
||||
tint: AppColors.green,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
summary.usedHelp
|
||||
? '本次使用过提示。下次可以先不看提示,再试一次。'
|
||||
: '下次试着换一个名字、地点或喜好,再完成同一任务。',
|
||||
),
|
||||
SecondaryButton(label: '再练一次初次见面', onPressed: onRetry),
|
||||
PrimaryButton(label: '开始一节课程', onPressed: onLesson),
|
||||
SecondaryButton(label: '回到首页', onPressed: onHome),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _SummaryLine extends StatelessWidget {
|
||||
const _SummaryLine({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.sentence,
|
||||
required this.tint,
|
||||
});
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String sentence;
|
||||
final Color tint;
|
||||
@override
|
||||
Widget build(BuildContext context) => Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, color: tint),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(color: tint, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(sentence),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/app_state.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/seed_courses.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
|
||||
class HomePage extends StatelessWidget {
|
||||
const HomePage({
|
||||
super.key,
|
||||
required this.state,
|
||||
required this.onStartPrimaryTask,
|
||||
required this.onOpenDialogue,
|
||||
required this.onResumeLessonDialogue,
|
||||
});
|
||||
|
||||
final AppState state;
|
||||
final VoidCallback onStartPrimaryTask;
|
||||
final VoidCallback onOpenDialogue;
|
||||
final VoidCallback onResumeLessonDialogue;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isReview = state.reviewIsPrimary;
|
||||
final count = state.dueReviewCount;
|
||||
final activeLesson = lessonById(state.activeLessonId);
|
||||
final activeSegment = state.activeSegmentIndexFor(activeLesson.id) + 1;
|
||||
final resumeDialogue = state.hasResumableLessonDialogue;
|
||||
final primaryTitle = resumeDialogue
|
||||
? '继续第 ${activeLesson.number} 课的课程对话'
|
||||
: isReview
|
||||
? '先复习 $count 项'
|
||||
: '第 ${activeLesson.number} 课 · ${activeLesson.title}${activeLesson.segments.length > 1 ? ' · 第 $activeSegment/${activeLesson.segments.length} 段' : ''}';
|
||||
final primaryNote = resumeDialogue
|
||||
? '已保留你的对话进度和提示状态。'
|
||||
: isReview
|
||||
? (state.reviewBacklog ? '有积压项目;先花几分钟清掉到期复习。' : '昨天练过的关键句,今天换个情境再用一次。')
|
||||
: '预热词汇 → 听说读写 → 对话 → 独立尝试';
|
||||
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
spacing: 16,
|
||||
children: [
|
||||
const Eyebrow('今天的学习'),
|
||||
Text(
|
||||
'今天,说几句能用的英语。',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
Text(
|
||||
isReview ? '到期复习优先;完成后再进入新内容。' : '没有到期复习,继续完成一个小任务。',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
SectionCard(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.psychology_outlined, color: AppColors.green),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'已接触 ${state.knownItemCount} 项 · 可独立使用 ${state.usableMasteryCount} 项',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SectionCard(
|
||||
tint: AppColors.softGreen,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
primaryTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
isReview ? '${(count * 2).clamp(2, 10)} 分钟' : '12 分钟',
|
||||
style: const TextStyle(color: AppColors.green),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
primaryNote,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
PrimaryButton(
|
||||
label: resumeDialogue
|
||||
? '继续对话'
|
||||
: isReview
|
||||
? '开始复习'
|
||||
: '开始今天的学习',
|
||||
onPressed: resumeDialogue
|
||||
? onResumeLessonDialogue
|
||||
: onStartPrimaryTask,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SectionCard(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Text(
|
||||
'AI 情境对话',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
'初次见面 · 4 个任务 · 文字或语音输入',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
SecondaryButton(label: '开始情境对话', onPressed: onOpenDialogue),
|
||||
],
|
||||
),
|
||||
),
|
||||
const _FrameworkNote(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FrameworkNote extends StatelessWidget {
|
||||
const _FrameworkNote();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.warm,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: AppColors.warmInk),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'文字回答可用于理解与写作练习;口语掌握须以录音或语音识别结果为准。可在“我的”配置 AI 服务。',
|
||||
style: TextStyle(
|
||||
color: AppColors.warmInk,
|
||||
fontSize: 13,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,211 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/app_state.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/models.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
|
||||
class WelcomePage extends StatefulWidget {
|
||||
const WelcomePage({super.key, required this.state, required this.onContinue});
|
||||
|
||||
final AppState state;
|
||||
final VoidCallback onContinue;
|
||||
|
||||
@override
|
||||
State<WelcomePage> createState() => _WelcomePageState();
|
||||
}
|
||||
|
||||
class _WelcomePageState extends State<WelcomePage> {
|
||||
late LearningGoal selectedGoal;
|
||||
late int selectedMinutes;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
selectedGoal = widget.state.goal;
|
||||
selectedMinutes = widget.state.dailyMinutes;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
spacing: 20,
|
||||
children: [
|
||||
const Eyebrow('欢迎'),
|
||||
Text(
|
||||
'每天 20 分钟,\n说出能用的英语。',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const Text('从真实生活场景开始,先做到听得懂、说得清。'),
|
||||
_ChoiceGroup<LearningGoal>(
|
||||
title: '你的主要目标',
|
||||
value: selectedGoal,
|
||||
options: const {
|
||||
LearningGoal.dailyLife: '日常生活',
|
||||
LearningGoal.travel: '旅行',
|
||||
LearningGoal.workStarter: '工作起步',
|
||||
},
|
||||
onChanged: (value) => setState(() => selectedGoal = value),
|
||||
),
|
||||
_ChoiceGroup<int>(
|
||||
title: '每天学习多久?',
|
||||
value: selectedMinutes,
|
||||
options: const {10: '10 分钟', 20: '20 分钟', 30: '30 分钟'},
|
||||
onChanged: (value) => setState(() => selectedMinutes = value),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: '继续',
|
||||
onPressed: () {
|
||||
widget.state
|
||||
..setGoal(selectedGoal)
|
||||
..setDailyMinutes(selectedMinutes);
|
||||
widget.onContinue();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PlacementPage extends StatefulWidget {
|
||||
const PlacementPage({super.key, required this.state, required this.onStart});
|
||||
|
||||
final AppState state;
|
||||
final VoidCallback onStart;
|
||||
|
||||
@override
|
||||
State<PlacementPage> createState() => _PlacementPageState();
|
||||
}
|
||||
|
||||
class _PlacementPageState extends State<PlacementPage> {
|
||||
late PlacementLevel selected;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
selected = widget.state.placement;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const labels = {
|
||||
PlacementLevel.beginner: ('完全零基础', '从你好、自我介绍开始'),
|
||||
PlacementLevel.someBasics: ('能说一点', '认识常见单词或短句'),
|
||||
PlacementLevel.simpleConversation: ('能简单对话', '想说得更自然、更有信心'),
|
||||
};
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
spacing: 14,
|
||||
children: [
|
||||
const Eyebrow('第一步 · 约 3 分钟'),
|
||||
Text('从哪里开始?', style: Theme.of(context).textTheme.headlineMedium),
|
||||
const Text('选择最接近的状态,之后随时能调整。'),
|
||||
for (final option in PlacementLevel.values)
|
||||
_PlacementChoice(
|
||||
option: option,
|
||||
labels: labels[option]!,
|
||||
isSelected: selected == option,
|
||||
onTap: () => setState(() => selected = option),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: '开始 3 分钟定位',
|
||||
onPressed: () {
|
||||
widget.state.setPlacement(selected);
|
||||
widget.onStart();
|
||||
},
|
||||
),
|
||||
Center(
|
||||
child: TextButton(
|
||||
onPressed: widget.onStart,
|
||||
child: const Text('直接从第一课开始'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlacementChoice extends StatelessWidget {
|
||||
const _PlacementChoice({
|
||||
required this.option,
|
||||
required this.labels,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final PlacementLevel option;
|
||||
final (String, String) labels;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SectionCard(
|
||||
tint: isSelected ? AppColors.softGreen : null,
|
||||
onTap: onTap,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isSelected ? Icons.radio_button_checked : Icons.radio_button_off,
|
||||
color: isSelected ? AppColors.green : AppColors.muted,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
labels.$1,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(labels.$2, style: Theme.of(context).textTheme.bodyMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ChoiceGroup<T> extends StatelessWidget {
|
||||
const _ChoiceGroup({
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.options,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final T value;
|
||||
final Map<T, String> options;
|
||||
final ValueChanged<T> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SectionCard(
|
||||
child: SpacedColumn(
|
||||
spacing: 10,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: options.entries
|
||||
.map(
|
||||
(entry) => ChoiceChip(
|
||||
label: Text(entry.value),
|
||||
selected: value == entry.key,
|
||||
selectedColor: AppColors.softGreen,
|
||||
onSelected: (_) => onChanged(entry.key),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/app_state.dart';
|
||||
import '../../core/assessment_bank.dart';
|
||||
import '../../core/ai_service.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/models.dart';
|
||||
import '../../core/voice_service.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
|
||||
class ProgressPage extends StatelessWidget {
|
||||
const ProgressPage({
|
||||
super.key,
|
||||
required this.state,
|
||||
required this.onSettings,
|
||||
required this.onOpenAssessment,
|
||||
});
|
||||
final AppState state;
|
||||
final VoidCallback onSettings;
|
||||
final ValueChanged<AssessmentPack> onOpenAssessment;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final recall = state.mastery.values
|
||||
.where((item) => item.status == MasteryStatus.recall)
|
||||
.length;
|
||||
final recentEvidence = state.attemptEvidence.reversed.take(5).toList();
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Eyebrow('当前:A0 起步'),
|
||||
Text('进度来自掌握证据。', style: Theme.of(context).textTheme.headlineMedium),
|
||||
const Text('不是上完固定课数就升级。核心表达需要在不同时间、不同情境中独立用出。'),
|
||||
_AbilityRow(
|
||||
icon: Icons.psychology_outlined,
|
||||
title: '已接触',
|
||||
note: '${state.knownItemCount} 个词句或任务',
|
||||
completed: state.knownItemCount > 0,
|
||||
),
|
||||
_AbilityRow(
|
||||
icon: Icons.replay_outlined,
|
||||
title: '能回忆',
|
||||
note: '$recall 项正在巩固',
|
||||
completed: recall > 0,
|
||||
),
|
||||
_AbilityRow(
|
||||
icon: Icons.record_voice_over_outlined,
|
||||
title: '可使用',
|
||||
note: '${state.coreUsableCount} / 48 项核心内容已获得独立使用证据',
|
||||
completed: state.coreUsableCount >= 48,
|
||||
),
|
||||
_AbilityRow(
|
||||
icon: Icons.verified_outlined,
|
||||
title: '已掌握(间隔复习)',
|
||||
note: '${state.coreMasteredCount} / 30 项达到四次间隔复习要求',
|
||||
completed: state.coreMasteredCount >= 30,
|
||||
),
|
||||
_AbilityRow(
|
||||
icon: Icons.assignment_turned_in_outlined,
|
||||
title: '两套四技能评估',
|
||||
note: state.hasTwoValidAssessmentPasses
|
||||
? '两套不同题组已在有效时间内通过'
|
||||
: '尚需两套不同题组通过,间隔至少 24 小时',
|
||||
completed: state.hasTwoValidAssessmentPasses,
|
||||
),
|
||||
SectionCard(
|
||||
tint: state.dueReviewCount > 0
|
||||
? AppColors.warm
|
||||
: AppColors.softGreen,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
state.dueReviewCount > 0
|
||||
? Icons.schedule
|
||||
: Icons.check_circle_outline,
|
||||
color: state.dueReviewCount > 0
|
||||
? AppColors.warmInk
|
||||
: AppColors.green,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
state.dueReviewCount > 0
|
||||
? '有 ${state.dueReviewCount} 项到期复习,完成后会更新掌握证据。'
|
||||
: '目前没有到期复习。',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SectionCard(
|
||||
child: Text(
|
||||
'进入下一阶段条件:60 项固定核心内容中至少 48 项可使用、30 项已掌握,且两套不同题组的听说读写评估都通过并间隔至少 24 小时。',
|
||||
),
|
||||
),
|
||||
if (state.a0Passed)
|
||||
const SectionCard(
|
||||
tint: AppColors.softGreen,
|
||||
child: Text('A0 已通过。A1 主线内容尚未提供,可继续进行 A0 巩固。'),
|
||||
),
|
||||
if (recentEvidence.isNotEmpty) ...[
|
||||
const Text(
|
||||
'最近学习证据',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const Text('用于解释进度,不是公开记录。'),
|
||||
for (final evidence in recentEvidence)
|
||||
SectionCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${_evidenceLabel(evidence.outcome)} · ${evidence.skill}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
if (evidence.rawAnswer?.isNotEmpty == true) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text('你的回答:${evidence.rawAnswer}'),
|
||||
],
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_relativeTime(evidence.createdAt),
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const Text(
|
||||
'A0 四技能评估',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
'评估不会提供翻译或句框;第二套题组须在第一套通过至少 24 小时后完成。',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
for (final pack in a0AssessmentPacks)
|
||||
SecondaryButton(
|
||||
label: state.canStartAssessmentPack(pack.id)
|
||||
? '开始 ${pack.id}'
|
||||
: '${pack.id} 需等待第一套评估通过 24 小时',
|
||||
onPressed: state.canStartAssessmentPack(pack.id)
|
||||
? () => onOpenAssessment(pack)
|
||||
: null,
|
||||
),
|
||||
SecondaryButton(label: '调整学习与 AI 设置', onPressed: onSettings),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _evidenceLabel(EvidenceKind outcome) => switch (outcome) {
|
||||
EvidenceKind.independentSuccess => '独立完成',
|
||||
EvidenceKind.assisted => '带提示完成',
|
||||
EvidenceKind.languageError => '需要复核',
|
||||
EvidenceKind.pending => '稍后完成',
|
||||
EvidenceKind.exposure => '已查看',
|
||||
};
|
||||
|
||||
static String _relativeTime(DateTime time) {
|
||||
final difference = DateTime.now().difference(time);
|
||||
if (difference.inMinutes < 1) return '刚刚';
|
||||
if (difference.inHours < 1) return '${difference.inMinutes} 分钟前';
|
||||
if (difference.inDays < 1) return '${difference.inHours} 小时前';
|
||||
return '${difference.inDays} 天前';
|
||||
}
|
||||
}
|
||||
|
||||
class _AbilityRow extends StatelessWidget {
|
||||
const _AbilityRow({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.note,
|
||||
required this.completed,
|
||||
});
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String note;
|
||||
final bool completed;
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: completed ? AppColors.green : AppColors.muted),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
Text(note, style: Theme.of(context).textTheme.bodyMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class SettingsPage extends StatefulWidget {
|
||||
const SettingsPage({super.key, required this.state});
|
||||
final AppState state;
|
||||
@override
|
||||
State<SettingsPage> createState() => _SettingsPageState();
|
||||
}
|
||||
|
||||
class _SettingsPageState extends State<SettingsPage> {
|
||||
late final TextEditingController endpoint;
|
||||
late final TextEditingController model;
|
||||
final apiKey = TextEditingController();
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
endpoint = TextEditingController(text: widget.state.aiEndpoint);
|
||||
model = TextEditingController(text: widget.state.aiModel);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
endpoint.dispose();
|
||||
model.dispose();
|
||||
apiKey.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AppPage(
|
||||
appBar: AppBar(title: const Text('学习设置')),
|
||||
child: SpacedColumn(
|
||||
spacing: 4,
|
||||
children: [
|
||||
_SettingTile(
|
||||
title: '每日学习时间',
|
||||
subtitle: '${widget.state.dailyMinutes} 分钟',
|
||||
onTap: () => _chooseDuration(context),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
title: const Text('默认显示中文提示'),
|
||||
subtitle: const Text('A0 阶段开启'),
|
||||
value: widget.state.showChineseHints,
|
||||
activeThumbColor: AppColors.green,
|
||||
onChanged: widget.state.toggleChineseHints,
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
title: const Text('保存原始录音'),
|
||||
subtitle: const Text('录音功能启用后仅保存在本机'),
|
||||
value: widget.state.keepRecordings,
|
||||
activeThumbColor: AppColors.green,
|
||||
onChanged: widget.state.toggleKeepRecordings,
|
||||
),
|
||||
_SettingTile(
|
||||
title: '已保存的录音',
|
||||
subtitle: '回听或删除本机英语练习录音',
|
||||
onTap: () => _showRecordings(context),
|
||||
),
|
||||
_SettingTile(
|
||||
title: '清除已保存的录音',
|
||||
subtitle: '只删除本机原始音频,不影响学习进度或 AI 密钥',
|
||||
onTap: () => _confirmDeleteRecordings(context),
|
||||
),
|
||||
const Divider(height: 28),
|
||||
const Text(
|
||||
'AI 对话服务',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const Text(
|
||||
'订阅版 ChatGPT / Gemini 不能直接作为 App API 使用。请使用自己的 API Key,或填写兼容 OpenAI 接口的 CLIProxyAPI 地址。密钥不在此页面保存。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
DropdownButtonFormField<AiProviderType>(
|
||||
initialValue: widget.state.aiProvider,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '服务类型',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: AiProviderType.mock,
|
||||
child: Text('内置练习模式(无需网络)'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: AiProviderType.openAi,
|
||||
child: Text('OpenAI API'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: AiProviderType.gemini,
|
||||
child: Text('Gemini API'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: AiProviderType.compatible,
|
||||
child: Text('OpenAI 兼容 / CLIProxyAPI'),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
widget.state.setAiProvider(value);
|
||||
if (value == AiProviderType.gemini &&
|
||||
endpoint.text.trim().isEmpty) {
|
||||
endpoint.text =
|
||||
'https://generativelanguage.googleapis.com/v1beta';
|
||||
model.text = model.text.trim().isEmpty
|
||||
? 'gemini-2.5-flash'
|
||||
: model.text;
|
||||
}
|
||||
},
|
||||
),
|
||||
TextField(
|
||||
controller: endpoint,
|
||||
keyboardType: TextInputType.url,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Base URL(可选)',
|
||||
hintText:
|
||||
'OpenAI / 兼容: https://…/v1;Gemini: https://generativelanguage.googleapis.com/v1beta',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: model,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '模型名称(可选)',
|
||||
hintText: '例如 gpt-4.1-mini',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: apiKey,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'API Key(仅保存到设备安全存储)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: '保存服务设置',
|
||||
onPressed: () async {
|
||||
widget.state.saveAiConfiguration(
|
||||
endpoint: endpoint.text,
|
||||
model: model.text,
|
||||
);
|
||||
if (apiKey.text.trim().isNotEmpty) {
|
||||
await AiService.instance.saveApiKey(apiKey.text);
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('服务设置已保存。')));
|
||||
},
|
||||
),
|
||||
SecondaryButton(
|
||||
label: '测试连接',
|
||||
onPressed: () async {
|
||||
final result = await AiService.instance.testConnection(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: endpoint.text,
|
||||
model: model.text,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(result.message)));
|
||||
},
|
||||
),
|
||||
const Divider(height: 28),
|
||||
_DangerAction(
|
||||
label: '清除学习进度',
|
||||
message:
|
||||
'这会清除本机的课程/分段进度、草稿、复习队列、学习作答证据、掌握记录、对话草稿、AI 补练缓存和阶段评估结果,且不可恢复。不会删除 API 密钥、AI 服务设置或已保存的原始录音;录音请使用下方独立操作删除。',
|
||||
onConfirm: widget.state.clearProgress,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> _confirmDeleteRecordings(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('清除已保存的录音?'),
|
||||
content: const Text('这些原始音频只保存在本机。删除后无法恢复,学习进度不会改变。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(backgroundColor: Colors.redAccent),
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('清除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
final count = await VoiceService.instance.deleteAllRecordings();
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(count == 0 ? '没有已保存的录音。' : '已清除 $count 段本机录音。')),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showRecordings(
|
||||
BuildContext context,
|
||||
) => showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (context) => SafeArea(
|
||||
child: FutureBuilder<List<String>>(
|
||||
future: VoiceService.instance.listRecordingPaths(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
final paths = snapshot.data!;
|
||||
if (paths.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text('还没有保存的录音。开启“保存原始录音”后,在跟读步骤完成录音即可在这里回听。'),
|
||||
);
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Text(
|
||||
'已保存的录音',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
for (final path in paths)
|
||||
SectionCard(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.mic_none, color: AppColors.green),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
path.split('/').last.replaceAll('.m4a', ''),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '回听',
|
||||
onPressed: () =>
|
||||
VoiceService.instance.playRecording(path),
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '删除',
|
||||
onPressed: () async {
|
||||
await VoiceService.instance.deleteRecording(path);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> _chooseDuration(BuildContext context) async {
|
||||
final value = await showModalBottomSheet<int>(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final minute in [10, 20, 30])
|
||||
ListTile(
|
||||
title: Text('$minute 分钟'),
|
||||
trailing: widget.state.dailyMinutes == minute
|
||||
? const Icon(Icons.check)
|
||||
: null,
|
||||
onTap: () => Navigator.pop(context, minute),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (value != null) widget.state.setDailyMinutes(value);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingTile extends StatelessWidget {
|
||||
const _SettingTile({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.onTap,
|
||||
});
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final VoidCallback? onTap;
|
||||
@override
|
||||
Widget build(BuildContext context) => ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
title: Text(title),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
class _DangerAction extends StatelessWidget {
|
||||
const _DangerAction({
|
||||
required this.label,
|
||||
required this.message,
|
||||
required this.onConfirm,
|
||||
});
|
||||
final String label;
|
||||
final String message;
|
||||
final VoidCallback? onConfirm;
|
||||
@override
|
||||
Widget build(BuildContext context) => ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
title: Text(label, style: const TextStyle(color: Colors.redAccent)),
|
||||
trailing: const Icon(Icons.chevron_right, color: Colors.redAccent),
|
||||
onTap: () => showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
var acknowledged = false;
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) => AlertDialog(
|
||||
title: Text(label),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(message),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'会清除:课程步骤、复习、掌握记录、学习证据、评估草稿和课程对话草稿。\n不会清除:已保存录音、AI API Key 与应用设置。',
|
||||
),
|
||||
CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: acknowledged,
|
||||
onChanged: (value) =>
|
||||
setDialogState(() => acknowledged = value ?? false),
|
||||
title: const Text('我了解这些本机学习数据无法恢复'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.redAccent,
|
||||
),
|
||||
onPressed: acknowledged
|
||||
? () {
|
||||
onConfirm?.call();
|
||||
Navigator.pop(context);
|
||||
}
|
||||
: null,
|
||||
child: const Text('确认清除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,697 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/app_state.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/ai_service.dart';
|
||||
import '../../core/generated_content.dart';
|
||||
import '../../core/models.dart';
|
||||
import '../../core/review_feedback.dart';
|
||||
import '../../core/a0_core.dart';
|
||||
import '../../core/voice_service.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
|
||||
class ReviewPage extends StatefulWidget {
|
||||
const ReviewPage({
|
||||
super.key,
|
||||
required this.state,
|
||||
required this.onFinished,
|
||||
required this.onOpenAdaptiveLesson,
|
||||
});
|
||||
final AppState state;
|
||||
final VoidCallback onFinished;
|
||||
final VoidCallback onOpenAdaptiveLesson;
|
||||
|
||||
@override
|
||||
State<ReviewPage> createState() => _ReviewPageState();
|
||||
}
|
||||
|
||||
class _ReviewPageState extends State<ReviewPage> {
|
||||
final controller = TextEditingController();
|
||||
bool showHint = false;
|
||||
bool usedHelp = false;
|
||||
String? validationMessage;
|
||||
bool generatingVariant = false;
|
||||
bool generatingLesson = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _next(ReviewItem item, {required bool assisted}) {
|
||||
final result = ReviewFeedback.check(item, controller.text);
|
||||
if (!result.complete) {
|
||||
setState(() => validationMessage = result.message);
|
||||
return;
|
||||
}
|
||||
widget.state.completeReview(
|
||||
item,
|
||||
assisted: assisted,
|
||||
rawAnswer: controller.text.trim(),
|
||||
);
|
||||
controller.clear();
|
||||
setState(() {
|
||||
showHint = false;
|
||||
usedHelp = false;
|
||||
validationMessage = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _generateVariant(ReviewItem item) async {
|
||||
setState(() => generatingVariant = true);
|
||||
final variant = await AiService.instance.generateReviewVariant(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
targetItemId: item.id,
|
||||
basePrompt: item.prompt,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => generatingVariant = false);
|
||||
if (variant == null) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('未获得合格变式,已继续使用本地审核题。')));
|
||||
return;
|
||||
}
|
||||
widget.state.applyGeneratedReviewVariant(variant);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('已生成并审核新题面,核心学习目标不变。')));
|
||||
}
|
||||
|
||||
Future<void> _generateAdaptiveLesson(ReviewItem item) async {
|
||||
final label = a0CoreItems[item.id];
|
||||
if (label == null) return;
|
||||
setState(() => generatingLesson = true);
|
||||
final lesson = await AiService.instance.generateAdaptiveLesson(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
targetItemId: item.id,
|
||||
targetLabel: label,
|
||||
);
|
||||
if (lesson == null) {
|
||||
if (mounted) setState(() => generatingLesson = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('未生成合格补练,继续使用本地复习题。')));
|
||||
}
|
||||
return;
|
||||
}
|
||||
final approved = await AiService.instance.auditGeneratedLesson(
|
||||
provider: widget.state.aiProvider,
|
||||
endpoint: widget.state.aiEndpoint,
|
||||
model: widget.state.aiModel,
|
||||
lesson: lesson,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => generatingLesson = false);
|
||||
if (!approved) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('补练未通过独立审核,未保存。')));
|
||||
return;
|
||||
}
|
||||
widget.state.cacheApprovedAdaptiveLesson(
|
||||
lesson,
|
||||
auditor: '${widget.state.aiProvider.name}:${widget.state.aiModel}',
|
||||
);
|
||||
widget.onOpenAdaptiveLesson();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final item = widget.state.dueReviews.isEmpty
|
||||
? null
|
||||
: widget.state.dueReviews.first;
|
||||
if (item == null) {
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Eyebrow('今日复习已完成'),
|
||||
Text(
|
||||
'到期项目已安排下次复练。',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const Text('记住不是一次答对就结束;系统会在不同间隔再次确认你仍能用出来。'),
|
||||
PrimaryButton(label: '回到首页', onPressed: widget.onFinished),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final checkpoint =
|
||||
widget.state.mastery[item.id]?.checkpoint ?? item.successfulReviews;
|
||||
final checkpointLabel = checkpoint >= 4
|
||||
? '30 天抽查'
|
||||
: '第 ${checkpoint + 1} / 4 个间隔检查点';
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Eyebrow('今天复习 · ${widget.state.dueReviewCount} 项待完成'),
|
||||
Text('不看答案,试着回答。', style: Theme.of(context).textTheme.headlineMedium),
|
||||
Text(
|
||||
'目标技能:${item.skill}',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
Text(
|
||||
checkpointLabel,
|
||||
style: const TextStyle(color: AppColors.green, fontSize: 13),
|
||||
),
|
||||
if (item.isAiGenerated)
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'AI 生成题面 · 已通过客户端结构审核',
|
||||
style: TextStyle(color: AppColors.muted, fontSize: 12),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
widget.state.reportGeneratedReviewVariant(item);
|
||||
controller.clear();
|
||||
setState(() {
|
||||
showHint = false;
|
||||
usedHelp = false;
|
||||
validationMessage = null;
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('已隔离该 AI 题面,并换回本地审核题。')),
|
||||
);
|
||||
},
|
||||
child: const Text('内容有问题'),
|
||||
),
|
||||
],
|
||||
),
|
||||
SectionCard(
|
||||
tint: AppColors.softGreen,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Text(
|
||||
'情境',
|
||||
style: TextStyle(
|
||||
color: AppColors.green,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(item.prompt, style: const TextStyle(fontSize: 19)),
|
||||
],
|
||||
),
|
||||
),
|
||||
TextField(
|
||||
controller: controller,
|
||||
minLines: 2,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: const InputDecoration(
|
||||
hintText: '输入你会怎么回答',
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
ActionChip(
|
||||
label: const Text('需要提示'),
|
||||
onPressed: () => setState(() {
|
||||
showHint = true;
|
||||
usedHelp = true;
|
||||
validationMessage = null;
|
||||
}),
|
||||
),
|
||||
ActionChip(
|
||||
label: const Text('稍后复习'),
|
||||
onPressed: () {
|
||||
widget.state.postponeReview(item);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
ActionChip(
|
||||
label: const Text('暂时想不起来'),
|
||||
onPressed: () {
|
||||
widget.state.reportReviewFailure(item);
|
||||
controller.clear();
|
||||
setState(() {
|
||||
showHint = false;
|
||||
usedHelp = false;
|
||||
validationMessage = null;
|
||||
});
|
||||
},
|
||||
),
|
||||
ActionChip(
|
||||
label: Text(generatingVariant ? '正在生成…' : '生成变式'),
|
||||
onPressed: generatingVariant
|
||||
? null
|
||||
: () => _generateVariant(item),
|
||||
),
|
||||
if (a0CoreItems.containsKey(item.id))
|
||||
ActionChip(
|
||||
label: Text(generatingLesson ? '审核补练中…' : '生成四技能补练'),
|
||||
onPressed: generatingLesson
|
||||
? null
|
||||
: () => _generateAdaptiveLesson(item),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (showHint)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: Text(
|
||||
'参考:${item.hint}\n目标:${item.target}',
|
||||
style: const TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
),
|
||||
if (validationMessage != null)
|
||||
Text(
|
||||
validationMessage!,
|
||||
style: const TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: usedHelp ? '带提示完成' : '我能独立回答',
|
||||
onPressed: controller.text.trim().isEmpty
|
||||
? null
|
||||
: () => _next(item, assisted: usedHelp),
|
||||
),
|
||||
const Text(
|
||||
'提示后完成会在明天换题复练;第一次想不起来先复核,连续两次才会降低当前检查点。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Displays an audited AI-authored reinforcement lesson. It remains a
|
||||
/// teaching activity: the existing local review and assessment systems own
|
||||
/// all mastery decisions.
|
||||
class AdaptiveLessonPage extends StatefulWidget {
|
||||
const AdaptiveLessonPage({
|
||||
super.key,
|
||||
required this.state,
|
||||
required this.onFinished,
|
||||
});
|
||||
final AppState state;
|
||||
final VoidCallback onFinished;
|
||||
|
||||
@override
|
||||
State<AdaptiveLessonPage> createState() => _AdaptiveLessonPageState();
|
||||
}
|
||||
|
||||
class _AdaptiveLessonPageState extends State<AdaptiveLessonPage> {
|
||||
final controller = TextEditingController();
|
||||
int index = 0;
|
||||
bool showReference = false;
|
||||
String? answerFeedback;
|
||||
bool listening = false;
|
||||
bool usedVoice = false;
|
||||
bool transcriptEdited = false;
|
||||
bool transcriptConfirmed = false;
|
||||
String lastTranscript = '';
|
||||
bool recording = false;
|
||||
bool playingRecording = false;
|
||||
String? recordingPath;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final lesson = widget.state.cachedAdaptiveLesson;
|
||||
if (lesson != null &&
|
||||
widget.state.adaptiveLessonDraftId == lesson.lessonId) {
|
||||
index = widget.state.adaptiveLessonDraftIndex.clamp(
|
||||
0,
|
||||
lesson.tasks.length,
|
||||
);
|
||||
controller.text = widget.state.adaptiveLessonDraftAnswer;
|
||||
showReference = widget.state.adaptiveLessonDraftReferenceShown;
|
||||
usedVoice = widget.state.adaptiveLessonDraftUsedVoice;
|
||||
transcriptEdited = widget.state.adaptiveLessonDraftTranscriptEdited;
|
||||
transcriptConfirmed = widget.state.adaptiveLessonDraftTranscriptConfirmed;
|
||||
lastTranscript = widget.state.adaptiveLessonDraftOriginalTranscript;
|
||||
recordingPath = widget.state.adaptiveLessonDraftRecordingPath;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
VoiceService.instance.stopListening();
|
||||
VoiceService.instance.stopRecordingPlayback();
|
||||
if (!widget.state.keepRecordings) {
|
||||
VoiceService.instance.deleteRecording(recordingPath);
|
||||
}
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _saveDraft(GeneratedLesson lesson) {
|
||||
widget.state.saveAdaptiveLessonDraft(
|
||||
lesson: lesson,
|
||||
taskIndex: index,
|
||||
answer: controller.text,
|
||||
referenceShown: showReference,
|
||||
usedVoice: usedVoice,
|
||||
transcriptEdited: transcriptEdited,
|
||||
transcriptConfirmed: transcriptConfirmed,
|
||||
originalTranscript: lastTranscript,
|
||||
recordingPath: widget.state.keepRecordings ? recordingPath : null,
|
||||
);
|
||||
}
|
||||
|
||||
void _advance(GeneratedLesson lesson, GeneratedLessonTask task) {
|
||||
final correct = matchesAdaptiveLessonAnswer(task, controller.text);
|
||||
if (!showReference && !correct) {
|
||||
setState(() => answerFeedback = '还缺少目标表达中的关键信息。可以重试,或查看参考后以教学模式继续。');
|
||||
return;
|
||||
}
|
||||
widget.state.recordAdaptiveLessonTask(
|
||||
lesson: lesson,
|
||||
task: task,
|
||||
rawAnswer: controller.text.trim(),
|
||||
assisted: showReference,
|
||||
correct: correct,
|
||||
inputMode: usedVoice && !transcriptEdited && transcriptConfirmed
|
||||
? 'speechToText'
|
||||
: 'text',
|
||||
recordingPath: widget.state.keepRecordings ? recordingPath : null,
|
||||
originalTranscript: usedVoice ? lastTranscript : null,
|
||||
transcriptConfirmed:
|
||||
usedVoice && transcriptConfirmed && !transcriptEdited,
|
||||
transcriptEdited: usedVoice && transcriptEdited,
|
||||
);
|
||||
if (!widget.state.keepRecordings) {
|
||||
VoiceService.instance.deleteRecording(recordingPath);
|
||||
}
|
||||
final nextIndex = index + 1;
|
||||
if (nextIndex >= lesson.tasks.length) {
|
||||
widget.state.clearAdaptiveLessonDraft();
|
||||
} else {
|
||||
widget.state.saveAdaptiveLessonDraft(
|
||||
lesson: lesson,
|
||||
taskIndex: nextIndex,
|
||||
answer: '',
|
||||
referenceShown: false,
|
||||
originalTranscript: '',
|
||||
recordingPath: null,
|
||||
);
|
||||
}
|
||||
setState(() {
|
||||
controller.clear();
|
||||
showReference = false;
|
||||
answerFeedback = null;
|
||||
usedVoice = false;
|
||||
transcriptEdited = false;
|
||||
transcriptConfirmed = false;
|
||||
lastTranscript = '';
|
||||
recordingPath = null;
|
||||
index = nextIndex;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _toggleListening() async {
|
||||
if (listening) {
|
||||
await VoiceService.instance.stopListening();
|
||||
if (mounted) setState(() => listening = false);
|
||||
return;
|
||||
}
|
||||
final ready = await VoiceService.instance.startListening((text, _) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
controller.text = text;
|
||||
usedVoice = true;
|
||||
transcriptEdited = false;
|
||||
transcriptConfirmed = false;
|
||||
lastTranscript = text;
|
||||
});
|
||||
final lesson = widget.state.cachedAdaptiveLesson;
|
||||
if (lesson != null) _saveDraft(lesson);
|
||||
});
|
||||
if (!mounted) return;
|
||||
setState(() => listening = ready);
|
||||
if (!ready) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('语音识别不可用;你仍可输入英文完成补练。')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleRecording() async {
|
||||
if (recording) {
|
||||
final path = await VoiceService.instance.stopRecording();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
recording = false;
|
||||
recordingPath = path;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
await VoiceService.instance.deleteRecording(recordingPath);
|
||||
final ready = await VoiceService.instance.startRecording();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
recording = ready;
|
||||
if (ready) recordingPath = null;
|
||||
});
|
||||
if (!ready) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('无法使用麦克风录音;请检查系统权限。')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playRecording() async {
|
||||
final path = recordingPath;
|
||||
if (path == null) return;
|
||||
setState(() => playingRecording = true);
|
||||
await VoiceService.instance.playRecording(path);
|
||||
if (mounted) setState(() => playingRecording = false);
|
||||
}
|
||||
|
||||
Future<void> _deleteRecording() async {
|
||||
await VoiceService.instance.deleteRecording(recordingPath);
|
||||
if (mounted) setState(() => recordingPath = null);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lesson = widget.state.cachedAdaptiveLesson;
|
||||
if (lesson == null) {
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Eyebrow('AI 四技能补练'),
|
||||
const Text('没有可用的已审核补练。'),
|
||||
PrimaryButton(label: '回到复习', onPressed: widget.onFinished),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final done = index >= lesson.tasks.length;
|
||||
if (done) {
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Eyebrow('补练已完成'),
|
||||
Text(
|
||||
'你完成了这组四技能教学补练。',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const Text('这组 AI 内容只作教学复练;掌握度仍由本地复习与出口评估的有效证据决定。'),
|
||||
PrimaryButton(label: '回到复习', onPressed: widget.onFinished),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final task = lesson.tasks[index];
|
||||
final showStimulus = task.skill != 'listening';
|
||||
return AppPage(
|
||||
appBar: AppBar(title: Text('AI 补练 · ${index + 1}/4')),
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Eyebrow('${_skillLabel(task.skill)} · 已审核教学内容'),
|
||||
if (widget.state.cachedAdaptiveLessonAuditedAt != null)
|
||||
Text(
|
||||
'审核:${widget.state.cachedAdaptiveLessonAuditor ?? '已配置服务'} · '
|
||||
'${_formatAuditTime(widget.state.cachedAdaptiveLessonAuditedAt!)}',
|
||||
style: const TextStyle(color: AppColors.muted, fontSize: 12),
|
||||
),
|
||||
Text(task.prompt, style: Theme.of(context).textTheme.headlineMedium),
|
||||
if (showStimulus)
|
||||
SectionCard(
|
||||
tint: AppColors.softGreen,
|
||||
child: Text(task.stimulus, style: const TextStyle(fontSize: 20)),
|
||||
)
|
||||
else
|
||||
const SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
child: Text('先播放音频,再输入你听到的答案。'),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => VoiceService.instance.speak(task.stimulus),
|
||||
icon: const Icon(Icons.volume_up_outlined),
|
||||
label: const Text('播放'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
VoiceService.instance.speak(task.stimulus, slow: true),
|
||||
icon: const Icon(Icons.slow_motion_video_outlined),
|
||||
label: const Text('慢放'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
TextField(
|
||||
controller: controller,
|
||||
minLines: 2,
|
||||
onChanged: (_) {
|
||||
if (usedVoice && controller.text != lastTranscript) {
|
||||
transcriptEdited = true;
|
||||
transcriptConfirmed = false;
|
||||
}
|
||||
_saveDraft(lesson);
|
||||
setState(() {});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: '输入或说出你的答案后,再继续',
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
prefixIcon: IconButton(
|
||||
tooltip: listening ? '停止语音输入' : '语音输入',
|
||||
onPressed: _toggleListening,
|
||||
icon: Icon(
|
||||
listening ? Icons.stop_circle_outlined : Icons.mic_none,
|
||||
),
|
||||
),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
if (task.skill == 'speaking')
|
||||
SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
child: SpacedColumn(
|
||||
spacing: 8,
|
||||
children: [
|
||||
Text(
|
||||
widget.state.keepRecordings
|
||||
? '可录音回听并仅保存在本机;不会发送给 AI。'
|
||||
: '可录音回听;离开本页后会自动删除。',
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: listening ? null : _toggleRecording,
|
||||
icon: Icon(
|
||||
recording
|
||||
? Icons.stop_circle_outlined
|
||||
: Icons.fiber_manual_record,
|
||||
),
|
||||
label: Text(recording ? '停止录音' : '录音回听'),
|
||||
),
|
||||
),
|
||||
if (recordingPath != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
tooltip: playingRecording ? '正在播放' : '回听录音',
|
||||
onPressed: playingRecording ? null : _playRecording,
|
||||
icon: const Icon(Icons.play_arrow),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '删除录音',
|
||||
onPressed: _deleteRecording,
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (usedVoice)
|
||||
SectionCard(
|
||||
tint: transcriptEdited || !transcriptConfirmed
|
||||
? AppColors.warm
|
||||
: AppColors.softGreen,
|
||||
child: SpacedColumn(
|
||||
spacing: 6,
|
||||
children: [
|
||||
Text(
|
||||
transcriptEdited
|
||||
? '你修改了设备转写:本次将按文字教学练习保存。'
|
||||
: transcriptConfirmed
|
||||
? '已确认设备转写:会保留本次语音输入记录。'
|
||||
: '请核对设备转写;确认前会按文字教学练习保存。',
|
||||
),
|
||||
if (!transcriptEdited && !transcriptConfirmed)
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() => transcriptConfirmed = true);
|
||||
_saveDraft(lesson);
|
||||
},
|
||||
child: const Text('确认转写无误'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (answerFeedback != null)
|
||||
Text(
|
||||
answerFeedback!,
|
||||
style: const TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
if (showReference)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: Text('参考表达:${task.answer}'),
|
||||
)
|
||||
else
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setState(() => showReference = true);
|
||||
_saveDraft(lesson);
|
||||
},
|
||||
child: const Text('需要帮助,查看参考表达'),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
widget.state.reportAdaptiveLesson(lesson);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('已隔离这组 AI 补练内容,并返回复习。')),
|
||||
);
|
||||
widget.onFinished();
|
||||
},
|
||||
icon: const Icon(Icons.flag_outlined),
|
||||
label: const Text('内容有问题'),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: index == lesson.tasks.length - 1 ? '完成补练' : '下一项',
|
||||
onPressed: controller.text.trim().isNotEmpty
|
||||
? () => _advance(lesson, task)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _skillLabel(String skill) => switch (skill) {
|
||||
'listening' => '听力',
|
||||
'speaking' => '口语',
|
||||
'reading' => '阅读',
|
||||
_ => '写作',
|
||||
};
|
||||
|
||||
String _formatAuditTime(DateTime value) =>
|
||||
'${value.year}-${value.month.toString().padLeft(2, '0')}-${value.day.toString().padLeft(2, '0')} '
|
||||
'${value.hour.toString().padLeft(2, '0')}:${value.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/app_state.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/models.dart';
|
||||
import '../../core/seed_courses.dart';
|
||||
import '../../core/assessment_bank.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
import '../dialogue/dialogue_flow.dart';
|
||||
import '../assessment/assessment_page.dart';
|
||||
import '../home/home_page.dart';
|
||||
import '../lesson/lesson_flow.dart';
|
||||
import '../progress/progress_pages.dart';
|
||||
import '../review/review_page.dart';
|
||||
|
||||
class LearningShell extends StatefulWidget {
|
||||
const LearningShell({super.key, required this.state});
|
||||
final AppState state;
|
||||
|
||||
@override
|
||||
State<LearningShell> createState() => _LearningShellState();
|
||||
}
|
||||
|
||||
class _LearningShellState extends State<LearningShell> {
|
||||
AppTab tab = AppTab.home;
|
||||
var route = _ShellRoute.tab;
|
||||
bool dialogueInLesson = false;
|
||||
AssessmentPack? assessmentPack;
|
||||
DialogueSummaryData? dialogueSummary;
|
||||
|
||||
void showTab(AppTab value) => setState(() {
|
||||
tab = value;
|
||||
route = _ShellRoute.tab;
|
||||
});
|
||||
|
||||
void showLesson() => setState(() => route = _ShellRoute.lesson);
|
||||
void showDialogueScene() => setState(() => route = _ShellRoute.scene);
|
||||
void showDialogue({bool inLesson = false}) => setState(() {
|
||||
dialogueInLesson = inLesson;
|
||||
route = _ShellRoute.dialogue;
|
||||
});
|
||||
void showSummary(DialogueSummaryData summary) => setState(() {
|
||||
dialogueSummary = summary;
|
||||
route = _ShellRoute.summary;
|
||||
});
|
||||
void showSettings() => setState(() => route = _ShellRoute.settings);
|
||||
void showAssessment(AssessmentPack pack) => setState(() {
|
||||
assessmentPack = pack;
|
||||
route = _ShellRoute.assessmentPreparation;
|
||||
});
|
||||
void startAssessment() => setState(() => route = _ShellRoute.assessment);
|
||||
void showAdaptiveLesson() =>
|
||||
setState(() => route = _ShellRoute.adaptiveLesson);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget body;
|
||||
switch (route) {
|
||||
case _ShellRoute.lesson:
|
||||
body = LessonFlow(
|
||||
state: widget.state,
|
||||
onOpenDialogue: () => showDialogue(inLesson: true),
|
||||
onFinish: () => showTab(AppTab.home),
|
||||
);
|
||||
case _ShellRoute.scene:
|
||||
body = DialogueScenePage(onStart: showDialogue);
|
||||
case _ShellRoute.dialogue:
|
||||
body = DialoguePage(
|
||||
state: widget.state,
|
||||
isLessonDialogue: dialogueInLesson,
|
||||
onFinished: dialogueInLesson
|
||||
? (_) => showLesson()
|
||||
: (summary) {
|
||||
if (summary != null) showSummary(summary);
|
||||
},
|
||||
);
|
||||
case _ShellRoute.summary:
|
||||
body = DialogueSummaryPage(
|
||||
summary: dialogueSummary!,
|
||||
onHome: () => showTab(AppTab.home),
|
||||
onLesson: showLesson,
|
||||
onRetry: showDialogue,
|
||||
);
|
||||
case _ShellRoute.settings:
|
||||
body = SettingsPage(state: widget.state);
|
||||
case _ShellRoute.adaptiveLesson:
|
||||
body = AdaptiveLessonPage(
|
||||
state: widget.state,
|
||||
onFinished: () => showTab(AppTab.review),
|
||||
);
|
||||
case _ShellRoute.assessment:
|
||||
body = AssessmentPage(
|
||||
state: widget.state,
|
||||
pack: assessmentPack!,
|
||||
onFinished: () => showTab(AppTab.progress),
|
||||
onStartReplacement: showAssessment,
|
||||
);
|
||||
case _ShellRoute.assessmentPreparation:
|
||||
body = AssessmentPreparationPage(
|
||||
state: widget.state,
|
||||
pack: assessmentPack!,
|
||||
onStart: startAssessment,
|
||||
onBack: () => showTab(AppTab.progress),
|
||||
);
|
||||
case _ShellRoute.tab:
|
||||
body = _tabContent();
|
||||
}
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: widget.state,
|
||||
builder: (context, _) => Scaffold(
|
||||
body: body,
|
||||
bottomNavigationBar: route == _ShellRoute.tab
|
||||
? NavigationBar(
|
||||
selectedIndex: tab.index,
|
||||
height: 70,
|
||||
indicatorColor: AppColors.softGreen,
|
||||
onDestinationSelected: (index) => showTab(AppTab.values[index]),
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: '首页',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.menu_book_outlined),
|
||||
selectedIcon: Icon(Icons.menu_book),
|
||||
label: '学习',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.chat_bubble_outline),
|
||||
selectedIcon: Icon(Icons.chat_bubble),
|
||||
label: '对话',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.refresh_outlined),
|
||||
selectedIcon: Icon(Icons.refresh),
|
||||
label: '复习',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.person_outline),
|
||||
selectedIcon: Icon(Icons.person),
|
||||
label: '我的',
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _tabContent() {
|
||||
switch (tab) {
|
||||
case AppTab.home:
|
||||
return HomePage(
|
||||
state: widget.state,
|
||||
onStartPrimaryTask: widget.state.reviewIsPrimary
|
||||
? () => showTab(AppTab.review)
|
||||
: showLesson,
|
||||
onOpenDialogue: showDialogue,
|
||||
onResumeLessonDialogue: () => showDialogue(inLesson: true),
|
||||
);
|
||||
case AppTab.learn:
|
||||
return _LearningMap(
|
||||
onOpenLesson: (id) {
|
||||
widget.state.openLesson(id);
|
||||
showLesson();
|
||||
},
|
||||
onStartReinforcement: () {
|
||||
widget.state.scheduleA0Reinforcement();
|
||||
showTab(AppTab.review);
|
||||
},
|
||||
onOpenReview: () => showTab(AppTab.review),
|
||||
state: widget.state,
|
||||
);
|
||||
case AppTab.dialogue:
|
||||
return DialogueScenePage(onStart: showDialogue);
|
||||
case AppTab.review:
|
||||
return ReviewPage(
|
||||
state: widget.state,
|
||||
onFinished: () => showTab(AppTab.home),
|
||||
onOpenAdaptiveLesson: showAdaptiveLesson,
|
||||
);
|
||||
case AppTab.progress:
|
||||
return ProgressPage(
|
||||
state: widget.state,
|
||||
onSettings: showSettings,
|
||||
onOpenAssessment: showAssessment,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum _ShellRoute {
|
||||
tab,
|
||||
lesson,
|
||||
scene,
|
||||
dialogue,
|
||||
summary,
|
||||
settings,
|
||||
adaptiveLesson,
|
||||
assessmentPreparation,
|
||||
assessment,
|
||||
}
|
||||
|
||||
class _LearningMap extends StatelessWidget {
|
||||
const _LearningMap({
|
||||
required this.onOpenLesson,
|
||||
required this.onStartReinforcement,
|
||||
required this.onOpenReview,
|
||||
required this.state,
|
||||
});
|
||||
final ValueChanged<String> onOpenLesson;
|
||||
final VoidCallback onStartReinforcement;
|
||||
final VoidCallback onOpenReview;
|
||||
final AppState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Eyebrow('学习地图 · 按掌握状态推进'),
|
||||
Text('从认识到开口', style: Theme.of(context).textTheme.headlineMedium),
|
||||
const Text('每节课都围绕一个能完成的小任务。'),
|
||||
if (state.reviewBacklog)
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Text(
|
||||
'复习已有积压,今天先完成 ${state.dueReviewCount} 项到期复习,再开启新课。',
|
||||
style: const TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
SecondaryButton(label: '先去复习', onPressed: onOpenReview),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final lesson in a0SeedLessons)
|
||||
SectionCard(
|
||||
tint: lesson.id == state.activeLessonId
|
||||
? AppColors.softGreen
|
||||
: null,
|
||||
onTap:
|
||||
state.isLessonUnlocked(lesson.id) &&
|
||||
(!state.reviewBacklog ||
|
||||
state.completedLessonIds.contains(lesson.id))
|
||||
? () => onOpenLesson(lesson.id)
|
||||
: null,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
state.completedLessonIds.contains(lesson.id)
|
||||
? Icons.check_circle
|
||||
: state.reviewBacklog
|
||||
? Icons.lock_outline
|
||||
: state.isLessonUnlocked(lesson.id)
|
||||
? Icons.play_circle_outline
|
||||
: Icons.lock_outline,
|
||||
color: state.completedLessonIds.contains(lesson.id)
|
||||
? AppColors.green
|
||||
: AppColors.muted,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'第 ${lesson.number} 课 · ${lesson.title}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
lesson.segments.length > 1
|
||||
? '${lesson.outcome} · 小段 ${lesson.segments.where((segment) => state.isSegmentComplete(segment.id)).length}/${lesson.segments.length}'
|
||||
: lesson.outcome,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (state.completedLessonIds.length == a0SeedLessons.length)
|
||||
SectionCard(
|
||||
tint: AppColors.softGreen,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Text(
|
||||
'A0 巩固变式',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const Text('换一个人物、地点或情境,继续巩固尚未稳定的核心表达。'),
|
||||
PrimaryButton(
|
||||
label: '安排一题巩固练习',
|
||||
onPressed: onStartReinforcement,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user