fix: 修正学习流程中的证据、复习与进度问题

- 独立尝试按课程要求校验内容,不再任意输入即算独立成功
- 复习节点改为学后第 1/3/7/14 天,之后每 30 天抽查;复习轮换情境
- 掌握状态按作答记录推导:需认识、可回忆、跨情境使用及最近两次无帮助
- 课程证据按实际用到的目标记录;跟读与课程对话只算辅助练习
- 复习成功不再降低已有状态
- 学完课程后进入下一节未完成课程;全部学完后引导巩固与阶段评估
- 新增 3 题基础定位,按结果设置起始课程
- 新增按课程开放的对话场景,首页推荐最少练习的场景
- 切换课程时清空上一课的步骤进度
- AI 返回 JSON 格式并按 JSON 回放历史;拼写校验忽略大小写与连字符;移除学习目标选择

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-17 18:52:01 +09:00
co-authored by Claude Opus 5
parent efdff769e4
commit 9ab08c25ef
19 changed files with 1645 additions and 302 deletions
@@ -11,54 +11,93 @@ import '../../widgets/lexicon_lookup.dart';
import '../../widgets/voice_answer.dart';
class DialogueScenePage extends StatelessWidget {
const DialogueScenePage({super.key, required this.onStart, this.onBack});
final VoidCallback onStart;
const DialogueScenePage({
super.key,
required this.state,
required this.onStart,
this.onBack,
});
final AppState state;
final ValueChanged<String> onStart;
final VoidCallback? onBack;
@override
Widget build(BuildContext context) => AppPage(
appBar: onBack != null
? AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: onBack,
),
title: const Text("AI 情境对话"),
)
: null,
Widget build(BuildContext context) {
final recommended = state.recommendedScene;
return AppPage(
appBar: onBack != null
? AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: onBack,
),
title: const Text("AI 情境对话"),
)
: null,
child: SpacedColumn(
children: [
const Eyebrow('按当前水平推荐'),
Text(
'选一个场景,开口练习。',
style: Theme.of(context).textTheme.headlineMedium,
),
Text(
'每轮 4 次回答,完成明确任务后结束。',
style: Theme.of(context).textTheme.bodyMedium,
),
for (final scene in a0Scenes)
if (state.isSceneUnlocked(scene))
_OpenScene(
scene: scene,
recommended: scene.id == recommended.id,
onStart: () => onStart(scene.id),
)
else
_LockedScene(
title: scene.title,
note:
'A0 · 学完第 ${lessonById(scene.unlockAfterLessonId!).number} 课后开放',
),
const _LockedScene(title: '咖啡店点单', note: 'A1 · 后续版本开放'),
],
),
);
}
}
class _OpenScene extends StatelessWidget {
const _OpenScene({
required this.scene,
required this.recommended,
required this.onStart,
});
final DialogueScene scene;
final bool recommended;
final VoidCallback onStart;
@override
Widget build(BuildContext context) => SectionCard(
tint: recommended ? AppColors.softGreen : null,
child: SpacedColumn(
children: [
const Eyebrow('按当前水平推荐'),
Text('选一个场景,开口练习。', style: Theme.of(context).textTheme.headlineMedium),
Text(
'每轮 4 次回答,完成明确任务后结束。',
style: Theme.of(context).textTheme.bodyMedium,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
scene.title,
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
Text(
recommended ? 'A0 · 推荐' : 'A0',
style: const TextStyle(color: AppColors.green),
),
],
),
SectionCard(
tint: AppColors.softGreen,
child: SpacedColumn(
children: [
const Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'初次见面',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
Text('A0', style: TextStyle(color: AppColors.green)),
],
),
Text(
'介绍姓名、地点、状态或喜好,并反问对方',
style: Theme.of(context).textTheme.bodyMedium,
),
PrimaryButton(label: '开始对话', onPressed: onStart),
],
),
),
const _LockedScene(title: '认识新同学', note: 'A0 · 后续版本开放'),
const _LockedScene(title: '咖啡店', note: 'A1 · 后续版本开放'),
Text(scene.summary, style: Theme.of(context).textTheme.bodyMedium),
recommended
? PrimaryButton(label: '开始对话', onPressed: onStart)
: SecondaryButton(label: '开始对话', onPressed: onStart),
],
),
);
@@ -94,10 +133,14 @@ class DialoguePage extends StatefulWidget {
required this.state,
required this.onFinished,
this.isLessonDialogue = false,
this.sceneId = 'a0-meet',
});
final AppState state;
final ValueChanged<DialogueSummaryData?> onFinished;
final bool isLessonDialogue;
/// The free scene to run when this is not a lesson dialogue.
final String sceneId;
@override
State<DialoguePage> createState() => _DialoguePageState();
}
@@ -124,8 +167,18 @@ class _DialoguePageState extends State<DialoguePage>
/// the dialogue ends: the spec forbids interrupting a beginner turn by turn.
String? latestFeedback;
/// The free scene stores its draft under its own id.
static const _sceneDraftId = 'scene-a0-meet';
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;
/// Each free scene stores its draft under its own id.
String get _sceneDraftId => 'scene-${_scene.id}';
/// Closing line for the turn after the last scripted prompt. It stays inside
/// taught A0 language instead of the old "Wonderful — nice meeting you!".
@@ -143,7 +196,7 @@ class _DialoguePageState extends State<DialoguePage>
.id,
widget.state.activeLessonId,
)
: a0MeetDialogue;
: _scene.script;
/// Only exact sentence matches are trusted. The old positional fallback
/// attached `script.translations[stage]` to whatever the AI happened to say,
@@ -286,8 +339,10 @@ class _DialoguePageState extends State<DialoguePage>
return;
}
widget.state.recordDialogueAttempt(
taskId:
'dialogue-${widget.isLessonDialogue ? _lessonSegmentId : 'a0-meet'}-$stage',
taskId: widget.isLessonDialogue
? 'dialogue-$_lessonSegmentId-$stage'
: 'dialogue-scene-${_scene.id}-$stage',
sceneId: widget.isLessonDialogue ? null : _scene.id,
rawAnswer: text,
assisted: usedHelp,
spoken: usedVoice && !transcriptEdited,
@@ -320,7 +375,10 @@ class _DialoguePageState extends State<DialoguePage>
: 'nothing more, the conversation is finished',
allowedLanguage: widget.isLessonDialogue
? taughtLanguageUpTo(widget.state.activeLessonId)
: allTaughtLanguage,
: _scene.unlockAfterLessonId == null
// The always-open scene mixes every A0 topic, as its script does.
? allTaughtLanguage
: taughtLanguageUpTo(_furthestCompletedLessonId),
history: turns
.map(
(turn) => <String, String>{
@@ -409,7 +467,7 @@ class _DialoguePageState extends State<DialoguePage>
final personalSentence = learnerTurns.isEmpty
? 'My name is …'
: learnerTurns.first.text;
widget.state.addDialogueRecap(personalSentence);
widget.state.addDialogueRecap(personalSentence, sceneId: _scene.id);
widget.onFinished(
DialogueSummaryData(
// Only the turns the learner actually passed are reported.
@@ -584,7 +642,7 @@ class _DialoguePageState extends State<DialoguePage>
onPressed: () => widget.onFinished(null),
),
title: Text(
'${widget.isLessonDialogue ? '课程对话' : '初次见面'} · ${finished ? totalStages : stage + 1} / $totalStages',
'${widget.isLessonDialogue ? '课程对话' : _scene.title} · ${finished ? totalStages : stage + 1} / $totalStages',
),
),
child: SpacedColumn(
+67 -6
View File
@@ -12,12 +12,16 @@ class HomePage extends StatelessWidget {
required this.onStartPrimaryTask,
required this.onOpenDialogue,
required this.onResumeLessonDialogue,
required this.onStartReinforcement,
required this.onOpenAssessment,
});
final AppState state;
final VoidCallback onStartPrimaryTask;
final VoidCallback onOpenDialogue;
final VoidCallback onResumeLessonDialogue;
final VoidCallback onStartReinforcement;
final ValueChanged<String> onOpenAssessment;
@override
Widget build(BuildContext context) {
@@ -26,16 +30,21 @@ class HomePage extends StatelessWidget {
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 项'
: '第 ${activeLesson.number} 课 · ${activeLesson.title}${activeLesson.segments.length > 1 ? ' · 第 $activeSegment/${activeLesson.segments.length} 段' : ''}';
: 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(
@@ -80,7 +89,9 @@ class HomePage extends StatelessWidget {
),
),
Text(
isReview ? '${(count * 2).clamp(2, 10)} 分钟' : '12 分钟',
isReview
? '${(count * 2).clamp(2, 10)} 分钟'
: stageStep?.minutes ?? '12 分钟',
style: const TextStyle(color: AppColors.green),
),
],
@@ -94,10 +105,10 @@ class HomePage extends StatelessWidget {
? '继续对话'
: isReview
? '开始复习'
: '开始今天的学习',
: stageStep?.action ?? '开始今天的学习',
onPressed: resumeDialogue
? onResumeLessonDialogue
: onStartPrimaryTask,
: stageStep?.onPressed ?? onStartPrimaryTask,
),
],
),
@@ -110,7 +121,7 @@ class HomePage extends StatelessWidget {
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
Text(
'初次见面 · 4 个任务 · 文字或语音输入',
'${scene.title} · ${scene.script.prompts.length} 个任务 · 文字或语音输入',
style: Theme.of(context).textTheme.bodyMedium,
),
SecondaryButton(label: '开始情境对话', onPressed: onOpenDialogue),
@@ -122,6 +133,56 @@ class HomePage extends StatelessWidget {
),
);
}
/// 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;
}
class _FrameworkNote extends StatelessWidget {
@@ -38,6 +38,10 @@ class _LessonFlowState extends State<LessonFlow> {
final writingController = TextEditingController();
final independentController = TextEditingController();
int selectedAnswer = -1;
/// Set once a wrong meaning is picked, so only a first-try choice counts as
/// recognition evidence.
bool listeningMissed = false;
int previewIndex = 0;
bool listeningAudioPlayed = false;
bool showWritingHelp = true;
@@ -108,7 +112,12 @@ class _LessonFlowState extends State<LessonFlow> {
correctAnswer: activity.answers.first,
selectedAnswer: selectedAnswer,
audioPlayed: listeningAudioPlayed,
onSelected: (value) => setState(() => selectedAnswer = value),
onSelected: (value) => setState(() {
selectedAnswer = value;
if (listeningOptions[value] != activity.answers.first) {
listeningMissed = true;
}
}),
onPlayed: () => setState(() => listeningAudioPlayed = true),
onLookup: () => showLexiconLookup(
context,
@@ -118,7 +127,8 @@ class _LessonFlowState extends State<LessonFlow> {
onContinue:
selectedAnswer >= 0 &&
listeningOptions[selectedAnswer] == activity.answers.first
? widget.state.completeListening
? () =>
widget.state.completeListening(recognized: !listeningMissed)
: null,
);
case LessonStep.speaking:
@@ -126,7 +136,7 @@ class _LessonFlowState extends State<LessonFlow> {
state: widget.state,
text: activity.speaking,
keepRecording: widget.state.keepRecordings,
onContinue: () => widget.state.completeSpeaking(assisted: true),
onContinue: widget.state.completeSpeaking,
);
case LessonStep.reading:
content = _ReadingStep(
@@ -3,7 +3,11 @@ 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/voice_service.dart';
import '../../widgets/app_widgets.dart';
import '../../widgets/voice_answer.dart';
class WelcomePage extends StatefulWidget {
const WelcomePage({super.key, required this.state, required this.onContinue});
@@ -16,13 +20,11 @@ class WelcomePage extends StatefulWidget {
}
class _WelcomePageState extends State<WelcomePage> {
late LearningGoal selectedGoal;
late int selectedMinutes;
@override
void initState() {
super.initState();
selectedGoal = widget.state.goal;
selectedMinutes = widget.state.dailyMinutes;
}
@@ -72,16 +74,6 @@ class _WelcomePageState extends State<WelcomePage> {
style: Theme.of(context).textTheme.headlineMedium,
),
const Text('从真实生活场景开始,先做到听得懂、说得清。'),
_ChoiceGroup<LearningGoal>(
title: '你的主要目标',
value: selectedGoal,
options: const {
LearningGoal.dailyLife: '日常生活',
LearningGoal.travel: '旅行',
LearningGoal.workStarter: '工作起步',
},
onChanged: (value) => setState(() => selectedGoal = value),
),
_ChoiceGroup<int>(
title: '每天学习多久?',
value: selectedMinutes,
@@ -91,9 +83,7 @@ class _WelcomePageState extends State<WelcomePage> {
PrimaryButton(
label: '继续',
onPressed: () {
widget.state
..setGoal(selectedGoal)
..setDailyMinutes(selectedMinutes);
widget.state.setDailyMinutes(selectedMinutes);
widget.onContinue();
},
),
@@ -119,8 +109,25 @@ class PlacementPage extends StatefulWidget {
State<PlacementPage> createState() => _PlacementPageState();
}
class _PlacementPageState extends State<PlacementPage> {
enum _PlacementPhase { selfReport, listen, readAloud, answer, result }
class _PlacementPageState extends State<PlacementPage>
with VoiceAnswerMixin<PlacementPage> {
static const _listeningText = 'Hello. My name is Mia. Nice to meet you.';
static const _listeningOptions = [
'你好,我叫 Mia,很高兴认识你。',
'你好,我来自 Mia,今天很开心。',
'再见,Mia,明天见。',
];
static const _readAloudText = 'Hello. My name is ...';
late PlacementLevel selected;
var phase = _PlacementPhase.selfReport;
int listeningChoice = -1;
final answerController = TextEditingController();
@override
AppState get voiceState => widget.state;
@override
void initState() {
@@ -128,52 +135,287 @@ class _PlacementPageState extends State<PlacementPage> {
selected = widget.state.placement;
}
@override
void dispose() {
VoiceService.instance.stopSpeaking();
disposeVoiceAnswer(keepRecording: false);
answerController.dispose();
super.dispose();
}
bool get _understoodGreeting => listeningChoice == 0;
bool get _answeredName =>
coreItemUsedIn('A0-P01', answerController.text.trim());
/// Placement only picks an A0 starting point until A1 content is frozen, and
/// it never marks a lesson as passed.
String get _suggestedLessonId =>
_understoodGreeting && _answeredName ? 'a0-02' : 'a0-01';
String get _reason => _understoodGreeting && _answeredName
? '你已经能听懂问候,也能说出自己的名字,可以从拼写名字开始。'
: _understoodGreeting
? '你能听懂问候,先把自我介绍说完整会更稳。'
: '先从问候和介绍名字开始,打好第一句的基础。';
void _back() {
VoiceService.instance.stopSpeaking();
switch (phase) {
case _PlacementPhase.selfReport:
widget.onBack?.call();
case _PlacementPhase.listen:
setState(() => phase = _PlacementPhase.selfReport);
case _PlacementPhase.readAloud:
setState(() => phase = _PlacementPhase.listen);
case _PlacementPhase.answer:
setState(() => phase = _PlacementPhase.readAloud);
case _PlacementPhase.result:
setState(() => phase = _PlacementPhase.answer);
}
}
void _startFrom(String lessonId) {
widget.state.setPlacementStartLesson(lessonId);
widget.onStart();
}
Future<void> _toggleVoiceAnswer() async {
if (aiVoiceRecording) {
await finishVoiceInput(
keepAudio: false,
onTranscript: (text) => answerController.text = text,
);
return;
}
await startVoiceInput();
}
@override
Widget build(BuildContext context) {
final showBack =
widget.onBack != null || phase != _PlacementPhase.selfReport;
return AppPage(
appBar: showBack
? AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: _back,
),
title: const Text("基础定位"),
)
: null,
child: switch (phase) {
_PlacementPhase.selfReport => _selfReport(context),
_PlacementPhase.listen => _listen(context),
_PlacementPhase.readAloud => _readAloud(context),
_PlacementPhase.answer => _answer(context),
_PlacementPhase.result => _result(context),
},
);
}
Widget _selfReport(BuildContext context) {
const labels = {
PlacementLevel.beginner: ('完全零基础', '从你好、自我介绍开始'),
PlacementLevel.someBasics: ('能说一点', '认识常见单词或短句'),
PlacementLevel.simpleConversation: ('能简单对话', '想说得更自然、更有信心'),
};
return AppPage(
appBar: widget.onBack != null
? AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: "返回",
onPressed: widget.onBack,
),
title: const Text("基础定位"),
)
: null,
child: SpacedColumn(
spacing: 14,
children: [
const Eyebrow('第一步 · 约 3 分钟'),
Text('从哪里开始?', style: Theme.of(context).textTheme.headlineMedium),
const Text('选择最接近的状态,之后随时能调整。'),
for (final option in PlacementLevel.values)
_PlacementChoice(
option: option,
labels: labels[option]!,
isSelected: selected == option,
onTap: () => setState(() => selected = option),
),
PrimaryButton(
label: '开始 3 分钟定位',
onPressed: () {
widget.state.setPlacement(selected);
widget.onStart();
},
return SpacedColumn(
spacing: 14,
children: [
const Eyebrow('第一步 · 约 3 分钟'),
Text('从哪里开始?', style: Theme.of(context).textTheme.headlineMedium),
const Text('选择最接近的状态,之后随时能调整。'),
for (final option in PlacementLevel.values)
_PlacementChoice(
option: option,
labels: labels[option]!,
isSelected: selected == option,
onTap: () => setState(() => selected = option),
),
PrimaryButton(
label: '开始 3 分钟定位',
onPressed: () {
widget.state.setPlacement(selected);
setState(() => phase = _PlacementPhase.listen);
},
),
Center(
child: TextButton(
onPressed: () => _startFrom('a0-01'),
child: const Text('直接从第一课开始'),
),
),
],
);
}
Widget _listen(BuildContext context) => SpacedColumn(
spacing: 14,
children: [
const Eyebrow('定位 · 1 / 3'),
Text('听一听,选出意思。', style: Theme.of(context).textTheme.headlineMedium),
SecondaryButton(
label: '播放句子',
onPressed: () => VoiceService.instance.speak(_listeningText),
),
for (var index = 0; index < _listeningOptions.length; index++)
SectionCard(
tint: listeningChoice == index ? AppColors.softGreen : null,
onTap: () => setState(() => listeningChoice = index),
child: Row(
children: [
Icon(
listeningChoice == index
? Icons.radio_button_checked
: Icons.radio_button_off,
color: listeningChoice == index
? AppColors.green
: AppColors.muted,
),
const SizedBox(width: 10),
Expanded(child: Text(_listeningOptions[index])),
],
),
),
PrimaryButton(
label: '下一题',
onPressed: listeningChoice < 0
? null
: () => setState(() => phase = _PlacementPhase.readAloud),
),
Center(
child: TextButton(
onPressed: () => setState(() {
listeningChoice = -1;
phase = _PlacementPhase.readAloud;
}),
child: const Text('听不懂,下一题'),
),
),
],
);
Widget _readAloud(BuildContext context) => SpacedColumn(
spacing: 14,
children: [
const Eyebrow('定位 · 2 / 3'),
Text('跟着读一句。', style: Theme.of(context).textTheme.headlineMedium),
const Text(
_readAloudText,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 26, fontWeight: FontWeight.w600),
),
SecondaryButton(
label: '播放示范音',
onPressed: () => VoiceService.instance.speak(_readAloudText),
),
const SectionCard(
tint: AppColors.surfaceMuted,
child: Text('这一题只是开口热身,不录音、不打分,可以跳过。'),
),
PrimaryButton(
label: '我读完了',
onPressed: () => setState(() => phase = _PlacementPhase.answer),
),
Center(
child: TextButton(
onPressed: () => setState(() => phase = _PlacementPhase.answer),
child: const Text('现在不方便开口,跳过'),
),
),
],
);
Widget _answer(BuildContext context) => SpacedColumn(
spacing: 14,
children: [
const Eyebrow('定位 · 3 / 3'),
Text('回答这个问题。', style: Theme.of(context).textTheme.headlineMedium),
SectionCard(
tint: AppColors.surfaceMuted,
child: Row(
children: [
const Expanded(
child: Text(
'What is your name?',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
),
),
IconButton(
tooltip: '播放问题',
icon: const Icon(Icons.volume_up_outlined),
onPressed: () =>
VoiceService.instance.speak('What is your name?'),
),
],
),
),
TextField(
controller: answerController,
decoration: const InputDecoration(hintText: '用英语输入你的回答'),
onChanged: (_) => setState(() {}),
),
SecondaryButton(
label: transcribing ? '正在识别…' : (aiVoiceRecording ? '说完了,识别' : '用语音回答'),
onPressed: transcribing ? null : _toggleVoiceAnswer,
),
PrimaryButton(
label: '查看建议',
onPressed: aiVoiceRecording || transcribing
? null
: () => setState(() => phase = _PlacementPhase.result),
),
Center(
child: TextButton(
onPressed: () => setState(() {
answerController.clear();
phase = _PlacementPhase.result;
}),
child: const Text('还不会说,查看建议'),
),
),
],
);
Widget _result(BuildContext context) {
final lesson = lessonById(_suggestedLessonId);
return SpacedColumn(
spacing: 14,
children: [
const Eyebrow('定位结果'),
Text(
'建议从 A0 第 ${lesson.number} 课开始',
style: Theme.of(context).textTheme.headlineMedium,
),
SectionCard(
tint: AppColors.softGreen,
child: SpacedColumn(
spacing: 6,
children: [
Text(
'第 ${lesson.number} 课 · ${lesson.title}',
style: const TextStyle(fontWeight: FontWeight.w600),
),
Text(_reason),
],
),
),
const Text('前面的课程随时可以回去学,定位不会跳过任何复习。'),
PrimaryButton(
label: '开始今天学习',
onPressed: () => _startFrom(_suggestedLessonId),
),
if (_suggestedLessonId != 'a0-01')
Center(
child: TextButton(
onPressed: widget.onStart,
child: const Text('直接从第一课开始'),
onPressed: () => _startFrom('a0-01'),
child: const Text('从更简单内容开始'),
),
),
],
),
],
);
}
}
@@ -26,6 +26,7 @@ class _LearningShellState extends State<LearningShell> {
var route = _ShellRoute.tab;
_ShellRoute? previousRoute;
bool dialogueInLesson = false;
String sceneId = 'a0-meet';
AssessmentPack? assessmentPack;
DialogueSummaryData? dialogueSummary;
@@ -45,9 +46,10 @@ class _LearningShellState extends State<LearningShell> {
route = _ShellRoute.scene;
});
void showDialogue({bool inLesson = false}) => setState(() {
void showDialogue({bool inLesson = false, String? scene}) => setState(() {
previousRoute = route;
dialogueInLesson = inLesson;
if (scene != null) sceneId = scene;
route = _ShellRoute.dialogue;
});
@@ -125,13 +127,16 @@ class _LearningShellState extends State<LearningShell> {
);
case _ShellRoute.scene:
body = DialogueScenePage(
onStart: showDialogue,
state: widget.state,
onStart: (id) => showDialogue(scene: id),
onBack: () => showTab(tab),
);
case _ShellRoute.dialogue:
body = DialoguePage(
key: ValueKey(dialogueInLesson ? 'lesson' : 'scene-$sceneId'),
state: widget.state,
isLessonDialogue: dialogueInLesson,
sceneId: sceneId,
onFinished: (summary) {
if (dialogueInLesson) {
showLesson();
@@ -147,7 +152,7 @@ class _LearningShellState extends State<LearningShell> {
summary: dialogueSummary!,
onHome: () => showTab(AppTab.home),
onLesson: showLesson,
onRetry: showDialogue,
onRetry: () => showDialogue(scene: sceneId),
onBack: () => showTab(tab),
);
case _ShellRoute.settings:
@@ -194,7 +199,8 @@ class _LearningShellState extends State<LearningShell> {
selectedIndex: tab.index,
height: 70,
indicatorColor: AppColors.softGreen,
onDestinationSelected: (index) => showTab(AppTab.values[index]),
onDestinationSelected: (index) =>
showTab(AppTab.values[index]),
destinations: const [
NavigationDestination(
icon: Icon(Icons.home_outlined),
@@ -243,6 +249,16 @@ class _LearningShellState extends State<LearningShell> {
},
onOpenDialogue: showDialogueScene,
onResumeLessonDialogue: () => showDialogue(inLesson: true),
onStartReinforcement: () {
widget.state.scheduleA0Reinforcement();
showTab(AppTab.review);
},
onOpenAssessment: (packId) {
final pack = a0AssessmentPacks
.where((item) => item.id == packId)
.firstOrNull;
if (pack != null) showAssessment(pack);
},
);
case AppTab.learn:
return _LearningMap(
@@ -258,7 +274,10 @@ class _LearningShellState extends State<LearningShell> {
state: widget.state,
);
case AppTab.dialogue:
return DialogueScenePage(onStart: showDialogue);
return DialogueScenePage(
state: widget.state,
onStart: (id) => showDialogue(scene: id),
);
case AppTab.review:
return ReviewPage(
state: widget.state,