整个课程和数据UI重新设计版本

This commit is contained in:
shenlei
2026-09-18 17:55:44 +09:00
parent febfd30f49
commit 0a4f97d105
154 changed files with 12366 additions and 4784 deletions
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/assessment_bank.dart';
import '../../core/courses/courses.dart';
import '../../core/models.dart';
import '../../core/voice_service.dart';
import '../../widgets/app_widgets.dart';
@@ -221,7 +221,7 @@ class _AssessmentPageState extends State<AssessmentPage>
results[task.id] = correct;
widget.state.recordAssessmentAttempt(
taskId: task.id,
skill: _skillLabel(task.skill),
skill: task.skill.label,
correct: correct,
rawAnswer: answer == null ? controller.text.trim() : task.choices[answer],
spoken: task.skill == AssessmentSkill.speaking,
@@ -254,7 +254,7 @@ class _AssessmentPageState extends State<AssessmentPage>
results[speaking.id] = false;
widget.state.recordAssessmentPending(
taskId: speaking.id,
skill: _skillLabel(AssessmentSkill.speaking),
skill: AssessmentSkill.speaking.label,
reason: '设备麦克风或语音识别不可用,等待补测。',
);
}
@@ -327,13 +327,20 @@ class _AssessmentPageState extends State<AssessmentPage>
SectionCard(
tint: AppColors.warm,
child: Text(
'待评估:${record.pendingSkills.map(_skillLabel).join('、')}。技术问题不会计为语言错误。',
'待评估:${record.pendingSkills.map((skill) => skill.label).join('、')}。技术问题不会计为语言错误。',
),
),
if (record.failedSkills.isNotEmpty) const Text('建议补练:'),
for (final skill in record.failedSkills)
SectionCard(child: Text(_remediation(skill))),
],
if (_wrongTasks.isNotEmpty) ...[
const Text(
'错题回顾',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
for (final wrong in _wrongTasks) _WrongAnswerCard(wrong: wrong),
],
if (!record.passed && replacementFor(widget.pack.id) != null)
SecondaryButton(
label: '开始替换题补测',
@@ -359,7 +366,7 @@ class _AssessmentPageState extends State<AssessmentPage>
child: SpacedColumn(
children: [
Text(
_skillLabel(task.skill),
task.skill.label,
style: Theme.of(context).textTheme.headlineMedium,
),
const Text('评估中不提供翻译、句框或答案。需要帮助请退出后先做补练。'),
@@ -435,16 +442,50 @@ class _AssessmentPageState extends State<AssessmentPage>
);
}
/// Tasks answered wrong in this attempt, with the learner's latest answer.
List<({AssessmentTask task, String answer})> get _wrongTasks => [
for (final task in widget.pack.tasks)
if (results[task.id] == false)
if (widget.state.attemptEvidence.reversed
.where((item) => item.taskId == task.id)
.firstOrNull
case final evidence? when evidence.outcome != EvidenceKind.pending)
(task: task, answer: evidence.rawAnswer ?? ''),
];
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 => '写作',
};
}
class _WrongAnswerCard extends StatelessWidget {
const _WrongAnswerCard({required this.wrong});
final ({AssessmentTask task, String answer}) wrong;
@override
Widget build(BuildContext context) {
final task = wrong.task;
return SectionCard(
child: SpacedColumn(
spacing: 6,
children: [
Text(
'${task.skill.label} · ${task.audio ?? task.prompt}',
style: const TextStyle(fontWeight: FontWeight.w600),
),
Text(
'你的答案:${wrong.answer.isEmpty ? '(未作答)' : wrong.answer}',
style: TextStyle(color: AppColors.warmInk),
),
Text(
assessmentAnswerGuide(task, wrong.answer),
style: TextStyle(color: AppColors.green),
),
],
),
);
}
}
@@ -4,7 +4,7 @@ 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/courses/courses.dart';
import '../../core/voice_service.dart';
import '../../widgets/app_widgets.dart';
import '../../widgets/lexicon_lookup.dart';
@@ -46,7 +46,7 @@ class DialogueScenePage extends StatelessWidget {
'每轮 4 次回答,完成明确任务后结束。',
style: Theme.of(context).textTheme.bodyMedium,
),
for (final scene in a0Scenes)
for (final scene in allScenes)
if (state.isSceneUnlocked(scene))
_OpenScene(
scene: scene,
@@ -158,6 +158,9 @@ class _DialoguePageState extends State<DialoguePage>
String lastTranscript = '';
bool waitingForReply = false;
String? validationError;
bool checkingWithAi = false;
WritingAiFeedback? aiCheck;
String? aiCheckError;
/// Shown when the reply on screen came from the built-in script instead of
/// the AI, so a canned line is never mistaken for a real answer.
@@ -169,13 +172,11 @@ class _DialoguePageState extends State<DialoguePage>
DialogueScene get _scene => sceneById(widget.sceneId);
/// A free scene may use everything the learner has finished so far.
String get _furthestCompletedLessonId => a0SeedLessons
.lastWhere(
(lesson) => widget.state.completedLessonIds.contains(lesson.id),
orElse: () => a0SeedLessons.first,
)
.id;
/// The lesson whose language the AI partner speaks: the active lesson, or
/// the unit a free scene belongs to. Null for the always-open A0 scene.
String? get _languageLessonId => widget.isLessonDialogue
? widget.state.activeLessonId
: _scene.unlockAfterLessonId;
/// Each free scene stores its draft under its own id.
String get _sceneDraftId => 'scene-${_scene.id}';
@@ -194,7 +195,6 @@ class _DialoguePageState extends State<DialoguePage>
widget.state.activeLessonId,
)]
.id,
widget.state.activeLessonId,
)
: _scene.script;
@@ -211,17 +211,11 @@ class _DialoguePageState extends State<DialoguePage>
}
}
}
// 2. Check the free scene script
for (var i = 0; i < a0MeetDialogue.prompts.length; i++) {
if (a0MeetDialogue.prompts[i].trim().toLowerCase() ==
cleanText.toLowerCase()) {
if (i < a0MeetDialogue.translations.length) {
return a0MeetDialogue.translations[i];
}
}
}
// 3. Check all lesson dialogues
for (final dialogue in a0Dialogues.values) {
// 2. Check every free scene and lesson dialogue
for (final dialogue in [
for (final scene in allScenes) scene.script,
...segmentDialogues.values,
]) {
for (var i = 0; i < dialogue.prompts.length; i++) {
if (dialogue.prompts[i].trim().toLowerCase() ==
cleanText.toLowerCase()) {
@@ -231,18 +225,7 @@ class _DialoguePageState extends State<DialoguePage>
}
}
}
// 4. Check all segment dialogues
for (final dialogue in a0SegmentDialogues.values) {
for (var i = 0; i < dialogue.prompts.length; i++) {
if (dialogue.prompts[i].trim().toLowerCase() ==
cleanText.toLowerCase()) {
if (i < dialogue.translations.length) {
return dialogue.translations[i];
}
}
}
}
// 5. Common fallback phrases
// 3. Common fallback phrases
if (cleanText == _closingLine) return _closingTranslation;
if (cleanText.toLowerCase().contains("wonderful") &&
cleanText.toLowerCase().contains("nice meeting you")) {
@@ -360,6 +343,8 @@ class _DialoguePageState extends State<DialoguePage>
waitingForReply = true;
hint = null;
validationError = null;
aiCheck = null;
aiCheckError = null;
});
_saveDraft();
_scrollToBottom();
@@ -373,12 +358,11 @@ class _DialoguePageState extends State<DialoguePage>
learnerTask: nextStage < script.hints.length
? 'answer with something like "${script.hints[nextStage]}"'
: 'nothing more, the conversation is finished',
allowedLanguage: widget.isLessonDialogue
? taughtLanguageUpTo(widget.state.activeLessonId)
: _scene.unlockAfterLessonId == null
// The always-open scene mixes every A0 topic, as its script does.
? allTaughtLanguage
: taughtLanguageUpTo(_furthestCompletedLessonId),
level: _languageLessonId == null ? 'A0' : lessonLevel(_languageLessonId!),
// The always-open scene mixes every A0 topic, as its script does.
allowedLanguage: _languageLessonId == null
? a0TaughtLanguage
: taughtLanguageUpTo(_languageLessonId!),
history: turns
.map(
(turn) => <String, String>{
@@ -440,6 +424,54 @@ class _DialoguePageState extends State<DialoguePage>
String _currentTaskLabel() => dialogueTaskLabel(script, stage);
/// Optional spelling and grammar check of the turn before it is sent. It
/// never decides whether the turn passes; [_matchesCurrentTask] does.
Future<void> _checkWithAi() async {
final answer = controller.text.trim();
if (answer.isEmpty || checkingWithAi || stage >= script.prompts.length) {
return;
}
if (widget.state.aiProvider == AiProviderType.mock) {
setState(() => aiCheckError = '请先在“我的”配置 AI 服务;本地检查仍可继续对话。');
return;
}
final checkedStage = stage;
setState(() {
checkingWithAi = true;
aiCheckError = null;
});
final partnerLine = turns.where((turn) => !turn.isLearner).lastOrNull;
final feedback = await AiService.instance.answerFeedback(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
answerId: 'dialogue-$checkedStage',
target: checkedStage < script.hints.length
? script.hints[checkedStage]
: _currentTaskLabel(),
taskPrompt: '对方说:${partnerLine?.text ?? ''} 本轮任务:${_currentTaskLabel()}',
answer: answer,
level: _languageLessonId == null ? 'A0' : lessonLevel(_languageLessonId!),
);
if (!mounted) return;
// Drop a result for a turn already sent or an answer since changed.
if (stage != checkedStage || controller.text.trim() != answer) {
setState(() => checkingWithAi = false);
return;
}
setState(() {
checkingWithAi = false;
aiCheck = feedback;
aiCheckError = feedback == null
? '暂时无法获得 AI 检查结果。你的回答保留在这里,可稍后重试或直接发送。'
: null;
// A shown correction is help, like the hint and translation chips.
if (feedback != null && feedback.verdict != 'accepted') usedHelp = true;
});
_saveDraft();
_scrollToBottom();
}
bool _matchesCurrentTask(String response) {
// Every dialogue now checks the language the turn is teaching. The old
// whole-lesson branch matched bare keywords such as 'it' anywhere in the
@@ -603,6 +635,8 @@ class _DialoguePageState extends State<DialoguePage>
usedVoice = true;
lastTranscript = text;
transcriptEdited = false;
aiCheck = null;
aiCheckError = null;
},
afterTranscribe: _scrollToBottom,
);
@@ -684,10 +718,7 @@ class _DialoguePageState extends State<DialoguePage>
if (hint != null)
SectionCard(
tint: AppColors.warm,
child: Text(
hint!,
style: TextStyle(color: AppColors.warmInk),
),
child: Text(hint!, style: TextStyle(color: AppColors.warmInk)),
),
if (aiNotice != null)
SectionCard(
@@ -723,6 +754,8 @@ class _DialoguePageState extends State<DialoguePage>
if (usedVoice && value != lastTranscript) {
transcriptEdited = true;
}
aiCheck = null;
aiCheckError = null;
}),
onSubmitted: (_) => send(),
decoration: InputDecoration(
@@ -752,6 +785,51 @@ class _DialoguePageState extends State<DialoguePage>
border: const OutlineInputBorder(),
),
),
OutlinedButton.icon(
onPressed:
controller.text.trim().isEmpty ||
checkingWithAi ||
waitingForReply
? null
: _checkWithAi,
icon: checkingWithAi
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.spellcheck),
label: Text(checkingWithAi ? '正在检查…' : '发送前 AI 检查语法和拼写(可选)'),
),
if (aiCheck != null)
SectionCard(
tint: aiCheck!.verdict == 'accepted'
? AppColors.softGreen
: AppColors.warm,
child: SpacedColumn(
spacing: 6,
children: [
Text('AI 检查:${aiCheck!.feedback}'),
for (final note in aiCheck!.missing) Text('· $note'),
if (aiCheck!.suggestion != null)
Text('参考改正:${aiCheck!.suggestion}'),
Text(
aiCheck!.verdict == 'accepted'
? 'AI 仅作参考;这一轮是否完成仍由本地检查判断。'
: '看过改正后再发送,本次对话会记为使用过提示。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
],
),
),
if (aiCheckError != null)
SectionCard(
tint: AppColors.warm,
child: Text(
aiCheckError!,
style: TextStyle(color: AppColors.warmInk),
),
),
if (usedVoice)
Text(
transcriptEdited
+38 -185
View File
@@ -2,214 +2,67 @@ 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';
import 'lesson_path.dart';
import 'today_task_card.dart';
/// 学习 tab:今天的任务在上,完整课程路径在下。
class HomePage extends StatelessWidget {
const HomePage({
super.key,
required this.state,
required this.onStartPrimaryTask,
required this.onOpenDialogue,
required this.onStartLesson,
required this.onOpenLesson,
required this.onStartReview,
required this.onResumeLessonDialogue,
required this.onStartReinforcement,
required this.onOpenAssessment,
});
final AppState state;
final VoidCallback onStartPrimaryTask;
final VoidCallback onOpenDialogue;
final VoidCallback onStartLesson;
final ValueChanged<String> onOpenLesson;
final VoidCallback onStartReview;
final VoidCallback onResumeLessonDialogue;
final VoidCallback onStartReinforcement;
final ValueChanged<String> onOpenAssessment;
@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 stageStep = !resumeDialogue && !isReview && state.allLessonsComplete
? _stageStep()
: null;
final primaryTitle = resumeDialogue
? '继续第 ${activeLesson.number} 课的课程对话'
: isReview
? '先复习 $count 项'
: stageStep?.title ??
'第 ${activeLesson.number} 课 · ${activeLesson.title}${activeLesson.segments.length > 1 ? ' · 第 $activeSegment/${activeLesson.segments.length} 段' : ''}';
final primaryNote = resumeDialogue
? '已保留你的对话进度和提示状态。'
: isReview
? (state.reviewBacklog ? '有积压项目;先花几分钟清掉到期复习。' : '昨天练过的关键句,今天换个情境再用一次。')
: stageStep?.note ?? '预热词汇 → 听说读写 → 对话 → 独立尝试';
final scene = state.recommendedScene;
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: [
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)} 分钟'
: stageStep?.minutes ?? '12 分钟',
style: TextStyle(color: AppColors.green),
),
],
),
Text(
primaryNote,
style: Theme.of(context).textTheme.bodyMedium,
),
PrimaryButton(
label: resumeDialogue
? '继续对话'
: isReview
? '开始复习'
: stageStep?.action ?? '开始今天的学习',
onPressed: resumeDialogue
? onResumeLessonDialogue
: stageStep?.onPressed ?? onStartPrimaryTask,
),
],
),
),
SectionCard(
child: SpacedColumn(
children: [
const Text(
'AI 情境对话',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
Text(
'${scene.title} · ${scene.script.prompts.length} 个任务 · 文字或语音输入',
style: Theme.of(context).textTheme.bodyMedium,
),
SecondaryButton(label: '开始情境对话', onPressed: onOpenDialogue),
],
),
),
const _FrameworkNote(),
],
),
);
}
/// What comes after the last A0 lesson: consolidation until the core items
/// are ready, then the stage assessment, then waiting for A1 content.
_StageStep _stageStep() {
final usable =
'可使用 ${state.coreUsableCount}/48 · 已掌握 ${state.coreMasteredCount}/30';
if (state.a0Passed) {
return _StageStep(
title: 'A0 已通过',
note: 'A1 课程准备中。先用巩固练习和情境对话保持手感。',
action: '安排一题巩固练习',
minutes: '3 分钟',
onPressed: onStartReinforcement,
);
}
final packId = state.nextAssessmentPackId;
if (state.a0AssessmentReady && packId != null) {
return _StageStep(
title: 'A0 阶段评估 · $packId',
note: '$usable。可以开始阶段评估,两套评估间隔至少 24 小时。',
action: '开始阶段评估',
minutes: '15 分钟',
onPressed: () => onOpenAssessment(packId),
);
}
return _StageStep(
title: state.a0AssessmentReady ? '第二套评估 24 小时后开放' : 'A0 课程已学完',
note: state.a0AssessmentReady
? '第一套评估已通过。等待期间继续巩固。'
: '$usable。继续按时复习、做情境对话,达到要求后开放阶段评估。',
action: '安排一题巩固练习',
minutes: '3 分钟',
onPressed: onStartReinforcement,
);
}
}
class _StageStep {
const _StageStep({
required this.title,
required this.note,
required this.action,
required this.minutes,
required this.onPressed,
});
final String title;
final String note;
final String action;
final String minutes;
final VoidCallback onPressed;
Widget build(BuildContext context) => AppPage(
child: SpacedColumn(
spacing: 16,
children: [
Text('学习', style: Theme.of(context).textTheme.headlineMedium),
TodayTaskCard(
state: state,
onStartLesson: onStartLesson,
onStartReview: onStartReview,
onResumeLessonDialogue: onResumeLessonDialogue,
onStartReinforcement: onStartReinforcement,
onOpenAssessment: onOpenAssessment,
),
LessonPath(state: state, onOpenLesson: onOpenLesson),
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: 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,
),
),
Widget build(BuildContext context) => Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.info_outline, color: AppColors.muted, size: 16),
const SizedBox(width: 6),
Expanded(
child: Text(
'口语掌握以录音或语音识别结果为准;AI 服务可在“我的”中配置。',
style: TextStyle(color: AppColors.muted, fontSize: 12, height: 1.45),
),
],
),
),
],
);
}
@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/courses/courses.dart';
import '../../widgets/app_widgets.dart';
/// 课程路径:全部课程按顺序排列,显示完成、当前与锁定状态。
class LessonPath extends StatelessWidget {
const LessonPath({
super.key,
required this.state,
required this.onOpenLesson,
});
final AppState state;
final ValueChanged<String> onOpenLesson;
@override
Widget build(BuildContext context) {
final done = allLessons
.where((lesson) => state.completedLessonIds.contains(lesson.id))
.length;
return SpacedColumn(
spacing: 10,
children: [
Row(
children: [
Expanded(
child: Text(
'课程路径',
style: Theme.of(context).textTheme.titleMedium,
),
),
Text(
'$done/${allLessons.length} 完成',
style: TextStyle(fontSize: 13, color: AppColors.muted),
),
],
),
if (state.reviewBacklog)
Text(
'复习有积压:先完成到期复习,新课暂时锁定;已学课程仍可重温。',
style: TextStyle(fontSize: 13, color: AppColors.warmInk),
),
for (final lesson in allLessons)
_LessonTile(
lesson: lesson,
state: state,
onTap: () => onOpenLesson(lesson.id),
),
],
);
}
}
class _LessonTile extends StatelessWidget {
const _LessonTile({
required this.lesson,
required this.state,
required this.onTap,
});
final SeedLesson lesson;
final AppState state;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final completed = state.completedLessonIds.contains(lesson.id);
final current = lesson.id == state.activeLessonId && !completed;
final open =
state.isLessonUnlocked(lesson.id) &&
(!state.reviewBacklog || completed);
final segments = lesson.segments.length;
final segmentsDone = lesson.segments
.where((segment) => state.isSegmentComplete(segment.id))
.length;
return SectionCard(
tint: current ? AppColors.softGreen : null,
onTap: open ? onTap : null,
child: Row(
children: [
Icon(
completed
? Icons.check_circle
: open
? Icons.play_circle_outline
: Icons.lock_outline,
color: completed || current ? 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(
segments > 1
? '${lesson.outcome} · 小段 $segmentsDone/$segments'
: lesson.outcome,
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/courses/courses.dart';
import '../../widgets/app_widgets.dart';
/// 今天最该做的一件事:继续课程对话 → 到期复习 → 阶段评估/巩固 → 下一课。
class TodayTaskCard extends StatelessWidget {
const TodayTaskCard({
super.key,
required this.state,
required this.onStartLesson,
required this.onStartReview,
required this.onResumeLessonDialogue,
required this.onStartReinforcement,
required this.onOpenAssessment,
});
final AppState state;
final VoidCallback onStartLesson;
final VoidCallback onStartReview;
final VoidCallback onResumeLessonDialogue;
final VoidCallback onStartReinforcement;
final ValueChanged<String> onOpenAssessment;
@override
Widget build(BuildContext context) {
final task = _todayTask();
return SectionCard(
tint: AppColors.softGreen,
child: SpacedColumn(
children: [
Row(
children: [
Expanded(child: Eyebrow(task.label)),
Text(task.minutes, style: TextStyle(color: AppColors.green)),
],
),
Text(
task.title,
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
Text(task.note, style: Theme.of(context).textTheme.bodyMedium),
PrimaryButton(label: task.action, onPressed: task.onPressed),
],
),
);
}
_TodayTask _todayTask() {
final lesson = lessonById(state.activeLessonId);
if (state.hasResumableLessonDialogue) {
return _TodayTask(
label: '未完成',
title: '继续第 ${lesson.number} 课的课程对话',
note: '已保留你的对话进度和提示状态。',
action: '继续对话',
minutes: '5 分钟',
onPressed: onResumeLessonDialogue,
);
}
if (state.reviewIsPrimary) {
final count = state.dueReviewCount;
return _TodayTask(
label: '今日复习',
title: '先复习 $count 项',
note: state.reviewBacklog
? '有积压项目;先花几分钟清掉到期复习,再开启新课。'
: '之前练过的关键句,今天换个情境再用一次。',
action: '开始复习',
minutes: '${(count * 2).clamp(2, 10)} 分钟',
onPressed: onStartReview,
);
}
// A0 课程学完、尚未通过阶段评估时,先巩固再评估;通过后回到下一课。
if (state.a0LessonsComplete && !state.a0Passed) return _stageTask();
final segments = lesson.segments.length;
final segment = state.activeSegmentIndexFor(lesson.id) + 1;
return _TodayTask(
label: '今日新课',
title:
'第 ${lesson.number} 课 · ${lesson.title}'
'${segments > 1 ? ' · 第 $segment/$segments 段' : ''}',
note: '预热词汇 → 听说读写 → 对话 → 独立尝试',
action: '开始今天的学习',
minutes: '12 分钟',
onPressed: onStartLesson,
);
}
_TodayTask _stageTask() {
final usable =
'可使用 ${state.coreUsableCount}/$a0UsableGoal · '
'已掌握 ${state.coreMasteredCount}/$a0MasteredGoal';
final packId = state.nextAssessmentPackId;
if (state.a0AssessmentReady && packId != null) {
return _TodayTask(
label: '阶段评估',
title: 'A0 阶段评估 · $packId',
note: '$usable。可以开始阶段评估,两套评估间隔至少 24 小时。',
action: '开始阶段评估',
minutes: '15 分钟',
onPressed: () => onOpenAssessment(packId),
);
}
return _TodayTask(
label: 'A0 巩固',
title: state.a0AssessmentReady ? '第二套评估 24 小时后开放' : 'A0 课程已学完',
note: state.a0AssessmentReady
? '第一套评估已通过。等待期间继续巩固。'
: '$usable。继续按时复习、做情境对话,达到要求后开放阶段评估。',
action: '安排一题巩固练习',
minutes: '3 分钟',
onPressed: onStartReinforcement,
);
}
}
class _TodayTask {
const _TodayTask({
required this.label,
required this.title,
required this.note,
required this.action,
required this.minutes,
required this.onPressed,
});
final String label;
final String title;
final String note;
final String action;
final String minutes;
final VoidCallback onPressed;
}
@@ -4,7 +4,9 @@ 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/courses/courses.dart';
import '../../core/courses/course_pack.dart';
import '../../core/courses/course_repository.dart';
import '../../core/speech_compare.dart';
import '../../core/voice_service.dart';
import '../../core/writing_feedback.dart';
@@ -14,6 +16,7 @@ import '../../widgets/voice_answer.dart';
part 'steps/independent_step.dart';
part 'steps/listening_step.dart';
part 'steps/material_step.dart';
part 'steps/preview_step.dart';
part 'steps/reading_step.dart';
part 'steps/speaking_step.dart';
@@ -53,10 +56,10 @@ class _LessonFlowState extends State<LessonFlow> {
}
LessonActivity get activity =>
activityBySegmentId(segment.id, widget.state.activeLessonId);
activityBySegmentId(segment.id);
List<VocabularyItem> get previewItems =>
vocabularyBySegmentId(segment.id, widget.state.activeLessonId);
vocabularyBySegmentId(segment.id);
@override
void initState() {
@@ -155,6 +158,16 @@ class _LessonFlowState extends State<LessonFlow> {
),
onContinue: widget.state.completeReading,
);
case LessonStep.material:
content = _MaterialStep(
state: widget.state,
materials:
CourseRepository.instance
.unitById(widget.state.activeLessonId)
?.materials ??
const [],
onContinue: widget.state.completeMaterial,
);
case LessonStep.writing:
content = _WritingStep(
state: widget.state,
@@ -180,6 +193,7 @@ class _LessonFlowState extends State<LessonFlow> {
case LessonStep.independent:
content = _IndependentStep(
state: widget.state,
lessonId: lesson.id,
segmentId: segment.id,
keepRecording: widget.state.keepRecordings,
activity: activity,
@@ -199,9 +213,9 @@ class _LessonFlowState extends State<LessonFlow> {
initialText: activity.independentPrompt,
);
},
onContinue: (spoken, recordingPath) =>
onContinue: (spoken, recordingPath, aiCorrected) =>
widget.state.completeIndependentAttempt(
assisted: showIndependentHelp,
assisted: showIndependentHelp || aiCorrected,
spoken: spoken,
rawAnswer: independentController.text.trim(),
recordingPath: recordingPath,
@@ -3,6 +3,7 @@ part of '../lesson_flow.dart';
class _IndependentStep extends StatefulWidget {
const _IndependentStep({
required this.state,
required this.lessonId,
required this.segmentId,
required this.keepRecording,
required this.activity,
@@ -16,6 +17,7 @@ class _IndependentStep extends StatefulWidget {
required this.onLater,
});
final AppState state;
final String lessonId;
final LessonActivity activity;
final String segmentId;
final bool keepRecording;
@@ -25,7 +27,8 @@ class _IndependentStep extends StatefulWidget {
final VoidCallback onChanged;
final VoidCallback onNeedHelp;
final VoidCallback onLookup;
final void Function(bool spoken, String? recordingPath) onContinue;
final void Function(bool spoken, String? recordingPath, bool aiCorrected)
onContinue;
final VoidCallback onLater;
@override
@@ -38,6 +41,12 @@ class _IndependentStepState extends State<_IndependentStep>
bool transcriptEdited = false;
String lastTranscript = '';
String? validationError;
bool checkingWithAi = false;
WritingAiFeedback? aiCheck;
String? aiCheckError;
/// A shown AI correction is help, so finishing afterwards is assisted.
bool aiCorrected = false;
@override
AppState get voiceState => widget.state;
@@ -49,16 +58,74 @@ class _IndependentStepState extends State<_IndependentStep>
}
void _submit() {
if (!matchesSegmentIndependent(widget.segmentId, widget.controller.text)) {
setState(() => validationError = '这次还没有用上本段要练的内容。查看帮助后补充一次。');
final shortfall = independentShortfall(
widget.segmentId,
widget.controller.text,
);
if (shortfall != null) {
setState(() => validationError = shortfall);
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
aiCheck?.verdict == 'accepted'
? '通过:用上了本段要练的表达,AI 也没发现拼写或语法问题。'
: '通过:用上了本段要练的表达。',
),
),
);
widget.onContinue(
usedVoice && !transcriptEdited,
widget.keepRecording ? recordingPath : null,
aiCorrected,
);
}
void _clearChecks() {
validationError = null;
aiCheck = null;
aiCheckError = null;
}
Future<void> _checkWithAi() async {
final answer = widget.controller.text.trim();
if (answer.isEmpty || checkingWithAi) return;
if (widget.state.aiProvider == AiProviderType.mock) {
setState(() => aiCheckError = '请先在“我的”配置 AI 服务;本地检查仍可继续学习。');
return;
}
setState(() {
checkingWithAi = true;
aiCheckError = null;
});
final feedback = await AiService.instance.answerFeedback(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
answerId: widget.segmentId,
target: widget.activity.independentHelp,
taskPrompt: widget.activity.independentPrompt,
answer: answer,
level: lessonLevel(widget.lessonId),
);
// The learner may have kept typing while the request ran.
if (!mounted || widget.controller.text.trim() != answer) {
if (mounted) setState(() => checkingWithAi = false);
return;
}
setState(() {
checkingWithAi = false;
aiCheck = feedback;
aiCheckError = feedback == null
? '暂时无法获得 AI 检查结果。你的答案保留在这里,可稍后重试或直接提交。'
: null;
if (feedback != null && feedback.verdict != 'accepted') {
aiCorrected = true;
}
});
}
Future<void> _toggleMic() async {
if (aiVoiceRecording) {
await finishVoiceInput(
@@ -67,6 +134,7 @@ class _IndependentStepState extends State<_IndependentStep>
usedVoice = true;
lastTranscript = text;
transcriptEdited = false;
_clearChecks();
},
afterTranscribe: widget.onChanged,
);
@@ -87,6 +155,7 @@ class _IndependentStepState extends State<_IndependentStep>
usedVoice = true;
lastTranscript = text;
transcriptEdited = false;
_clearChecks();
});
widget.onChanged();
},
@@ -133,7 +202,7 @@ class _IndependentStepState extends State<_IndependentStep>
controller: widget.controller,
onChanged: (value) {
if (usedVoice && value != lastTranscript) transcriptEdited = true;
setState(() {});
setState(_clearChecks);
widget.onChanged();
},
minLines: 2,
@@ -185,6 +254,48 @@ class _IndependentStepState extends State<_IndependentStep>
),
),
),
OutlinedButton.icon(
onPressed: widget.canContinue && !checkingWithAi
? _checkWithAi
: null,
icon: checkingWithAi
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.spellcheck),
label: Text(checkingWithAi ? '正在检查…' : '提交前 AI 检查语法和拼写(可选)'),
),
if (aiCheck != null)
SectionCard(
tint: aiCheck!.verdict == 'accepted'
? AppColors.softGreen
: AppColors.warm,
child: SpacedColumn(
spacing: 6,
children: [
Text('AI 检查:${aiCheck!.feedback}'),
for (final note in aiCheck!.missing) Text('· $note'),
if (aiCheck!.suggestion != null)
Text('参考改正:${aiCheck!.suggestion}'),
Text(
aiCheck!.verdict == 'accepted'
? 'AI 仅作参考;是否完成仍由本地检查判断。'
: '看过改正后再提交,本次会记为带提示完成。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
],
),
),
if (aiCheckError != null)
SectionCard(
tint: AppColors.warm,
child: Text(
aiCheckError!,
style: TextStyle(color: AppColors.warmInk),
),
),
if (validationError != null)
SectionCard(
tint: AppColors.warm,
@@ -210,7 +321,7 @@ class _IndependentStepState extends State<_IndependentStep>
else
TextButton(onPressed: widget.onLookup, child: const Text('查词或短语')),
PrimaryButton(
label: widget.showHelp ? '带帮助完成' : '独立完成',
label: widget.showHelp || aiCorrected ? '带帮助完成' : '独立完成',
onPressed: widget.canContinue ? _submit : null,
),
TextButton(onPressed: widget.onLater, child: const Text('稍后继续')),
@@ -0,0 +1,344 @@
part of '../lesson_flow.dart';
/// The unit's listening/reading comprehension step. It presents each material's
/// text (with word lookup and glosses) and a few multiple-choice questions. The
/// learner continues once every question is answered correctly or its answer
/// has been revealed — comprehension is checked, but it never blocks the lesson.
class _MaterialStep extends StatefulWidget {
const _MaterialStep({
required this.state,
required this.materials,
required this.onContinue,
});
final AppState state;
final List<PackMaterial> materials;
final VoidCallback onContinue;
@override
State<_MaterialStep> createState() => _MaterialStepState();
}
class _MaterialStepState extends State<_MaterialStep> {
/// materialIndex -> questionIndex -> selected option index.
final Map<int, Map<int, int>> _selected = {};
/// materialIndex -> questionIndex where the answer was revealed.
final Set<String> _revealed = {};
List<PackQuestion> _questions(int materialIndex) =>
widget.materials[materialIndex].questions;
List<String> _options(int materialIndex, int questionIndex) {
final q = _questions(materialIndex)[questionIndex];
return shuffledOptions(q.options, '${q.question}-material');
}
bool _isCorrect(int m, int q, int optionIndex) {
final options = _options(m, q);
if (optionIndex < 0 || optionIndex >= options.length) return false;
return options[optionIndex] == _questions(m)[q].answer;
}
bool _answered(int m, int q) {
if (_revealed.contains('$m-$q')) return true;
final chosen = _selected[m]?[q];
return chosen != null && _isCorrect(m, q, chosen);
}
bool get _allAnswered {
for (var m = 0; m < widget.materials.length; m++) {
for (var q = 0; q < _questions(m).length; q++) {
if (!_answered(m, q)) return false;
}
}
return true;
}
@override
Widget build(BuildContext context) {
return _LessonScaffold(
step: 4,
child: SpacedColumn(
children: [
const Eyebrow('听读材料'),
Text(
'读一段真实的小材料,做完理解题。',
style: Theme.of(context).textTheme.headlineMedium,
),
for (var m = 0; m < widget.materials.length; m++)
_materialCard(context, m),
PrimaryButton(
label: _allAnswered ? '继续写一写' : '答对所有理解题后继续',
onPressed: _allAnswered ? widget.onContinue : null,
),
],
),
);
}
Widget _materialCard(BuildContext context, int m) {
final material = widget.materials[m];
final isListening = material.mode == 'listening';
return SpacedColumn(
children: [
SectionCard(
tint: AppColors.softGreen,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(
isListening
? Icons.headphones_outlined
: Icons.menu_book_outlined,
size: 16,
color: AppColors.green,
),
const SizedBox(width: 6),
Text(
isListening ? '听力材料' : '阅读材料',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.green,
),
),
],
),
InkWell(
onTap: () => VoiceService.instance.speak(material.text),
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: Row(
children: [
Icon(
Icons.volume_up_outlined,
size: 16,
color: AppColors.green,
),
const SizedBox(width: 4),
Text(
'朗读',
style: TextStyle(
fontSize: 13,
color: AppColors.green,
),
),
],
),
),
),
],
),
if (material.title.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
material.title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
],
const SizedBox(height: 8),
// A listening material stays hidden until played, so the learner
// listens instead of reading it. Reading materials show the text.
if (isListening)
_ListeningRevealText(state: widget.state, text: material.text)
else
LexiconText(
material.text,
state: widget.state,
style: const TextStyle(fontSize: 16, height: 1.6),
),
if (material.glosses.isNotEmpty) ...[
const SizedBox(height: 12),
_GlossList(glosses: material.glosses),
],
],
),
),
for (var q = 0; q < material.questions.length; q++)
_questionBlock(context, m, q),
],
);
}
Widget _questionBlock(BuildContext context, int m, int q) {
final question = _questions(m)[q];
final options = _options(m, q);
final chosen = _selected[m]?[q];
final revealed = _revealed.contains('$m-$q');
return SpacedColumn(
children: [
SectionCard(
tint: AppColors.surfaceMuted,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.green.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'问题',
style: TextStyle(
color: AppColors.green,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
question.question,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.ink,
),
),
),
],
),
),
for (var index = 0; index < options.length; index++)
SectionCard(
tint: chosen == index
? (_isCorrect(m, q, index)
? AppColors.softGreen
: AppColors.warm)
: null,
onTap: () => setState(() {
(_selected[m] ??= {})[q] = index;
}),
child: Row(
children: [
Icon(
chosen == index
? (_isCorrect(m, q, index)
? Icons.check_circle
: Icons.cancel_outlined)
: Icons.radio_button_off,
color: chosen == index
? (_isCorrect(m, q, index)
? AppColors.green
: AppColors.warmInk)
: AppColors.muted,
),
const SizedBox(width: 12),
Expanded(
child: Text(
options[index],
style: TextStyle(
fontSize: 15,
fontWeight: chosen == index
? FontWeight.w600
: FontWeight.normal,
color: chosen == index
? (_isCorrect(m, q, index)
? AppColors.green
: AppColors.warmInk)
: AppColors.ink,
),
),
),
],
),
),
if (revealed)
SectionCard(
tint: AppColors.warm,
child: Text(
'答案:${question.answer}',
style: TextStyle(color: AppColors.warmInk),
),
)
else if (chosen != null && !_isCorrect(m, q, chosen))
TextButton(
onPressed: () => setState(() => _revealed.add('$m-$q')),
child: const Text('查看答案后继续'),
),
],
);
}
}
/// Keeps a listening material's transcript hidden until it has been played, so
/// the learner answers from listening rather than reading.
class _ListeningRevealText extends StatefulWidget {
const _ListeningRevealText({required this.state, required this.text});
final AppState state;
final String text;
@override
State<_ListeningRevealText> createState() => _ListeningRevealTextState();
}
class _ListeningRevealTextState extends State<_ListeningRevealText> {
bool _shown = false;
@override
Widget build(BuildContext context) {
if (_shown) {
return LexiconText(
widget.text,
state: widget.state,
style: const TextStyle(fontSize: 16, height: 1.6),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('先点“朗读”听两遍,再作答。', style: TextStyle(color: AppColors.muted)),
const SizedBox(height: 6),
TextButton.icon(
onPressed: () => setState(() => _shown = true),
icon: const Icon(Icons.visibility_outlined, size: 18),
label: const Text('显示原文'),
),
],
);
}
}
/// The material's untaught words with their Chinese glosses.
class _GlossList extends StatelessWidget {
const _GlossList({required this.glosses});
final Map<String, String> glosses;
@override
Widget build(BuildContext context) => Wrap(
spacing: 8,
runSpacing: 6,
children: [
for (final entry in glosses.entries)
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.line),
),
child: Text(
'${entry.key} · ${entry.value}',
style: const TextStyle(fontSize: 13),
),
),
],
);
}
@@ -21,6 +21,9 @@ class _ReadingStepState extends State<_ReadingStep> {
int? selectedOptionIndex;
bool showAnswer = false;
/// Typed answers are judged on tap, not on every keystroke.
bool typedAnswerChecked = false;
/// 打乱后的选项:答案不再固定排在第一位,但同一道题顺序保持稳定。
late final List<String> options = shuffledOptions(
widget.activity.readingOptions,
@@ -332,7 +335,7 @@ class _ReadingStepState extends State<_ReadingStep> {
] else ...[
TextField(
controller: controller,
onChanged: (_) => setState(() {}),
onChanged: (_) => setState(() => typedAnswerChecked = false),
decoration: InputDecoration(
hintText: '用英文输入答案',
filled: true,
@@ -340,6 +343,14 @@ class _ReadingStepState extends State<_ReadingStep> {
border: OutlineInputBorder(),
),
),
if (typedAnswerChecked && !answeredCorrectly && !showAnswer)
SectionCard(
tint: AppColors.warm,
child: Text(
'不对哦:答案需要写全对话里的关键词。再读一遍对话,或查看答案后继续。',
style: TextStyle(color: AppColors.warmInk),
),
),
],
if (showAnswer)
SectionCard(
@@ -351,9 +362,7 @@ class _ReadingStepState extends State<_ReadingStep> {
),
if (!showAnswer &&
((isOptionMode && hasSelected && !answeredCorrectly) ||
(!isOptionMode &&
controller.text.isNotEmpty &&
!answeredCorrectly)))
(!isOptionMode && typedAnswerChecked && !answeredCorrectly)))
TextButton(
onPressed: () => setState(() => showAnswer = true),
child: const Text('查看答案后继续学习'),
@@ -364,6 +373,8 @@ class _ReadingStepState extends State<_ReadingStep> {
: (isOptionMode ? '请选择答案' : '检查并继续'),
onPressed: showAnswer || answeredCorrectly
? widget.onContinue
: !isOptionMode && controller.text.trim().isNotEmpty
? () => setState(() => typedAnswerChecked = true)
: null,
),
],
@@ -41,9 +41,8 @@ class _WritingStepState extends State<_WritingStep> {
}
setState(
() => result = WritingFeedback.check(
widget.lessonId,
widget.segmentId,
widget.controller.text,
segmentId: widget.segmentId,
),
);
}
@@ -65,6 +64,7 @@ class _WritingStepState extends State<_WritingStep> {
lessonId: widget.lessonId,
taskPrompt: widget.activity.writingPrompt,
answer: widget.controller.text.trim(),
level: lessonLevel(widget.lessonId),
);
if (!mounted) return;
setState(() {
@@ -89,7 +89,7 @@ class _WritingStepState extends State<_WritingStep> {
SectionCard(
tint: AppColors.surfaceMuted,
child: Text(
'小提示:${grammarNoteForSegment(widget.segmentId, widget.lessonId)}',
'小提示:${grammarNoteForSegment(widget.segmentId)}',
),
),
if (widget.showHelp)
@@ -3,8 +3,7 @@ import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/models.dart';
import '../../core/review_feedback.dart';
import '../../core/seed_courses.dart';
import '../../core/courses/courses.dart';
import '../../core/voice_service.dart';
import '../../widgets/app_widgets.dart';
import '../../widgets/voice_answer.dart';
@@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import '../../core/ai_service.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/models.dart';
import '../../widgets/app_widgets.dart';
String aiProviderLabel(AiProviderType provider) => switch (provider) {
AiProviderType.mock => '内置练习模式',
AiProviderType.openAi => 'OpenAI API',
AiProviderType.gemini => 'Gemini API',
AiProviderType.compatible => 'OpenAI 兼容',
};
/// AI 服务:服务类型、接口地址、模型与 API Key。
class AiServiceView extends StatefulWidget {
const AiServiceView({super.key, required this.state});
final AppState state;
@override
State<AiServiceView> createState() => _AiServiceViewState();
}
class _AiServiceViewState extends State<AiServiceView> {
late final endpoint = TextEditingController(text: widget.state.aiEndpoint);
late final model = TextEditingController(text: widget.state.aiModel);
final apiKey = TextEditingController();
bool testingConnection = false;
@override
void dispose() {
endpoint.dispose();
model.dispose();
apiKey.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => SpacedColumn(
children: [
Text(
'订阅版 ChatGPT / Gemini 不能直接作为 App API 使用。请使用自己的 API Key,或填写兼容 OpenAI 接口的 CLIProxyAPI 地址。AI 只提供建议,不会直接决定你的掌握程度。',
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: _changeProvider,
),
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: _save),
SecondaryButton(
label: testingConnection ? '正在测试连接...' : '测试连接',
onPressed: testingConnection ? null : _testConnection,
),
SecondaryButton(
label: '从配置文件重载 (ai_config.json)',
onPressed: _reloadFromAsset,
),
],
);
void _changeProvider(AiProviderType? value) {
if (value == null) return;
setState(() {
widget.state.setAiProvider(value);
if (value == AiProviderType.gemini && endpoint.text.trim().isEmpty) {
endpoint.text = 'https://generativelanguage.googleapis.com/v1beta';
if (model.text.trim().isEmpty) model.text = 'gemini-2.5-flash';
}
});
}
Future<void> _save() async {
final messenger = ScaffoldMessenger.of(context);
widget.state.saveAiConfiguration(
endpoint: endpoint.text,
model: model.text,
);
if (apiKey.text.trim().isNotEmpty) {
await AiService.instance.saveApiKey(apiKey.text);
}
messenger.showSnackBar(const SnackBar(content: Text('服务设置已保存。')));
}
Future<void> _testConnection() async {
final messenger = ScaffoldMessenger.of(context);
setState(() => testingConnection = true);
final result = await AiService.instance.testConnection(
provider: widget.state.aiProvider,
endpoint: endpoint.text,
model: model.text,
explicitApiKey: apiKey.text,
);
if (!mounted) return;
setState(() => testingConnection = false);
messenger.showSnackBar(
SnackBar(
content: Text(result.message),
backgroundColor: result.ok ? AppColors.green : Colors.redAccent,
duration: const Duration(seconds: 4),
),
);
}
Future<void> _reloadFromAsset() async {
final messenger = ScaffoldMessenger.of(context);
final ok = await widget.state.reloadAiConfigFromAsset();
if (!mounted) return;
if (ok) {
setState(() {
endpoint.text = widget.state.aiEndpoint;
model.text = widget.state.aiModel;
});
}
messenger.showSnackBar(
SnackBar(
content: Text(
ok ? '已从 assets/config/ai_config.json 载入配置。' : '未找到配置文件或解析失败。',
),
),
);
}
}
@@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/courses/courses.dart';
import '../../core/models.dart';
import '../../widgets/app_widgets.dart';
/// 四技能评估:两套题组的状态与入口。
class AssessmentListView extends StatelessWidget {
const AssessmentListView({
super.key,
required this.state,
required this.onOpenAssessment,
});
final AppState state;
final ValueChanged<AssessmentPack> onOpenAssessment;
@override
Widget build(BuildContext context) => SpacedColumn(
children: [
const Text('评估不提供翻译或句框。两套都通过,且间隔 24 小时以上,才计入升级。'),
for (final pack in a0AssessmentPacks) _packCard(context, pack),
],
);
Widget _packCard(BuildContext context, AssessmentPack pack) {
final record = state.assessments
.where((item) => item.packId == pack.id)
.firstOrNull;
final canStart = state.canStartAssessmentPack(pack.id);
final (status, color) = switch (record) {
AssessmentRecord(passed: true) => ('已通过', AppColors.green),
AssessmentRecord() => ('未通过', AppColors.warmInk),
null when canStart => ('未开始', AppColors.muted),
null => ('未解锁', AppColors.muted),
};
return SectionCard(
tint: record?.passed == true ? AppColors.softGreen : null,
child: SpacedColumn(
children: [
Row(
children: [
Expanded(
child: Text(
pack.id,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
Text(status, style: TextStyle(color: color)),
],
),
Text(
canStart
? record == null
? '听 · 说 · 读 · 写 · 约 15 分钟'
: record.passed
? '四项技能都已通过'
: '还需通过:${record.failedSkills.map((skill) => skill.label).join('、')}'
: '第一套评估通过 24 小时后可开始',
style: Theme.of(context).textTheme.bodyMedium,
),
if (canStart && record?.passed != true)
PrimaryButton(
label: record == null ? '开始 ${pack.id}' : '重新评估 ${pack.id}',
onPressed: () => onOpenAssessment(pack),
),
],
),
);
}
}
@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/models.dart';
import '../../widgets/app_widgets.dart';
import 'profile_widgets.dart';
enum _RecordFilter { all, independent, assisted }
/// 学习记录:按天分组的作答证据,用于解释进度。
class LearningRecordsView extends StatefulWidget {
const LearningRecordsView({super.key, required this.state});
final AppState state;
@override
State<LearningRecordsView> createState() => _LearningRecordsViewState();
}
class _LearningRecordsViewState extends State<LearningRecordsView> {
static const _limit = 100;
var filter = _RecordFilter.all;
bool _matches(AttemptEvidence evidence) => switch (filter) {
_RecordFilter.all => true,
_RecordFilter.independent =>
evidence.outcome == EvidenceKind.independentSuccess,
_RecordFilter.assisted => evidence.outcome == EvidenceKind.assisted,
};
@override
Widget build(BuildContext context) {
final records = widget.state.attemptEvidence.reversed
.where(_matches)
.take(_limit)
.toList();
final days = <String, List<AttemptEvidence>>{};
for (final record in records) {
days.putIfAbsent(_dayLabel(record.createdAt), () => []).add(record);
}
return SpacedColumn(
spacing: 16,
children: [
SizedBox(
width: double.infinity,
child: SegmentedButton<_RecordFilter>(
segments: const [
ButtonSegment(value: _RecordFilter.all, label: Text('全部')),
ButtonSegment(
value: _RecordFilter.independent,
label: Text('独立完成'),
),
ButtonSegment(value: _RecordFilter.assisted, label: Text('带帮助')),
],
selected: {filter},
showSelectedIcon: false,
onSelectionChanged: (value) => setState(() => filter = value.first),
),
),
if (records.isEmpty)
const Text('还没有记录。完成课程、复习或对话后,会在这里看到每次作答。')
else
for (final MapEntry(key: day, value: items) in days.entries)
MenuGroup(
title: day,
children: [for (final item in items) _RecordTile(evidence: item)],
),
Text(
'记录只保存在本机,用于解释进度,不是公开记录。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
],
);
}
static String _dayLabel(DateTime time) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final days = today.difference(DateTime(time.year, time.month, time.day));
return switch (days.inDays) {
0 => '今天',
1 => '昨天',
_ => '${time.month} 月 ${time.day} 日',
};
}
}
class _RecordTile extends StatelessWidget {
const _RecordTile({required this.evidence});
final AttemptEvidence evidence;
@override
Widget build(BuildContext context) {
final answer = evidence.rawAnswer;
final (label, color, tint) = switch (evidence.outcome) {
EvidenceKind.independentSuccess => (
'独立完成',
AppColors.green,
AppColors.softGreen,
),
EvidenceKind.assisted => ('带提示', AppColors.warmInk, AppColors.warm),
EvidenceKind.languageError => ('需要复核', AppColors.warmInk, AppColors.warm),
EvidenceKind.pending => ('稍后完成', AppColors.muted, AppColors.surfaceMuted),
EvidenceKind.exposure => ('已查看', AppColors.muted, AppColors.surfaceMuted),
};
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
title: Text(
answer?.isNotEmpty == true ? answer! : evidence.skill,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text('${evidence.skill} · ${relativeTime(evidence.createdAt)}'),
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: tint,
borderRadius: BorderRadius.circular(99),
),
child: Text(label, style: TextStyle(fontSize: 12, color: color)),
),
);
}
}
@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../widgets/app_widgets.dart';
import 'profile_widgets.dart';
/// 升级进度:A0 出口的三项条件。
class LevelProgressView extends StatelessWidget {
const LevelProgressView({super.key, required this.state});
final AppState state;
@override
Widget build(BuildContext context) => SpacedColumn(
spacing: 16,
children: [
const Eyebrow('A0 → A1'),
Text(
state.a0Passed
? 'A0 已通过'
: '已完成 ${(state.a0StageProgress * 100).round()}%',
style: Theme.of(context).textTheme.headlineMedium,
),
Text(
state.a0Passed ? 'A1 主线内容尚未提供,可继续进行 A0 巩固。' : '进度来自掌握证据,不是上完固定课数就升级。',
),
MenuGroup(
children: [
GoalProgressRow(
icon: Icons.record_voice_over_outlined,
title: '可使用',
note: '核心内容已获得独立使用证据',
value: state.coreUsableCount,
goal: a0UsableGoal,
),
GoalProgressRow(
icon: Icons.verified_outlined,
title: '已掌握(间隔复习)',
note: '达到四次间隔复习要求',
value: state.coreMasteredCount,
goal: a0MasteredGoal,
),
GoalProgressRow(
icon: Icons.assignment_turned_in_outlined,
title: '两套四技能评估',
note: '两套不同题组都通过,间隔至少 24 小时',
value: state.passedAssessmentPackCount,
goal: 2,
),
],
),
SectionCard(
padding: EdgeInsets.zero,
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 14),
childrenPadding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
expandedCrossAxisAlignment: CrossAxisAlignment.start,
leading: Icon(Icons.info_outline, color: AppColors.muted),
title: const Text('进入下一阶段的条件'),
children: const [
Text(
'60 项固定核心内容中至少 $a0UsableGoal 项可使用、$a0MasteredGoal 项已掌握,且两套不同题组的听说读写评估都通过并间隔至少 24 小时。',
),
],
),
),
),
],
);
}
@@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/sherpa_stt_service.dart';
import '../../widgets/app_widgets.dart';
import 'profile_widgets.dart';
/// 学习偏好:每日时长、中文提示与语音识别。
class LearningPreferencesView extends StatelessWidget {
const LearningPreferencesView({super.key, required this.state});
final AppState state;
@override
Widget build(BuildContext context) => SpacedColumn(
spacing: 16,
children: [
MenuGroup(
children: [
MenuTile(
icon: Icons.timer_outlined,
title: '每日学习时间',
value: '${state.dailyMinutes} 分钟',
onTap: () => _chooseDuration(context),
),
MenuSwitch(
icon: Icons.translate,
title: '默认显示中文提示',
subtitle: 'A0 阶段建议开启',
value: state.showChineseHints,
onChanged: state.toggleChineseHints,
),
],
),
MenuGroup(
title: '语音',
children: [
MenuTile(
icon: Icons.graphic_eq,
title: '离线语音识别',
subtitle: SherpaSttService.instance.isReady
? 'SenseVoice-Small · 已就绪,本地识别'
: 'SenseVoice-Small · 已内置,无需下载',
trailing: Icon(
Icons.check_circle,
color: AppColors.green,
size: 20,
),
),
],
),
],
);
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: state.dailyMinutes == minute
? const Icon(Icons.check)
: null,
onTap: () => Navigator.pop(context, minute),
),
],
),
),
);
if (value != null) state.setDailyMinutes(value);
}
}
/// 外观:浅色 / 深色 / 跟随系统。
class AppearanceView extends StatelessWidget {
const AppearanceView({super.key, required this.state});
final AppState state;
@override
Widget build(BuildContext context) => SpacedColumn(
children: [
SizedBox(
width: double.infinity,
child: SegmentedButton<AppThemeMode>(
segments: const [
ButtonSegment(value: AppThemeMode.system, label: Text('跟随系统')),
ButtonSegment(value: AppThemeMode.light, label: Text('浅色')),
ButtonSegment(value: AppThemeMode.dark, label: Text('深色')),
],
selected: {state.themeMode},
showSelectedIcon: false,
onSelectionChanged: (selection) =>
state.setThemeMode(selection.first),
),
),
Text(
'深色模式适合夜间使用;跟随系统会随手机的深色开关自动切换。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
],
);
}
String themeModeLabel(AppThemeMode mode) => switch (mode) {
AppThemeMode.system => '跟随系统',
AppThemeMode.light => '浅色',
AppThemeMode.dark => '深色',
};
@@ -0,0 +1,317 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/courses/courses.dart';
import '../../core/sync/sync_coordinator.dart';
import '../../widgets/app_widgets.dart';
import 'ai_service_page.dart';
import 'assessment_list_page.dart';
import 'learning_records_page.dart';
import 'level_progress_page.dart';
import 'preference_pages.dart';
import 'profile_widgets.dart';
import 'recordings_page.dart';
import 'sync_settings_sheet.dart';
/// 我的:概览卡、升级进度,以及进入各二级页面的入口。
class ProfilePage extends StatelessWidget {
const ProfilePage({
super.key,
required this.state,
required this.onOpenAssessment,
required this.onOpenReview,
});
final AppState state;
final ValueChanged<AssessmentPack> onOpenAssessment;
final VoidCallback onOpenReview;
void _open(BuildContext context, String title, WidgetBuilder builder) =>
openProfilePage(context, state: state, title: title, builder: builder);
void _openLevel(BuildContext context) =>
_open(context, '升级进度', (_) => LevelProgressView(state: state));
void _openRecords(BuildContext context) =>
_open(context, '学习记录', (_) => LearningRecordsView(state: state));
@override
Widget build(BuildContext context) {
final passedPacks = state.passedAssessmentPackCount;
return AppPage(
child: SpacedColumn(
spacing: 16,
children: [
Text('我的', style: Theme.of(context).textTheme.headlineMedium),
_OverviewCard(
state: state,
onOpenLevel: () => _openLevel(context),
onOpenRecords: () => _openRecords(context),
onOpenReview: onOpenReview,
),
_StageCard(state: state, onTap: () => _openLevel(context)),
MenuGroup(
title: '学习',
children: [
MenuTile(
icon: Icons.trending_up,
title: '升级进度',
value: '${(state.a0StageProgress * 100).round()}%',
onTap: () => _openLevel(context),
),
MenuTile(
icon: Icons.quiz_outlined,
title: '四技能评估',
value: '$passedPacks/2 通过',
onTap: () => _open(
context,
'四技能评估',
(context) => AssessmentListView(
state: state,
onOpenAssessment: (pack) {
Navigator.of(context).pop();
onOpenAssessment(pack);
},
),
),
),
MenuTile(
icon: Icons.history,
title: '学习记录',
onTap: () => _openRecords(context),
),
MenuTile(
icon: Icons.library_music_outlined,
title: '我的录音',
onTap: () =>
_open(context, '我的录音', (_) => RecordingsView(state: state)),
),
],
),
MenuGroup(
title: '设置',
children: [
MenuTile(
icon: Icons.tune,
title: '学习偏好',
value: '${state.dailyMinutes} 分钟',
onTap: () => _open(
context,
'学习偏好',
(_) => LearningPreferencesView(state: state),
),
),
MenuTile(
icon: Icons.palette_outlined,
title: '外观',
value: themeModeLabel(state.themeMode),
onTap: () =>
_open(context, '外观', (_) => AppearanceView(state: state)),
),
ListenableBuilder(
listenable: SyncCoordinator.instance,
builder: (context, _) {
final loggedIn = SyncCoordinator.instance.isLoggedIn;
return MenuTile(
icon: loggedIn
? Icons.cloud_done_outlined
: Icons.cloud_queue_outlined,
iconColor: loggedIn ? AppColors.green : null,
title: '云同步',
value: loggedIn ? '已登录' : '未登录',
onTap: () => SyncSettingsSheet.show(context, state),
);
},
),
MenuTile(
icon: Icons.smart_toy_outlined,
title: 'AI 服务',
value: aiProviderLabel(state.aiProvider),
onTap: () =>
_open(context, 'AI 服务', (_) => AiServiceView(state: state)),
),
],
),
MenuGroup(
children: [
DangerAction(
label: '清除学习进度',
message:
'这会清除本机的课程/分段进度、草稿、复习队列、学习作答证据、掌握记录、对话草稿、AI 补练缓存和阶段评估结果,且不可恢复。',
details: '不会清除:已保存录音、AI API Key 与应用设置。录音请在“我的录音”中单独删除。',
acknowledgement: '我了解这些本机学习数据无法恢复',
onConfirm: state.clearProgress,
),
],
),
],
),
);
}
}
class _OverviewCard extends StatelessWidget {
const _OverviewCard({
required this.state,
required this.onOpenLevel,
required this.onOpenRecords,
required this.onOpenReview,
});
final AppState state;
final VoidCallback onOpenLevel;
final VoidCallback onOpenRecords;
final VoidCallback onOpenReview;
@override
Widget build(BuildContext context) {
final due = state.dueReviewCount;
return SectionCard(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Row(
children: [
CircleAvatar(
radius: 24,
backgroundColor: AppColors.softGreen,
child: Icon(Icons.person, color: AppColors.green),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
state.a0Passed ? 'A0 已通过' : 'A0 起步',
style: Theme.of(context).textTheme.titleMedium,
),
Text(
'每天 ${state.dailyMinutes} 分钟',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
],
),
Divider(height: 28, color: AppColors.line),
Row(
children: [
_Stat(
value: state.completedLessonIds.length,
label: '已学课',
onTap: onOpenRecords,
),
_Stat(
value: state.coreUsableCount,
label: '可使用',
onTap: onOpenLevel,
),
_Stat(
value: state.coreMasteredCount,
label: '已掌握',
onTap: onOpenLevel,
),
_Stat(
value: due,
label: '待复习',
color: due > 0 ? AppColors.warmInk : null,
onTap: onOpenReview,
),
],
),
],
),
);
}
}
class _Stat extends StatelessWidget {
const _Stat({
required this.value,
required this.label,
required this.onTap,
this.color,
});
final int value;
final String label;
final VoidCallback onTap;
final Color? color;
@override
Widget build(BuildContext context) => Expanded(
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
children: [
Text(
'$value',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: color ?? AppColors.ink,
),
),
Text(label, style: TextStyle(fontSize: 12, color: AppColors.muted)),
],
),
),
),
);
}
/// 距离下一阶段的总进度,点击进入升级进度。
class _StageCard extends StatelessWidget {
const _StageCard({required this.state, required this.onTap});
final AppState state;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final missing = [
if (state.coreUsableCount < a0UsableGoal)
'${a0UsableGoal - state.coreUsableCount} 项可使用',
if (state.coreMasteredCount < a0MasteredGoal)
'${a0MasteredGoal - state.coreMasteredCount} 项掌握',
if (state.passedAssessmentPackCount < 2)
'${2 - state.passedAssessmentPackCount} 套评估',
];
return SectionCard(
tint: AppColors.softGreen,
onTap: onTap,
child: SpacedColumn(
spacing: 8,
children: [
Row(
children: [
const Expanded(
child: Text(
'距离 A1',
style: TextStyle(fontWeight: FontWeight.w600),
),
),
Text(
'${(state.a0StageProgress * 100).round()}%',
style: TextStyle(color: AppColors.green),
),
Icon(Icons.chevron_right, color: AppColors.green),
],
),
ProgressBar(
value: state.a0StageProgress,
background: AppColors.surface,
),
Text(
missing.isEmpty
? 'A0 已通过。A1 主线内容尚未提供,可继续进行 A0 巩固。'
: '还需:${missing.join(' · ')}',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
);
}
}
@@ -0,0 +1,296 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../widgets/app_widgets.dart';
/// Opens a "我的" sub page. Pushed routes sit above the shell, so each page
/// listens to [state] itself to stay current.
Future<void> openProfilePage(
BuildContext context, {
required AppState state,
required String title,
required WidgetBuilder builder,
}) => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => AppPage(
appBar: AppBar(title: Text(title)),
child: ListenableBuilder(
listenable: state,
builder: (context, _) => builder(context),
),
),
),
);
/// A titled card whose rows are separated by dividers.
class MenuGroup extends StatelessWidget {
const MenuGroup({super.key, this.title, this.note, required this.children});
final String? title;
final String? note;
final List<Widget> children;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null || note != null)
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null)
Text(
title!,
style: TextStyle(fontSize: 13, color: AppColors.muted),
),
if (note != null)
Text(
note!,
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
],
),
),
SectionCard(
padding: EdgeInsets.zero,
child: Column(
children: [
for (var i = 0; i < children.length; i++) ...[
if (i > 0) Divider(height: 1, color: AppColors.line),
children[i],
],
],
),
),
],
);
}
/// One row of a [MenuGroup]: icon, title, optional current value and chevron.
class MenuTile extends StatelessWidget {
const MenuTile({
super.key,
required this.icon,
required this.title,
this.subtitle,
this.value,
this.iconColor,
this.trailing,
this.onTap,
});
final IconData icon;
final String title;
final String? subtitle;
final String? value;
final Color? iconColor;
final Widget? trailing;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) => ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
leading: Icon(icon, color: iconColor ?? AppColors.muted),
title: Text(title),
subtitle: subtitle == null ? null : Text(subtitle!),
trailing:
trailing ??
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (value != null)
Text(
value!,
style: TextStyle(fontSize: 13, color: AppColors.muted),
),
if (onTap != null)
Icon(Icons.chevron_right, color: AppColors.muted),
],
),
onTap: onTap,
);
}
/// A [MenuTile]-aligned switch row.
class MenuSwitch extends StatelessWidget {
const MenuSwitch({
super.key,
required this.icon,
required this.title,
this.subtitle,
required this.value,
required this.onChanged,
});
final IconData icon;
final String title;
final String? subtitle;
final bool value;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) => SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
secondary: Icon(icon, color: AppColors.muted),
title: Text(title),
subtitle: subtitle == null ? null : Text(subtitle!),
value: value,
activeThumbColor: AppColors.green,
onChanged: onChanged,
);
}
/// A labelled progress bar, e.g. "可使用 31 / 48".
class GoalProgressRow extends StatelessWidget {
const GoalProgressRow({
super.key,
required this.icon,
required this.title,
required this.note,
required this.value,
required this.goal,
});
final IconData icon;
final String title;
final String note;
final int value;
final int goal;
@override
Widget build(BuildContext context) {
final completed = value >= goal;
return Padding(
padding: const EdgeInsets.all(14),
child: Row(
children: [
Icon(icon, color: completed ? AppColors.green : AppColors.muted),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
title,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
Text(
'$value / $goal',
style: TextStyle(color: AppColors.muted),
),
],
),
const SizedBox(height: 6),
ProgressBar(value: goal == 0 ? 0 : value / goal),
const SizedBox(height: 6),
Text(note, style: Theme.of(context).textTheme.bodyMedium),
],
),
),
],
),
);
}
}
class ProgressBar extends StatelessWidget {
const ProgressBar({super.key, required this.value, this.background});
final double value;
final Color? background;
@override
Widget build(BuildContext context) => ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: value.clamp(0, 1).toDouble(),
minHeight: 6,
color: AppColors.green,
backgroundColor: background ?? AppColors.surfaceMuted,
),
);
}
/// A red row that asks for explicit acknowledgement before [onConfirm].
class DangerAction extends StatelessWidget {
const DangerAction({
super.key,
required this.label,
required this.message,
this.details,
this.acknowledgement,
required this.onConfirm,
});
final String label;
final String message;
final String? details;
/// When set, the confirm button stays disabled until this box is ticked.
final String? acknowledgement;
final VoidCallback onConfirm;
@override
Widget build(BuildContext context) => ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 14),
leading: const Icon(Icons.delete_forever_outlined, color: Colors.redAccent),
title: Text(label, style: const TextStyle(color: Colors.redAccent)),
onTap: () => showDialog<void>(
context: context,
builder: (context) {
var acknowledged = acknowledgement == null;
return StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: Text(label),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(message),
if (details != null) ...[
const SizedBox(height: 12),
Text(details!),
],
if (acknowledgement != null)
CheckboxListTile(
contentPadding: EdgeInsets.zero,
value: acknowledged,
onChanged: (value) =>
setDialogState(() => acknowledged = value ?? false),
title: Text(acknowledgement!),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Colors.redAccent,
),
onPressed: acknowledged
? () {
Navigator.pop(context);
onConfirm();
}
: null,
child: const Text('确认清除'),
),
],
),
);
},
),
);
}
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} 天前';
}
@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import '../../core/app_state.dart';
import '../../core/app_theme.dart';
import '../../core/voice_service.dart';
import '../../widgets/app_widgets.dart';
import 'profile_widgets.dart';
/// 我的录音:回听或删除本机保存的练习录音。
class RecordingsView extends StatefulWidget {
const RecordingsView({super.key, required this.state});
final AppState state;
@override
State<RecordingsView> createState() => _RecordingsViewState();
}
class _RecordingsViewState extends State<RecordingsView> {
late Future<List<String>> recordings = VoiceService.instance
.listRecordingPaths();
void _reload() => setState(() {
recordings = VoiceService.instance.listRecordingPaths();
});
@override
Widget build(BuildContext context) => SpacedColumn(
spacing: 16,
children: [
MenuGroup(
children: [
MenuSwitch(
icon: Icons.mic_none,
title: '保存原始录音',
subtitle: '仅保存在本机',
value: widget.state.keepRecordings,
onChanged: widget.state.toggleKeepRecordings,
),
],
),
FutureBuilder<List<String>>(
future: recordings,
builder: (context, snapshot) {
final paths = snapshot.data;
if (paths == null) {
return const Center(child: CircularProgressIndicator());
}
if (paths.isEmpty) {
return const Text('还没有保存的录音。开启“保存原始录音”后,在跟读步骤完成录音即可在这里回听。');
}
return SpacedColumn(
spacing: 16,
children: [
MenuGroup(
title: '共 ${paths.length} 条',
children: [for (final path in paths) _recordingTile(path)],
),
MenuGroup(
children: [
DangerAction(
label: '清除全部录音',
message: '这些原始音频只保存在本机。删除后无法恢复,学习进度不会改变。',
onConfirm: _deleteAll,
),
],
),
],
);
},
),
],
);
Widget _recordingTile(String path) => ListTile(
contentPadding: const EdgeInsets.only(left: 4, right: 4),
leading: IconButton(
tooltip: '回听',
onPressed: () => VoiceService.instance.playRecording(path),
icon: Icon(Icons.play_circle_outline, color: AppColors.green),
),
title: Text(path.split('/').last.replaceAll('.m4a', '')),
trailing: IconButton(
tooltip: '删除',
onPressed: () async {
await VoiceService.instance.deleteRecording(path);
if (mounted) _reload();
},
icon: Icon(Icons.delete_outline, color: AppColors.muted),
),
);
Future<void> _deleteAll() async {
final messenger = ScaffoldMessenger.of(context);
final count = await VoiceService.instance.deleteAllRecordings();
if (!mounted) return;
_reload();
messenger.showSnackBar(
SnackBar(content: Text(count == 0 ? '没有已保存的录音。' : '已清除 $count 段本机录音。')),
);
}
}
@@ -21,9 +21,7 @@ class SyncSettingsSheet extends StatefulWidget {
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SafeArea(
child: SyncSettingsSheet(state: state),
),
child: SafeArea(child: SyncSettingsSheet(state: state)),
),
);
}
@@ -78,9 +76,9 @@ class _SyncSettingsSheetState extends State<SyncSettingsSheet> {
final pwd = _passwordController.text;
if (server.isEmpty || user.isEmpty || pwd.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请填写完整的服务器地址、账号和密码')),
);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请填写完整的服务器地址、账号和密码')));
return;
}
@@ -141,7 +139,7 @@ class _SyncSettingsSheetState extends State<SyncSettingsSheet> {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
child: SpacedColumn(
children: [
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -159,8 +157,8 @@ class _SyncSettingsSheetState extends State<SyncSettingsSheet> {
color: _coordinator.state == SyncState.syncing
? AppColors.warm
: _coordinator.state == SyncState.error
? Colors.red.shade50
: AppColors.softGreen,
? Colors.red.shade50
: AppColors.softGreen,
borderRadius: BorderRadius.circular(12),
),
child: Row(
@@ -170,29 +168,29 @@ class _SyncSettingsSheetState extends State<SyncSettingsSheet> {
_coordinator.state == SyncState.syncing
? Icons.sync
: _coordinator.state == SyncState.error
? Icons.error_outline
: Icons.cloud_done_outlined,
? Icons.error_outline
: Icons.cloud_done_outlined,
size: 14,
color: _coordinator.state == SyncState.syncing
? AppColors.warmInk
: _coordinator.state == SyncState.error
? Colors.redAccent
: AppColors.green,
? Colors.redAccent
: AppColors.green,
),
const SizedBox(width: 4),
Text(
_coordinator.state == SyncState.syncing
? '同步中'
: _coordinator.state == SyncState.error
? '同步异常'
: '已连接',
? '同步异常'
: '已连接',
style: TextStyle(
fontSize: 12,
color: _coordinator.state == SyncState.syncing
? AppColors.warmInk
: _coordinator.state == SyncState.error
? Colors.redAccent
: AppColors.green,
? Colors.redAccent
: AppColors.green,
fontWeight: FontWeight.w600,
),
),
@@ -211,7 +209,7 @@ class _SyncSettingsSheetState extends State<SyncSettingsSheet> {
// Logged In State
SectionCard(
child: Column(
children: [
children: [
Row(
children: [
CircleAvatar(
@@ -222,7 +220,7 @@ class _SyncSettingsSheetState extends State<SyncSettingsSheet> {
const SizedBox(width: 12),
Expanded(
child: Column(
children: [
children: [
Text(
_coordinator.username ?? '同步用户',
style: const TextStyle(
@@ -273,7 +271,10 @@ class _SyncSettingsSheetState extends State<SyncSettingsSheet> {
tint: Colors.red.shade50,
child: Row(
children: [
const Icon(Icons.error_outline, color: Colors.redAccent),
const Icon(
Icons.error_outline,
color: Colors.redAccent,
),
const SizedBox(width: 10),
Expanded(
child: Text(
@@ -337,7 +338,9 @@ class _SyncSettingsSheetState extends State<SyncSettingsSheet> {
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.network_check),
onPressed: _testingConnection ? null : _handleTestConnection,
onPressed: _testingConnection
? null
: _handleTestConnection,
),
),
),
@@ -346,7 +349,9 @@ class _SyncSettingsSheetState extends State<SyncSettingsSheet> {
_testMessage!,
style: TextStyle(
fontSize: 12,
color: _testOk == true ? AppColors.green : Colors.redAccent,
color: _testOk == true
? AppColors.green
: Colors.redAccent,
),
),
TextField(
@@ -1,744 +0,0 @@
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 '../../core/sherpa_stt_service.dart';
import '../../widgets/app_widgets.dart';
import '../../core/sync/sync_coordinator.dart';
import 'sync_settings_sheet.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 小时。',
),
),
AnimatedBuilder(
animation: SyncCoordinator.instance,
builder: (context, _) {
final loggedIn = SyncCoordinator.instance.isLoggedIn;
return SectionCard(
onTap: () => SyncSettingsSheet.show(context, state),
child: Row(
children: [
Icon(
loggedIn
? Icons.cloud_done_outlined
: Icons.cloud_queue_outlined,
color: loggedIn ? AppColors.green : AppColors.muted,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loggedIn
? '云同步:${SyncCoordinator.instance.username}'
: '云端同步与多端备份',
style: const TextStyle(fontWeight: FontWeight.w600),
),
Text(
loggedIn
? '多端学习进度与复习状态已连接 · 点击管理'
: '未登录 · 点击配置自建服务器,在手机与电脑间同步',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
Icon(Icons.chevron_right, color: AppColors.muted),
],
),
);
},
),
if (state.a0Passed)
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, this.onBack});
final AppState state;
final VoidCallback? onBack;
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
late final TextEditingController endpoint;
late final TextEditingController model;
final apiKey = TextEditingController();
bool _testingConnection = false;
@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(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: () {
if (widget.onBack != null) {
widget.onBack!();
} else if (Navigator.canPop(context)) {
Navigator.pop(context);
}
},
),
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,
),
Padding(
padding: const EdgeInsets.fromLTRB(4, 10, 4, 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'外观主题',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text(
'深色模式适合夜间使用;跟随系统会随手机的深色开关自动切换',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: SegmentedButton<AppThemeMode>(
segments: const [
ButtonSegment(
value: AppThemeMode.system,
label: Text('跟随系统'),
),
ButtonSegment(value: AppThemeMode.light, label: Text('浅色')),
ButtonSegment(value: AppThemeMode.dark, label: Text('深色')),
],
selected: {widget.state.themeMode},
showSelectedIcon: false,
onSelectionChanged: (selection) =>
widget.state.setThemeMode(selection.first),
),
),
],
),
),
_SettingTile(
title: '已保存的录音',
subtitle: '回听或删除本机英语练习录音',
onTap: () => _showRecordings(context),
),
_SettingTile(
title: '清除已保存的录音',
subtitle: '只删除本机原始音频,不影响学习进度或 AI 密钥',
onTap: () => _confirmDeleteRecordings(context),
),
const Divider(height: 28),
const Text(
'离线语音识别引擎',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
Text(
'已内置 SenseVoice-Small 高精度离线语音识别模型 (INT8)。随 App 安装包直接打包,离线即用,无需额外下载,零网络流量消耗。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
_SettingTile(
title: '离线语音识别:SenseVoice-Small',
subtitle: SherpaSttService.instance.isReady
? '已就绪 · 本地离线识别 (16kHz WAV · INT8)'
: '预加载就绪 · 已内置打包',
trailing: Icon(Icons.check_circle, color: AppColors.green, size: 20),
),
const Divider(height: 28),
const Text(
'云同步与多端备份',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
Text(
'支持通过自建服务器在 Android / iOS / macOS 之间同步学习进度与复习掌握度。离线自动缓存,联网自动双向合并。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
AnimatedBuilder(
animation: SyncCoordinator.instance,
builder: (context, _) => _SettingTile(
title: SyncCoordinator.instance.isLoggedIn
? '同步账号:${SyncCoordinator.instance.username}'
: '配置云端同步账号',
subtitle: SyncCoordinator.instance.isLoggedIn
? '已连接自建服务器 · 点击管理同步'
: '未登录 · 点击配置自建服务器并登录',
onTap: () => SyncSettingsSheet.show(context, widget.state),
),
),
const Divider(height: 28),
const Text(
'AI 对话服务',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
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;
setState(() {
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: _testingConnection ? '正在测试连接...' : '测试连接',
onPressed: _testingConnection
? null
: () async {
final messenger = ScaffoldMessenger.of(context);
setState(() => _testingConnection = true);
final result = await AiService.instance.testConnection(
provider: widget.state.aiProvider,
endpoint: endpoint.text,
model: model.text,
explicitApiKey: apiKey.text,
);
if (!mounted) return;
setState(() => _testingConnection = false);
messenger.showSnackBar(
SnackBar(
content: Text(result.message),
backgroundColor:
result.ok ? AppColors.green : Colors.redAccent,
duration: const Duration(seconds: 4),
),
);
},
),
SecondaryButton(
label: '从配置文件重载 (ai_config.json)',
onPressed: () async {
final messenger = ScaffoldMessenger.of(context);
final ok = await widget.state.reloadAiConfigFromAsset();
if (!mounted) return;
if (ok) {
setState(() {
endpoint.text = widget.state.aiEndpoint;
model.text = widget.state.aiModel;
});
messenger.showSnackBar(
const SnackBar(
content: Text('已从 assets/config/ai_config.json 载入配置。'),
),
);
} else {
messenger.showSnackBar(
const SnackBar(content: Text('未找到配置文件或解析失败。')),
);
}
},
),
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: [
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,
this.onTap,
this.trailing,
});
final String title;
final String subtitle;
final VoidCallback? onTap;
final Widget? trailing;
@override
Widget build(BuildContext context) => ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
title: Text(title),
subtitle: Text(subtitle),
trailing: trailing ?? (onTap != null ? const Icon(Icons.chevron_right) : null),
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('确认清除'),
),
],
),
);
},
),
);
}
@@ -7,7 +7,8 @@ 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/courses/courses.dart';
import '../../core/speech_compare.dart';
import '../../core/voice_service.dart';
import '../../widgets/app_widgets.dart';
import '../../widgets/voice_answer.dart';
@@ -35,9 +36,18 @@ class _ReviewPageState extends State<ReviewPage>
String? validationMessage;
bool generatingVariant = false;
bool generatingLesson = false;
bool checkingWithAi = false;
WritingAiFeedback? aiFeedback;
String? aiFeedbackError;
/// The untouched transcript of a spoken answer; null for typed answers.
String? transcript;
/// The failed dictation check, shown word by word.
SpokenComparison? dictationDiff;
/// The answer just accepted, shown before moving to the next item.
({String answer, String reference, String? audio, bool assisted})? lastResult;
bool dictationPlayed = false;
@override
@@ -58,18 +68,65 @@ class _ReviewPageState extends State<ReviewPage>
showHint = false;
usedHelp = false;
validationMessage = null;
dictationDiff = null;
transcript = null;
recordingPath = null;
dictationPlayed = false;
aiFeedback = null;
aiFeedbackError = null;
}
Future<void> _checkWithAi(ReviewItem item) async {
final answer = controller.text.trim();
if (answer.isEmpty || checkingWithAi) return;
if (widget.state.aiProvider == AiProviderType.mock) {
setState(() => aiFeedbackError = '请先在“我的”配置 AI 服务;本地检查仍可继续复习。');
return;
}
setState(() {
checkingWithAi = true;
aiFeedbackError = null;
});
final feedback = await AiService.instance.answerFeedback(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
answerId: item.id,
target: item.target,
taskPrompt: item.prompt,
answer: answer,
level: itemLevel(item.id),
);
// The learner may have moved on or kept typing while the request ran.
if (!mounted || controller.text.trim() != answer) {
if (mounted) setState(() => checkingWithAi = false);
return;
}
setState(() {
checkingWithAi = false;
aiFeedback = feedback;
aiFeedbackError = feedback == null
? '暂时无法获得 AI 检查结果。你的答案保留在这里,可稍后重试或直接提交。'
: null;
// A shown correction is help: finishing afterwards counts as assisted.
if (feedback != null && feedback.verdict != 'accepted') usedHelp = true;
});
}
void _next(ReviewItem item, {required bool assisted}) {
final result = ReviewFeedback.check(item, controller.text);
if (!result.complete) {
setState(() => validationMessage = result.message);
setState(() {
validationMessage = result.message;
dictationDiff = result.dictation;
});
return;
}
final answer = controller.text.trim();
final dictation = item.skill == dictationSkill
? coreDictationSentences[item.id]
: null;
final hint = item.hint.trim();
final spoken = transcript != null;
widget.state.completeReview(
item,
@@ -83,7 +140,79 @@ class _ReviewPageState extends State<ReviewPage>
);
// A kept recording now belongs to the evidence row; don't delete it.
if (spoken && widget.state.keepRecordings) recordingPath = null;
setState(_resetAnswer);
setState(() {
_resetAnswer();
lastResult = (
answer: answer,
reference:
dictation?.sentence ??
(hint.isNotEmpty && hint != item.target
? '${item.target}\n例:$hint'
: item.target),
audio: dictation?.sentence ?? reviewAudioText(item.id, item.target),
assisted: assisted,
);
});
}
Widget _resultView(
({String answer, String reference, String? audio, bool assisted}) result,
) {
final remaining = widget.state.dueReviews.length;
return AppPage(
child: SpacedColumn(
children: [
Eyebrow(remaining > 0 ? '还有 $remaining 项待复习' : '今天的复习项目已全部完成'),
Row(
children: [
Icon(
result.assisted ? Icons.lightbulb_outline : Icons.check_circle,
color: result.assisted ? AppColors.warmInk : AppColors.green,
size: 32,
),
const SizedBox(width: 10),
Expanded(
child: Text(
result.assisted ? '带提示完成' : '答对了!',
style: Theme.of(context).textTheme.headlineMedium,
),
),
],
),
SectionCard(
child: SpacedColumn(
spacing: 8,
children: [
Text('你的回答:${result.answer}'),
Text(
'参考说法:${result.reference}',
style: TextStyle(
color: AppColors.green,
fontWeight: FontWeight.w600,
),
),
if (result.audio != null)
TextButton.icon(
onPressed: () => VoiceService.instance.speak(result.audio!),
icon: const Icon(Icons.volume_up_outlined),
label: const Text('听参考说法'),
),
],
),
),
Text(
result.assisted
? '用过提示的项目会在明天换题再练一次。'
: '本地检查确认用上了目标表达;下次会在更长的间隔后再检查。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
PrimaryButton(
label: remaining > 0 ? '下一题' : '完成今天的复习',
onPressed: () => setState(() => lastResult = null),
),
],
),
);
}
Future<void> _toggleVoice() async {
@@ -94,6 +223,8 @@ class _ReviewPageState extends State<ReviewPage>
controller.text = text;
transcript = text;
validationMessage = null;
aiFeedback = null;
aiFeedbackError = null;
},
);
return;
@@ -134,8 +265,8 @@ class _ReviewPageState extends State<ReviewPage>
}
Future<void> _generateAdaptiveLesson(ReviewItem item) async {
final label = a0CoreItems[item.id];
if (label == null) return;
if (!isCoreItem(item.id)) return;
final label = coreItemEnglish(item.id);
setState(() => generatingLesson = true);
final lesson = await AiService.instance.generateAdaptiveLesson(
provider: widget.state.aiProvider,
@@ -176,6 +307,7 @@ class _ReviewPageState extends State<ReviewPage>
@override
Widget build(BuildContext context) {
if (lastResult case final result?) return _resultView(result);
final item = widget.state.dueReviews.isEmpty
? null
: widget.state.dueReviews.first;
@@ -195,7 +327,7 @@ class _ReviewPageState extends State<ReviewPage>
);
}
final dictation = item.skill == dictationSkill
? a0DictationSentences[item.id]
? coreDictationSentences[item.id]
: null;
final spokenTask = item.skill == spokenRecallSkill;
final audioText = reviewAudioText(item.id, item.target);
@@ -277,7 +409,11 @@ class _ReviewPageState extends State<ReviewPage>
minLines: 2,
maxLines: 4,
enabled: dictation == null || dictationPlayed,
onChanged: (_) => setState(() {}),
onChanged: (_) => setState(() {
aiFeedback = null;
aiFeedbackError = null;
dictationDiff = null;
}),
decoration: InputDecoration(
hintText: dictation != null
? (dictationPlayed ? '写下你听到的英文' : '先播放句子')
@@ -336,7 +472,7 @@ class _ReviewPageState extends State<ReviewPage>
? null
: () => _generateVariant(item),
),
if (a0CoreItems.containsKey(item.id))
if (isCoreItem(item.id))
ActionChip(
label: Text(generatingLesson ? '审核补练中…' : '生成四技能补练'),
onPressed: generatingLesson
@@ -372,11 +508,56 @@ class _ReviewPageState extends State<ReviewPage>
],
),
),
// Dictation is already compared word by word against the sentence.
if (dictation == null)
OutlinedButton.icon(
onPressed: controller.text.trim().isEmpty || checkingWithAi
? null
: () => _checkWithAi(item),
icon: checkingWithAi
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.spellcheck),
label: Text(checkingWithAi ? '正在检查…' : 'AI 检查语法和拼写(可选)'),
),
if (aiFeedback != null)
SectionCard(
tint: aiFeedback!.verdict == 'accepted'
? AppColors.softGreen
: AppColors.warm,
child: SpacedColumn(
spacing: 6,
children: [
Text('AI 检查:${aiFeedback!.feedback}'),
for (final note in aiFeedback!.missing) Text('· $note'),
if (aiFeedback!.suggestion != null)
Text('参考改正:${aiFeedback!.suggestion}'),
Text(
aiFeedback!.verdict == 'accepted'
? 'AI 仅作参考;是否完成仍由本地检查判断。'
: '看过改正后再提交,本次会记为带提示完成。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
],
),
),
if (aiFeedbackError != null)
SectionCard(
tint: AppColors.warm,
child: Text(
aiFeedbackError!,
style: TextStyle(color: AppColors.warmInk),
),
),
if (validationMessage != null)
Text(
validationMessage!,
style: TextStyle(color: AppColors.warmInk),
),
if (dictationDiff case final diff?) _DictationDiffCard(diff: diff),
PrimaryButton(
label: usedHelp ? '带提示完成' : '我能独立回答',
onPressed: controller.text.trim().isEmpty
@@ -393,6 +574,47 @@ class _ReviewPageState extends State<ReviewPage>
}
}
/// Shows which dictated words were caught without giving the missed ones
/// away: missed words become blanks and wrong words are listed.
class _DictationDiffCard extends StatelessWidget {
const _DictationDiffCard({required this.diff});
final SpokenComparison diff;
@override
Widget build(BuildContext context) => SectionCard(
tint: AppColors.warm,
child: SpacedColumn(
spacing: 8,
children: [
Text(
'写对 ${diff.heardCount}/${diff.total} 个词;空格处是漏写或写错的词:',
style: TextStyle(color: AppColors.warmInk),
),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final word in diff.words)
Text(
word.heard ? word.text : '__',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: word.heard ? AppColors.green : AppColors.warmInk,
),
),
],
),
if (diff.extraWords.isNotEmpty)
Text(
'句子里没有这些词(可能拼错了):${diff.extraWords.join('、')}',
style: TextStyle(color: AppColors.warmInk),
),
],
),
);
}
/// Displays an audited AI-authored reinforcement lesson. It remains a
/// teaching activity: the existing local review and assessment systems own
/// all mastery decisions.
@@ -415,6 +637,15 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
int index = 0;
bool showReference = false;
String? answerFeedback;
/// The answer passed the local check; the next tap records it.
bool answerAccepted = false;
bool checkingWithAi = false;
WritingAiFeedback? aiCheck;
String? aiCheckError;
/// A shown AI correction is help, so this task is recorded as assisted.
bool aiCorrected = false;
bool usedVoice = false;
bool transcriptEdited = false;
bool transcriptConfirmed = false;
@@ -464,16 +695,25 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
}
void _advance(GeneratedLesson lesson, GeneratedLessonTask task) {
final correct = matchesAdaptiveLessonAnswer(task, controller.text);
final shortfall = adaptiveAnswerShortfall(task, controller.text);
final correct = shortfall == null;
if (!showReference && !correct) {
setState(() => answerFeedback = '还缺少目标表达中的关键信息。可以重试,或查看参考后以教学模式继续。');
setState(() => answerFeedback = '$shortfall 可以重试,或查看参考后以教学模式继续。');
return;
}
// Show the verdict before moving on, so the learner sees it.
if (!answerAccepted) {
setState(() {
answerAccepted = true;
answerFeedback = correct ? null : '$shortfall 已查看参考,本项按教学练习继续。';
});
return;
}
widget.state.recordAdaptiveLessonTask(
lesson: lesson,
task: task,
rawAnswer: controller.text.trim(),
assisted: showReference,
assisted: showReference || aiCorrected,
correct: correct,
inputMode: usedVoice && !transcriptEdited && transcriptConfirmed
? 'speechToText'
@@ -504,6 +744,8 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
controller.clear();
showReference = false;
answerFeedback = null;
_clearChecks();
aiCorrected = false;
usedVoice = false;
transcriptEdited = false;
transcriptConfirmed = false;
@@ -513,6 +755,49 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
});
}
void _clearChecks() {
answerAccepted = false;
aiCheck = null;
aiCheckError = null;
}
Future<void> _checkWithAi(GeneratedLessonTask task) async {
final answer = controller.text.trim();
if (answer.isEmpty || checkingWithAi) return;
if (widget.state.aiProvider == AiProviderType.mock) {
setState(() => aiCheckError = '请先在“我的”配置 AI 服务;本地检查仍可继续补练。');
return;
}
setState(() {
checkingWithAi = true;
aiCheckError = null;
});
final feedback = await AiService.instance.answerFeedback(
provider: widget.state.aiProvider,
endpoint: widget.state.aiEndpoint,
model: widget.state.aiModel,
answerId: task.taskId,
target: task.answer,
taskPrompt: '${task.prompt} ${task.stimulus}',
answer: answer,
);
// The learner may have kept typing or moved on while the request ran.
if (!mounted || controller.text.trim() != answer) {
if (mounted) setState(() => checkingWithAi = false);
return;
}
setState(() {
checkingWithAi = false;
aiCheck = feedback;
aiCheckError = feedback == null
? '暂时无法获得 AI 检查结果。你的答案保留在这里,可稍后重试或直接继续。'
: null;
if (feedback != null && feedback.verdict != 'accepted') {
aiCorrected = true;
}
});
}
@override
AppState get voiceState => widget.state;
@@ -526,6 +811,7 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
transcriptEdited = false;
transcriptConfirmed = false;
lastTranscript = text;
_clearChecks();
},
afterTranscribe: () {
final lesson = widget.state.cachedAdaptiveLesson;
@@ -654,7 +940,10 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
transcriptConfirmed = false;
}
_saveDraft(lesson);
setState(() {});
setState(() {
_clearChecks();
answerFeedback = null;
});
},
decoration: InputDecoration(
hintText: '输入或说出你的答案后,再继续',
@@ -727,10 +1016,73 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
],
),
),
if (task.skill == 'writing' || task.skill == 'speaking')
OutlinedButton.icon(
onPressed: controller.text.trim().isEmpty || checkingWithAi
? null
: () => _checkWithAi(task),
icon: checkingWithAi
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.spellcheck),
label: Text(checkingWithAi ? '正在检查…' : 'AI 检查语法和拼写(可选)'),
),
if (aiCheck != null)
SectionCard(
tint: aiCheck!.verdict == 'accepted'
? AppColors.softGreen
: AppColors.warm,
child: SpacedColumn(
spacing: 6,
children: [
Text('AI 检查:${aiCheck!.feedback}'),
for (final note in aiCheck!.missing) Text('· $note'),
if (aiCheck!.suggestion != null)
Text('参考改正:${aiCheck!.suggestion}'),
Text(
aiCheck!.verdict == 'accepted'
? 'AI 仅作参考;是否正确仍由本地检查判断。'
: '看过改正后再继续,本项会记为带提示完成。',
style: TextStyle(fontSize: 12, color: AppColors.muted),
),
],
),
),
if (aiCheckError != null)
SectionCard(
tint: AppColors.warm,
child: Text(
aiCheckError!,
style: TextStyle(color: AppColors.warmInk),
),
),
if (answerFeedback != null)
Text(
answerFeedback!,
style: TextStyle(color: AppColors.warmInk),
Text(answerFeedback!, style: TextStyle(color: AppColors.warmInk)),
if (answerAccepted && answerFeedback == null)
SectionCard(
tint: AppColors.softGreen,
child: SpacedColumn(
spacing: 6,
children: [
Row(
children: [
Icon(Icons.check_circle, color: AppColors.green),
const SizedBox(width: 8),
Text(
showReference || aiCorrected ? '正确(带提示完成)' : '答对了!',
style: TextStyle(
color: AppColors.green,
fontWeight: FontWeight.w600,
),
),
],
),
Text('参考表达:${task.answer}'),
],
),
),
if (showReference)
SectionCard(
@@ -757,7 +1109,11 @@ class _AdaptiveLessonPageState extends State<AdaptiveLessonPage>
label: const Text('内容有问题'),
),
PrimaryButton(
label: index == lesson.tasks.length - 1 ? '完成补练' : '下一项',
label: !answerAccepted
? '检查答案'
: index == lesson.tasks.length - 1
? '完成补练'
: '下一项',
onPressed: controller.text.trim().isNotEmpty
? () => _advance(lesson, task)
: null,
@@ -3,14 +3,12 @@ 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 "../../core/courses/courses.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 "../profile/profile_page.dart";
import "../review/review_page.dart";
class LearningShell extends StatefulWidget {
@@ -22,7 +20,7 @@ class LearningShell extends StatefulWidget {
}
class _LearningShellState extends State<LearningShell> {
AppTab tab = AppTab.home;
AppTab tab = AppTab.learn;
var route = _ShellRoute.tab;
_ShellRoute? previousRoute;
bool dialogueInLesson = false;
@@ -41,11 +39,6 @@ class _LearningShellState extends State<LearningShell> {
route = _ShellRoute.lesson;
});
void showDialogueScene() => setState(() {
previousRoute = route;
route = _ShellRoute.scene;
});
void showDialogue({bool inLesson = false, String? scene}) => setState(() {
previousRoute = route;
dialogueInLesson = inLesson;
@@ -59,11 +52,6 @@ class _LearningShellState extends State<LearningShell> {
route = _ShellRoute.summary;
});
void showSettings() => setState(() {
previousRoute = route;
route = _ShellRoute.settings;
});
void showAssessment(AssessmentPack pack) => setState(() {
previousRoute = route;
assessmentPack = pack;
@@ -84,33 +72,27 @@ class _LearningShellState extends State<LearningShell> {
switch (route) {
case _ShellRoute.lesson:
showTab(tab);
case _ShellRoute.scene:
showTab(tab);
case _ShellRoute.dialogue:
if (dialogueInLesson) {
showLesson();
} else if (previousRoute == _ShellRoute.scene) {
showDialogueScene();
} else {
showTab(tab == AppTab.dialogue ? AppTab.dialogue : tab);
showTab(tab);
}
case _ShellRoute.summary:
showTab(tab);
case _ShellRoute.settings:
showTab(AppTab.progress);
case _ShellRoute.adaptiveLesson:
showTab(AppTab.review);
case _ShellRoute.assessmentPreparation:
showTab(AppTab.progress);
showTab(AppTab.profile);
case _ShellRoute.assessment:
if (assessmentPack != null) {
showAssessment(assessmentPack!);
} else {
showTab(AppTab.progress);
showTab(AppTab.profile);
}
case _ShellRoute.tab:
if (tab != AppTab.home) {
showTab(AppTab.home);
if (tab != AppTab.learn) {
showTab(AppTab.learn);
}
}
}
@@ -130,12 +112,6 @@ class _LearningShellState extends State<LearningShell> {
onOpenDialogue: () => showDialogue(inLesson: true),
onFinish: () => showTab(tab),
);
case _ShellRoute.scene:
body = DialogueScenePage(
state: widget.state,
onStart: (id) => showDialogue(scene: id),
onBack: () => showTab(tab),
);
case _ShellRoute.dialogue:
body = DialoguePage(
key: ValueKey(dialogueInLesson ? 'lesson' : 'scene-$sceneId'),
@@ -155,16 +131,11 @@ class _LearningShellState extends State<LearningShell> {
case _ShellRoute.summary:
body = DialogueSummaryPage(
summary: dialogueSummary!,
onHome: () => showTab(AppTab.home),
onHome: () => showTab(AppTab.learn),
onLesson: showLesson,
onRetry: () => showDialogue(scene: sceneId),
onBack: () => showTab(tab),
);
case _ShellRoute.settings:
body = SettingsPage(
state: widget.state,
onBack: () => showTab(AppTab.progress),
);
case _ShellRoute.adaptiveLesson:
body = AdaptiveLessonPage(
state: widget.state,
@@ -174,7 +145,7 @@ class _LearningShellState extends State<LearningShell> {
body = AssessmentPage(
state: widget.state,
pack: assessmentPack!,
onFinished: () => showTab(AppTab.progress),
onFinished: () => showTab(AppTab.profile),
onStartReplacement: showAssessment,
);
case _ShellRoute.assessmentPreparation:
@@ -182,14 +153,14 @@ class _LearningShellState extends State<LearningShell> {
state: widget.state,
pack: assessmentPack!,
onStart: startAssessment,
onBack: () => showTab(AppTab.progress),
onBack: () => showTab(AppTab.profile),
);
case _ShellRoute.tab:
body = _tabContent();
}
return PopScope(
canPop: route == _ShellRoute.tab && tab == AppTab.home,
canPop: route == _ShellRoute.tab && tab == AppTab.learn,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) {
handleBack();
@@ -207,11 +178,6 @@ class _LearningShellState extends State<LearningShell> {
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),
@@ -242,17 +208,15 @@ class _LearningShellState extends State<LearningShell> {
Widget _tabContent() {
switch (tab) {
case AppTab.home:
case AppTab.learn:
return HomePage(
state: widget.state,
onStartPrimaryTask: () {
if (widget.state.reviewIsPrimary) {
showTab(AppTab.review);
} else {
showLesson();
}
onStartLesson: showLesson,
onOpenLesson: (id) {
widget.state.openLesson(id);
showLesson();
},
onOpenDialogue: showDialogueScene,
onStartReview: () => showTab(AppTab.review),
onResumeLessonDialogue: () => showDialogue(inLesson: true),
onStartReinforcement: () {
widget.state.scheduleA0Reinforcement();
@@ -265,19 +229,6 @@ class _LearningShellState extends State<LearningShell> {
if (pack != null) showAssessment(pack);
},
);
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(
state: widget.state,
@@ -286,14 +237,14 @@ class _LearningShellState extends State<LearningShell> {
case AppTab.review:
return ReviewPage(
state: widget.state,
onFinished: () => showTab(AppTab.home),
onFinished: () => showTab(AppTab.learn),
onOpenAdaptiveLesson: showAdaptiveLesson,
);
case AppTab.progress:
return ProgressPage(
case AppTab.profile:
return ProfilePage(
state: widget.state,
onSettings: showSettings,
onOpenAssessment: showAssessment,
onOpenReview: () => showTab(AppTab.review),
);
}
}
@@ -302,114 +253,9 @@ class _LearningShellState extends State<LearningShell> {
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: 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,
),
],
),
),
],
),
);
}
}