feat: complete kouyu_english app codebase, A0 specifications and .gitignore

This commit is contained in:
shen
2026-09-15 15:58:33 +08:00
parent b8072673d8
commit 37c86f7ecb
136 changed files with 19042 additions and 0 deletions
@@ -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. Whats your name?',
'Nice to meet you. Where are you from?',
'Great! How are you today? Or what do you like?',
'Im good, thanks. Now ask me one question!',
];
static const hints = [
'My name is Alex.',
'Im from Hong Kong.',
'Im good, thanks. / I like coffee.',
'Whats 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://…/v1Gemini 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,
),
],
),
),
],
),
);
}
}