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