整个课程和数据UI重新设计版本
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 段本机录音。')),
|
||||
);
|
||||
}
|
||||
}
|
||||
+27
-22
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user