fix: 修正学习流程中的证据、复习与进度问题
- 独立尝试按课程要求校验内容,不再任意输入即算独立成功 - 复习节点改为学后第 1/3/7/14 天,之后每 30 天抽查;复习轮换情境 - 掌握状态按作答记录推导:需认识、可回忆、跨情境使用及最近两次无帮助 - 课程证据按实际用到的目标记录;跟读与课程对话只算辅助练习 - 复习成功不再降低已有状态 - 学完课程后进入下一节未完成课程;全部学完后引导巩固与阶段评估 - 新增 3 题基础定位,按结果设置起始课程 - 新增按课程开放的对话场景,首页推荐最少练习的场景 - 切换课程时清空上一课的步骤进度 - AI 返回 JSON 格式并按 JSON 回放历史;拼写校验忽略大小写与连字符;移除学习目标选择 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -382,12 +382,19 @@ class AiService {
|
|||||||
// with a small token cap, so reasoning only adds cost and can exhaust the
|
// with a small token cap, so reasoning only adds cost and can exhaust the
|
||||||
// cap before the JSON is written. Other providers may reject these
|
// cap before the JSON is written. Other providers may reject these
|
||||||
// DeepSeek-specific switches, so they keep the original payload.
|
// DeepSeek-specific switches, so they keep the original payload.
|
||||||
|
//
|
||||||
|
// Every prompt asks for JSON, but without JSON Output DeepSeek copies the
|
||||||
|
// plain-text assistant turns in a dialogue history and answers in plain
|
||||||
|
// text about half the time, which the app then treats as a failed call.
|
||||||
if (_isDeepSeek(uri)) {
|
if (_isDeepSeek(uri)) {
|
||||||
return isResponses
|
return isResponses
|
||||||
? {
|
? {
|
||||||
'model': model,
|
'model': model,
|
||||||
'input': messages,
|
'input': messages,
|
||||||
'reasoning': {'effort': 'none'},
|
'reasoning': {'effort': 'none'},
|
||||||
|
'text': {
|
||||||
|
'format': {'type': 'json_object'},
|
||||||
|
},
|
||||||
'temperature': ?temperature,
|
'temperature': ?temperature,
|
||||||
'max_output_tokens': ?maxTokens,
|
'max_output_tokens': ?maxTokens,
|
||||||
}
|
}
|
||||||
@@ -395,6 +402,7 @@ class AiService {
|
|||||||
'model': model,
|
'model': model,
|
||||||
'messages': messages,
|
'messages': messages,
|
||||||
'thinking': {'type': 'disabled'},
|
'thinking': {'type': 'disabled'},
|
||||||
|
'response_format': {'type': 'json_object'},
|
||||||
'temperature': ?temperature,
|
'temperature': ?temperature,
|
||||||
'max_tokens': ?maxTokens,
|
'max_tokens': ?maxTokens,
|
||||||
};
|
};
|
||||||
@@ -787,7 +795,18 @@ class AiService {
|
|||||||
endpoint: endpoint,
|
endpoint: endpoint,
|
||||||
model: model,
|
model: model,
|
||||||
system: system,
|
system: system,
|
||||||
messages: history,
|
// Earlier AI lines are stored as plain English. Sent that way they teach
|
||||||
|
// the model to answer in plain text (or, in JSON mode, with blanks), so
|
||||||
|
// they are replayed in the JSON shape the system prompt asks for.
|
||||||
|
messages: [
|
||||||
|
for (final message in history)
|
||||||
|
message['role'] == 'assistant'
|
||||||
|
? {
|
||||||
|
'role': 'assistant',
|
||||||
|
'content': jsonEncode({'reply': message['content']}),
|
||||||
|
}
|
||||||
|
: message,
|
||||||
|
],
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
maxTokens: 300,
|
maxTokens: 300,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import 'models.dart';
|
|||||||
import 'a0_core.dart';
|
import 'a0_core.dart';
|
||||||
import 'generated_content.dart';
|
import 'generated_content.dart';
|
||||||
import 'local_store.dart';
|
import 'local_store.dart';
|
||||||
|
import 'review_feedback.dart';
|
||||||
import 'seed_courses.dart';
|
import 'seed_courses.dart';
|
||||||
import 'ai_config.dart';
|
import 'ai_config.dart';
|
||||||
import 'ai_service.dart';
|
import 'ai_service.dart';
|
||||||
@@ -32,6 +33,10 @@ abstract class _AppStateData extends ChangeNotifier {
|
|||||||
bool keepRecordings = false;
|
bool keepRecordings = false;
|
||||||
int completedLessons = 0;
|
int completedLessons = 0;
|
||||||
String activeLessonId = 'a0-01';
|
String activeLessonId = 'a0-01';
|
||||||
|
|
||||||
|
/// Lesson recommended by the placement check; it and earlier lessons are
|
||||||
|
/// open without finishing the lesson before them.
|
||||||
|
String placementStartLessonId = 'a0-01';
|
||||||
final Set<String> completedLessonIds = {};
|
final Set<String> completedLessonIds = {};
|
||||||
final Set<String> completedSegmentIds = {};
|
final Set<String> completedSegmentIds = {};
|
||||||
final Set<String> reportedAiVariantKeys = {};
|
final Set<String> reportedAiVariantKeys = {};
|
||||||
@@ -212,6 +217,16 @@ class AppState extends _AppStateData
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Applies the placement result. Only A0 starting points exist until A1
|
||||||
|
/// content is frozen, and placement never counts as passing a lesson.
|
||||||
|
void setPlacementStartLesson(String lessonId) {
|
||||||
|
if (!a0SeedLessons.any((lesson) => lesson.id == lessonId)) return;
|
||||||
|
placementStartLessonId = lessonId;
|
||||||
|
activeLessonId = lessonId;
|
||||||
|
_resetLessonFlow();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
void setAiProvider(AiProviderType value) {
|
void setAiProvider(AiProviderType value) {
|
||||||
aiProvider = value;
|
aiProvider = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -236,6 +251,7 @@ class AppState extends _AppStateData
|
|||||||
void clearProgress() {
|
void clearProgress() {
|
||||||
completedLessons = 0;
|
completedLessons = 0;
|
||||||
activeLessonId = 'a0-01';
|
activeLessonId = 'a0-01';
|
||||||
|
placementStartLessonId = 'a0-01';
|
||||||
completedLessonIds.clear();
|
completedLessonIds.clear();
|
||||||
completedSegmentIds.clear();
|
completedSegmentIds.clear();
|
||||||
reportedAiVariantKeys.clear();
|
reportedAiVariantKeys.clear();
|
||||||
|
|||||||
@@ -23,10 +23,22 @@ mixin _AssessmentProgress on _AppStateData, _ReviewAndMastery {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get a0Passed =>
|
/// Enough core items are usable and mastered to sit the stage assessment.
|
||||||
coreUsableCount >= 48 &&
|
bool get a0AssessmentReady =>
|
||||||
coreMasteredCount >= 30 &&
|
coreUsableCount >= 48 && coreMasteredCount >= 30;
|
||||||
hasTwoValidAssessmentPasses;
|
|
||||||
|
bool get a0Passed => a0AssessmentReady && hasTwoValidAssessmentPasses;
|
||||||
|
|
||||||
|
/// The first assessment pack the learner can start and has not passed yet.
|
||||||
|
String? get nextAssessmentPackId {
|
||||||
|
for (final packId in const ['A0-E1', 'A0-E2']) {
|
||||||
|
final passed = assessments.any(
|
||||||
|
(record) => record.packId == packId && record.passed,
|
||||||
|
);
|
||||||
|
if (!passed && canStartAssessmentPack(packId)) return packId;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
bool canStartAssessmentPack(String packId) {
|
bool canStartAssessmentPack(String packId) {
|
||||||
if (packId != 'A0-E2') return true;
|
if (packId != 'A0-E2') return true;
|
||||||
|
|||||||
@@ -82,14 +82,29 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _lessonIndex(String id) =>
|
||||||
|
a0SeedLessons.indexWhere((lesson) => lesson.id == id);
|
||||||
|
|
||||||
bool isLessonUnlocked(String id) {
|
bool isLessonUnlocked(String id) {
|
||||||
final index = a0SeedLessons.indexWhere((lesson) => lesson.id == id);
|
final index = _lessonIndex(id);
|
||||||
|
if (index < 0) return false;
|
||||||
|
// The placement start and everything before it are open, so a learner
|
||||||
|
// placed further ahead can still go back to easier lessons.
|
||||||
return index == 0 ||
|
return index == 0 ||
|
||||||
(index > 0 && completedLessonIds.contains(a0SeedLessons[index - 1].id));
|
index <= _lessonIndex(placementStartLessonId) ||
|
||||||
|
completedLessonIds.contains(a0SeedLessons[index - 1].id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get allLessonsComplete =>
|
||||||
|
a0SeedLessons.every((lesson) => completedLessonIds.contains(lesson.id));
|
||||||
|
|
||||||
void openLesson(String id) {
|
void openLesson(String id) {
|
||||||
if (!isLessonUnlocked(id)) return;
|
if (!isLessonUnlocked(id)) return;
|
||||||
|
if (id != activeLessonId) {
|
||||||
|
// Step flags belong to the lesson they were earned in; carrying them
|
||||||
|
// over would let another lesson finish without its own tasks.
|
||||||
|
_resetLessonFlow();
|
||||||
|
}
|
||||||
activeLessonId = id;
|
activeLessonId = id;
|
||||||
lessonStep = LessonStep.preview;
|
lessonStep = LessonStep.preview;
|
||||||
previewIndex = 0;
|
previewIndex = 0;
|
||||||
@@ -104,24 +119,34 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void completeListening() {
|
/// [recognized] is true when the learner picked the right meaning on the
|
||||||
|
/// first try, which is recognition evidence for the segment's targets.
|
||||||
|
void completeListening({bool recognized = false}) {
|
||||||
_introduceLessonTargets();
|
_introduceLessonTargets();
|
||||||
|
if (recognized && !lessonListeningComplete) {
|
||||||
|
_recordLessonTaskEvidence(
|
||||||
|
targetIds: _activeTargetItemIds,
|
||||||
|
taskSuffix: 'listening-check',
|
||||||
|
skill: '听辨识别',
|
||||||
|
outcome: EvidenceKind.independentSuccess,
|
||||||
|
);
|
||||||
|
}
|
||||||
lessonListeningComplete = true;
|
lessonListeningComplete = true;
|
||||||
lessonStep = LessonStep.speaking;
|
lessonStep = LessonStep.speaking;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
void completeSpeaking({bool assisted = false}) {
|
/// Follow-reading repeats a model sentence, so it is always assisted
|
||||||
|
/// practice and never independent speaking evidence.
|
||||||
|
void completeSpeaking() {
|
||||||
lessonSpeakingComplete = true;
|
lessonSpeakingComplete = true;
|
||||||
lessonStep = LessonStep.reading;
|
lessonStep = LessonStep.reading;
|
||||||
_recordLessonTaskEvidence(
|
_recordLessonTaskEvidence(
|
||||||
targetIds: [_primaryTargetId],
|
targetIds: _activeTargetItemIds,
|
||||||
taskSuffix: 'speaking',
|
taskSuffix: 'speaking',
|
||||||
skill: '口语表达',
|
skill: '口语表达',
|
||||||
outcome: assisted
|
outcome: EvidenceKind.assisted,
|
||||||
? EvidenceKind.assisted
|
assisted: true,
|
||||||
: EvidenceKind.independentSuccess,
|
|
||||||
assisted: assisted,
|
|
||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
@@ -130,7 +155,7 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
|
|||||||
lessonReadingComplete = true;
|
lessonReadingComplete = true;
|
||||||
lessonStep = LessonStep.writing;
|
lessonStep = LessonStep.writing;
|
||||||
_recordLessonTaskEvidence(
|
_recordLessonTaskEvidence(
|
||||||
targetIds: [_primaryTargetId],
|
targetIds: _activeTargetItemIds,
|
||||||
taskSuffix: 'reading',
|
taskSuffix: 'reading',
|
||||||
skill: '阅读理解',
|
skill: '阅读理解',
|
||||||
outcome: EvidenceKind.exposure,
|
outcome: EvidenceKind.exposure,
|
||||||
@@ -143,7 +168,7 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
|
|||||||
lessonWritingDraft = '';
|
lessonWritingDraft = '';
|
||||||
lessonStep = LessonStep.dialogue;
|
lessonStep = LessonStep.dialogue;
|
||||||
_recordLessonTaskEvidence(
|
_recordLessonTaskEvidence(
|
||||||
targetIds: [_primaryTargetId],
|
targetIds: _answerTargets(assisted: assisted, rawAnswer: rawAnswer),
|
||||||
taskSuffix: 'writing',
|
taskSuffix: 'writing',
|
||||||
skill: '写作表达',
|
skill: '写作表达',
|
||||||
outcome: assisted
|
outcome: assisted
|
||||||
@@ -159,7 +184,7 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
|
|||||||
lessonDialogueComplete = true;
|
lessonDialogueComplete = true;
|
||||||
lessonStep = LessonStep.independent;
|
lessonStep = LessonStep.independent;
|
||||||
_recordLessonTaskEvidence(
|
_recordLessonTaskEvidence(
|
||||||
targetIds: [_primaryTargetId],
|
targetIds: _activeTargetItemIds,
|
||||||
taskSuffix: 'dialogue',
|
taskSuffix: 'dialogue',
|
||||||
skill: '受控对话',
|
skill: '受控对话',
|
||||||
outcome: EvidenceKind.assisted,
|
outcome: EvidenceKind.assisted,
|
||||||
@@ -180,7 +205,7 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
|
|||||||
independentAttemptSpoken = spoken && !assisted;
|
independentAttemptSpoken = spoken && !assisted;
|
||||||
lessonStep = LessonStep.complete;
|
lessonStep = LessonStep.complete;
|
||||||
_recordLessonTaskEvidence(
|
_recordLessonTaskEvidence(
|
||||||
targetIds: [_primaryTargetId],
|
targetIds: _answerTargets(assisted: assisted, rawAnswer: rawAnswer),
|
||||||
taskSuffix: 'independent',
|
taskSuffix: 'independent',
|
||||||
skill: spoken && !assisted ? '口语表达' : '写作表达',
|
skill: spoken && !assisted ? '口语表达' : '写作表达',
|
||||||
inputMode: spoken && !assisted ? 'speech-unedited-transcript' : 'text',
|
inputMode: spoken && !assisted ? 'speech-unedited-transcript' : 'text',
|
||||||
@@ -198,12 +223,8 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
|
|||||||
if (!lessonCanComplete) return;
|
if (!lessonCanComplete) return;
|
||||||
completedLessonIds.add(activeLessonId);
|
completedLessonIds.add(activeLessonId);
|
||||||
completedLessons = completedLessonIds.length;
|
completedLessons = completedLessonIds.length;
|
||||||
final currentIndex = a0SeedLessons.indexWhere(
|
final next = _nextIncompleteLessonId();
|
||||||
(lesson) => lesson.id == activeLessonId,
|
if (next != null) activeLessonId = next;
|
||||||
);
|
|
||||||
if (currentIndex >= 0 && currentIndex < a0SeedLessons.length - 1) {
|
|
||||||
activeLessonId = a0SeedLessons[currentIndex + 1].id;
|
|
||||||
}
|
|
||||||
_resetLessonFlow();
|
_resetLessonFlow();
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_syncInBackground();
|
_syncInBackground();
|
||||||
@@ -276,6 +297,22 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
|
|||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The first unfinished lesson from the placement start onward, falling back
|
||||||
|
/// to any earlier unfinished lesson. Null once every lesson is finished.
|
||||||
|
String? _nextIncompleteLessonId() {
|
||||||
|
final start = _lessonIndex(
|
||||||
|
placementStartLessonId,
|
||||||
|
).clamp(0, a0SeedLessons.length);
|
||||||
|
final ordered = [
|
||||||
|
...a0SeedLessons.skip(start),
|
||||||
|
...a0SeedLessons.take(start),
|
||||||
|
];
|
||||||
|
return ordered
|
||||||
|
.where((lesson) => !completedLessonIds.contains(lesson.id))
|
||||||
|
.firstOrNull
|
||||||
|
?.id;
|
||||||
|
}
|
||||||
|
|
||||||
void _resetLessonFlow() {
|
void _resetLessonFlow() {
|
||||||
lessonStep = LessonStep.preview;
|
lessonStep = LessonStep.preview;
|
||||||
previewIndex = 0;
|
previewIndex = 0;
|
||||||
@@ -305,31 +342,78 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
|
|||||||
_syncInBackground();
|
_syncInBackground();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Records one dialogue turn.
|
||||||
|
///
|
||||||
|
/// A lesson dialogue follows a model, so its turns stay assisted or pending.
|
||||||
|
/// A free scene ([sceneId] set) is use outside the lesson: every taught core
|
||||||
|
/// item the learner actually used counts as context use.
|
||||||
void recordDialogueAttempt({
|
void recordDialogueAttempt({
|
||||||
required String taskId,
|
required String taskId,
|
||||||
required String rawAnswer,
|
required String rawAnswer,
|
||||||
required bool assisted,
|
required bool assisted,
|
||||||
bool spoken = false,
|
bool spoken = false,
|
||||||
String? recordingPath,
|
String? recordingPath,
|
||||||
|
String? sceneId,
|
||||||
}) {
|
}) {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
attemptEvidence.add(
|
final inScene = sceneId != null;
|
||||||
AttemptEvidence(
|
final itemIds = inScene
|
||||||
id: 'dialogue-$taskId-${now.microsecondsSinceEpoch}',
|
? [
|
||||||
itemId: _primaryTargetId,
|
for (final id in a0CoreItems.keys)
|
||||||
taskId: taskId,
|
if (mastery[id]?.firstTaughtAt != null &&
|
||||||
skill: '受控对话',
|
coreItemUsedIn(id, rawAnswer))
|
||||||
inputMode: spoken ? 'speech-unedited-transcript' : 'text',
|
id,
|
||||||
outcome: assisted ? EvidenceKind.assisted : EvidenceKind.pending,
|
]
|
||||||
createdAt: now,
|
: [
|
||||||
rawAnswer: rawAnswer,
|
for (final id in _activeTargetItemIds)
|
||||||
recordingPath: recordingPath,
|
if (coreItemUsedIn(id, rawAnswer)) id,
|
||||||
assisted: assisted,
|
];
|
||||||
),
|
if (itemIds.isEmpty && !inScene) itemIds.add(_primaryTargetId);
|
||||||
);
|
final outcome = assisted
|
||||||
|
? EvidenceKind.assisted
|
||||||
|
: (inScene ? EvidenceKind.independentSuccess : EvidenceKind.pending);
|
||||||
|
for (var index = 0; index < itemIds.length; index++) {
|
||||||
|
final id = itemIds[index];
|
||||||
|
attemptEvidence.add(
|
||||||
|
AttemptEvidence(
|
||||||
|
id: 'dialogue-$taskId-$id-${now.microsecondsSinceEpoch}-$index',
|
||||||
|
itemId: id,
|
||||||
|
taskId: taskId,
|
||||||
|
skill: inScene ? '情境使用' : '受控对话',
|
||||||
|
inputMode: spoken ? 'speech-unedited-transcript' : 'text',
|
||||||
|
outcome: outcome,
|
||||||
|
createdAt: now,
|
||||||
|
rawAnswer: rawAnswer,
|
||||||
|
recordingPath: recordingPath,
|
||||||
|
assisted: assisted,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (outcome != EvidenceKind.pending) _recordEvidence(id, outcome);
|
||||||
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A scene the learner has the language for.
|
||||||
|
bool isSceneUnlocked(DialogueScene scene) =>
|
||||||
|
scene.unlockAfterLessonId == null ||
|
||||||
|
completedLessonIds.contains(scene.unlockAfterLessonId);
|
||||||
|
|
||||||
|
/// The open scene practised least recently, judged by its pending recaps.
|
||||||
|
DialogueScene get recommendedScene {
|
||||||
|
final open = a0Scenes.where(isSceneUnlocked).toList();
|
||||||
|
int recaps(DialogueScene scene) => reviewQueue
|
||||||
|
.where((item) => item.id.startsWith('dialogue-${scene.id}-'))
|
||||||
|
.length;
|
||||||
|
open.sort((left, right) {
|
||||||
|
final byRecaps = recaps(left).compareTo(recaps(right));
|
||||||
|
// Prefer the newest scene when both have been practised equally.
|
||||||
|
return byRecaps != 0
|
||||||
|
? byRecaps
|
||||||
|
: a0Scenes.indexOf(right).compareTo(a0Scenes.indexOf(left));
|
||||||
|
});
|
||||||
|
return open.first;
|
||||||
|
}
|
||||||
|
|
||||||
List<String> get _activeTargetItemIds {
|
List<String> get _activeTargetItemIds {
|
||||||
final lesson = lessonById(activeLessonId);
|
final lesson = lessonById(activeLessonId);
|
||||||
return lesson.segments[activeSegmentIndexFor(activeLessonId)].targetItemIds;
|
return lesson.segments[activeSegmentIndexFor(activeLessonId)].targetItemIds;
|
||||||
@@ -337,6 +421,17 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
|
|||||||
|
|
||||||
String get _primaryTargetId => _activeTargetItemIds.lastOrNull ?? 'A0-P02';
|
String get _primaryTargetId => _activeTargetItemIds.lastOrNull ?? 'A0-P02';
|
||||||
|
|
||||||
|
/// An assisted answer is assisted practice for every target; an unaided one
|
||||||
|
/// only proves the targets it actually uses.
|
||||||
|
List<String> _answerTargets({required bool assisted, String? rawAnswer}) {
|
||||||
|
if (assisted) return _activeTargetItemIds;
|
||||||
|
final answer = rawAnswer ?? '';
|
||||||
|
return [
|
||||||
|
for (final id in _activeTargetItemIds)
|
||||||
|
if (coreItemUsedIn(id, answer)) id,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/// Records one evidence row for every target actually attached to a local
|
/// Records one evidence row for every target actually attached to a local
|
||||||
/// task. A task can be displayed once but must never silently award its
|
/// task. A task can be displayed once but must never silently award its
|
||||||
/// result to unrelated core items.
|
/// result to unrelated core items.
|
||||||
@@ -353,7 +448,6 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
|
|||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
for (var index = 0; index < targetIds.length; index++) {
|
for (var index = 0; index < targetIds.length; index++) {
|
||||||
final id = targetIds[index];
|
final id = targetIds[index];
|
||||||
_recordEvidence(id, outcome);
|
|
||||||
attemptEvidence.add(
|
attemptEvidence.add(
|
||||||
AttemptEvidence(
|
AttemptEvidence(
|
||||||
id: 'lesson-$_activeSegmentId-$taskSuffix-$id-${now.microsecondsSinceEpoch}-$index',
|
id: 'lesson-$_activeSegmentId-$taskSuffix-$id-${now.microsecondsSinceEpoch}-$index',
|
||||||
@@ -368,6 +462,8 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery {
|
|||||||
assisted: assisted,
|
assisted: assisted,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
// The row goes in first: status is derived from the attempt history.
|
||||||
|
_recordEvidence(id, outcome);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,10 @@ mixin _ReviewAndMastery on _AppStateData {
|
|||||||
final nextSuccesses = canProgress
|
final nextSuccesses = canProgress
|
||||||
? current.successfulReviews + 1
|
? current.successfulReviews + 1
|
||||||
: current.successfulReviews;
|
: current.successfulReviews;
|
||||||
const intervals = [1, 2, 4];
|
// Checkpoints fall on days 1, 3, 7 and 14 after first teaching: the first
|
||||||
|
// review is due one day after teaching, then 2, 4 and 7 days apart, and
|
||||||
|
// every 30 days once all four checkpoints are passed.
|
||||||
|
const intervals = [2, 4, 7];
|
||||||
final intervalIndex = (nextSuccesses - 1)
|
final intervalIndex = (nextSuccesses - 1)
|
||||||
.clamp(0, intervals.length - 1)
|
.clamp(0, intervals.length - 1)
|
||||||
.toInt();
|
.toInt();
|
||||||
@@ -115,17 +118,23 @@ mixin _ReviewAndMastery on _AppStateData {
|
|||||||
: nextSuccesses >= 4
|
: nextSuccesses >= 4
|
||||||
? 30
|
? 30
|
||||||
: intervals[intervalIndex];
|
: intervals[intervalIndex];
|
||||||
|
// The next check of a core item uses a different reviewed situation, so
|
||||||
|
// later successes also show the item works outside the lesson's context.
|
||||||
|
// An AI variant keeps its index so it can still be reported afterwards.
|
||||||
|
final nextTemplate =
|
||||||
|
a0CoreItems.containsKey(current.id) && !current.isAiGenerated
|
||||||
|
? coreReviewVariant(current.id, current.variantIndex + 1)
|
||||||
|
: null;
|
||||||
reviewQueue[index] = current.copyWith(
|
reviewQueue[index] = current.copyWith(
|
||||||
dueAt: DateTime.now().add(Duration(days: days)),
|
dueAt: DateTime.now().add(Duration(days: days)),
|
||||||
attempts: current.attempts + 1,
|
attempts: current.attempts + 1,
|
||||||
successfulReviews: nextSuccesses,
|
successfulReviews: nextSuccesses,
|
||||||
lastProgressedAt: canProgress ? now : current.lastProgressedAt,
|
lastProgressedAt: canProgress ? now : current.lastProgressedAt,
|
||||||
|
prompt: nextTemplate?.prompt,
|
||||||
|
hint: nextTemplate?.hint,
|
||||||
|
skill: nextTemplate?.skill,
|
||||||
|
variantIndex: nextTemplate == null ? null : current.variantIndex + 1,
|
||||||
);
|
);
|
||||||
if (assisted) {
|
|
||||||
_recordEvidence(current.id, EvidenceKind.assisted);
|
|
||||||
} else {
|
|
||||||
_recordReviewSuccess(current.id, nextSuccesses);
|
|
||||||
}
|
|
||||||
_addAttemptEvidence(
|
_addAttemptEvidence(
|
||||||
current,
|
current,
|
||||||
outcome: assisted
|
outcome: assisted
|
||||||
@@ -134,6 +143,11 @@ mixin _ReviewAndMastery on _AppStateData {
|
|||||||
assisted: assisted,
|
assisted: assisted,
|
||||||
rawAnswer: rawAnswer,
|
rawAnswer: rawAnswer,
|
||||||
);
|
);
|
||||||
|
if (assisted) {
|
||||||
|
_recordEvidence(current.id, EvidenceKind.assisted);
|
||||||
|
} else {
|
||||||
|
_recordReviewSuccess(current.id, nextSuccesses);
|
||||||
|
}
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
_syncInBackground();
|
_syncInBackground();
|
||||||
}
|
}
|
||||||
@@ -156,8 +170,9 @@ mixin _ReviewAndMastery on _AppStateData {
|
|||||||
? (existing.checkpoint - 1).clamp(0, 4).toInt()
|
? (existing.checkpoint - 1).clamp(0, 4).toInt()
|
||||||
: existing.checkpoint;
|
: existing.checkpoint;
|
||||||
mastery[item.id] = existing.copyWith(
|
mastery[item.id] = existing.copyWith(
|
||||||
status: secondFailure
|
// Mastery needs all four checkpoints; losing one caps the item at use.
|
||||||
? _statusForCheckpoint(nextCheckpoint)
|
status: secondFailure && nextCheckpoint < 4
|
||||||
|
? _lowerStatus(existing.status, MasteryStatus.use)
|
||||||
: existing.status,
|
: existing.status,
|
||||||
checkpoint: nextCheckpoint,
|
checkpoint: nextCheckpoint,
|
||||||
needsReview: true,
|
needsReview: true,
|
||||||
@@ -227,17 +242,17 @@ mixin _ReviewAndMastery on _AppStateData {
|
|||||||
|
|
||||||
/// Adds one low-priority, non-core recap based on a completed independent
|
/// Adds one low-priority, non-core recap based on a completed independent
|
||||||
/// dialogue. It is deliberately separate from A0 denominator items.
|
/// dialogue. It is deliberately separate from A0 denominator items.
|
||||||
void addDialogueRecap(String sentence) {
|
void addDialogueRecap(String sentence, {String sceneId = 'a0-meet'}) {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final day =
|
final day =
|
||||||
'${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}';
|
'${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}';
|
||||||
final id = 'dialogue-a0-meet-$day';
|
final id = 'dialogue-$sceneId-$day';
|
||||||
if (reviewQueue.any((item) => item.id == id)) return;
|
if (reviewQueue.any((item) => item.id == id)) return;
|
||||||
reviewQueue.add(
|
reviewQueue.add(
|
||||||
ReviewItem(
|
ReviewItem(
|
||||||
id: id,
|
id: id,
|
||||||
target: sentence,
|
target: sentence,
|
||||||
prompt: '再用英语介绍一次自己。',
|
prompt: sceneById(sceneId).recapPrompt,
|
||||||
hint: '试着不用提示,说出你刚才表达的内容。',
|
hint: '试着不用提示,说出你刚才表达的内容。',
|
||||||
dueAt: now.add(const Duration(days: 1)),
|
dueAt: now.add(const Duration(days: 1)),
|
||||||
skill: '情境复练',
|
skill: '情境复练',
|
||||||
@@ -374,7 +389,6 @@ mixin _ReviewAndMastery on _AppStateData {
|
|||||||
..sort((left, right) => left.createdAt.compareTo(right.createdAt));
|
..sort((left, right) => left.createdAt.compareTo(right.createdAt));
|
||||||
var checkpoint = 0;
|
var checkpoint = 0;
|
||||||
var needsReview = forceReview;
|
var needsReview = forceReview;
|
||||||
var nonReviewSuccesses = 0;
|
|
||||||
DateTime? firstTaughtAt;
|
DateTime? firstTaughtAt;
|
||||||
final progressedDays = <String>{};
|
final progressedDays = <String>{};
|
||||||
for (final event in events) {
|
for (final event in events) {
|
||||||
@@ -384,10 +398,6 @@ mixin _ReviewAndMastery on _AppStateData {
|
|||||||
(firstTaughtAt == null || event.createdAt.isBefore(firstTaughtAt))) {
|
(firstTaughtAt == null || event.createdAt.isBefore(firstTaughtAt))) {
|
||||||
firstTaughtAt = event.createdAt;
|
firstTaughtAt = event.createdAt;
|
||||||
}
|
}
|
||||||
if (!isReview && event.outcome == EvidenceKind.independentSuccess) {
|
|
||||||
nonReviewSuccesses++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!isReview) continue;
|
if (!isReview) continue;
|
||||||
if (event.outcome == EvidenceKind.independentSuccess) {
|
if (event.outcome == EvidenceKind.independentSuccess) {
|
||||||
final day =
|
final day =
|
||||||
@@ -399,12 +409,10 @@ mixin _ReviewAndMastery on _AppStateData {
|
|||||||
needsReview = true;
|
needsReview = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
final independentLevel = nonReviewSuccesses.clamp(0, 3);
|
|
||||||
final level = checkpoint > independentLevel ? checkpoint : independentLevel;
|
|
||||||
mastery[id] = MasteryItem(
|
mastery[id] = MasteryItem(
|
||||||
id: id,
|
id: id,
|
||||||
label: existing?.label ?? a0CoreItems[id] ?? id,
|
label: existing?.label ?? a0CoreItems[id] ?? id,
|
||||||
status: _statusForCheckpoint(level),
|
status: _statusFromEvidence(id, checkpoint),
|
||||||
checkpoint: checkpoint,
|
checkpoint: checkpoint,
|
||||||
needsReview: needsReview,
|
needsReview: needsReview,
|
||||||
evidence: events.map((event) => event.outcome).toList(),
|
evidence: events.map((event) => event.outcome).toList(),
|
||||||
@@ -417,22 +425,17 @@ mixin _ReviewAndMastery on _AppStateData {
|
|||||||
mastery[id] ??
|
mastery[id] ??
|
||||||
MasteryItem(
|
MasteryItem(
|
||||||
id: id,
|
id: id,
|
||||||
label: id,
|
label: a0CoreItems[id] ?? id,
|
||||||
status: MasteryStatus.newItem,
|
status: MasteryStatus.newItem,
|
||||||
evidence: const [],
|
evidence: const [],
|
||||||
);
|
);
|
||||||
final allEvidence = [...existing.evidence, evidence];
|
mastery[id] = existing.copyWith(
|
||||||
MasteryStatus next = existing.status;
|
status: _higherStatus(
|
||||||
if (evidence == EvidenceKind.independentSuccess) {
|
existing.status,
|
||||||
next = switch (existing.status) {
|
_statusFromEvidence(id, existing.checkpoint),
|
||||||
MasteryStatus.newItem => MasteryStatus.recognize,
|
),
|
||||||
MasteryStatus.recognize => MasteryStatus.recall,
|
evidence: [...existing.evidence, evidence],
|
||||||
MasteryStatus.recall => MasteryStatus.use,
|
);
|
||||||
MasteryStatus.use || MasteryStatus.master => existing.status,
|
|
||||||
MasteryStatus.needsReview => MasteryStatus.recall,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
mastery[id] = existing.copyWith(status: next, evidence: allEvidence);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _recordReviewSuccess(String id, int successes) {
|
void _recordReviewSuccess(String id, int successes) {
|
||||||
@@ -445,19 +448,93 @@ mixin _ReviewAndMastery on _AppStateData {
|
|||||||
evidence: const [],
|
evidence: const [],
|
||||||
);
|
);
|
||||||
final checkpoint = successes.clamp(0, 4).toInt();
|
final checkpoint = successes.clamp(0, 4).toInt();
|
||||||
|
// A success never lowers what earlier evidence already showed.
|
||||||
mastery[id] = existing.copyWith(
|
mastery[id] = existing.copyWith(
|
||||||
status: _statusForCheckpoint(checkpoint),
|
status: _higherStatus(
|
||||||
|
existing.status,
|
||||||
|
_statusFromEvidence(id, checkpoint),
|
||||||
|
),
|
||||||
checkpoint: checkpoint,
|
checkpoint: checkpoint,
|
||||||
needsReview: false,
|
needsReview: false,
|
||||||
evidence: [...existing.evidence, EvidenceKind.independentSuccess],
|
evidence: [...existing.evidence, EvidenceKind.independentSuccess],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
MasteryStatus _statusForCheckpoint(int checkpoint) => switch (checkpoint) {
|
/// Skills whose success only shows the learner recognises the item.
|
||||||
0 => MasteryStatus.newItem,
|
static const _recognitionSkills = {'听辨识别', '阅读识别', '听力理解', '阅读理解'};
|
||||||
1 => MasteryStatus.recognize,
|
|
||||||
2 => MasteryStatus.recall,
|
/// Controlled lesson steps (follow-reading, scripted dialogue) are practice,
|
||||||
3 => MasteryStatus.use,
|
/// not attempts to recall, so they never count as a valid answer.
|
||||||
_ => MasteryStatus.master,
|
static bool _isControlledPractice(AttemptEvidence entry) =>
|
||||||
|
entry.taskId.startsWith('lesson-') &&
|
||||||
|
(entry.taskId.endsWith('-speaking') ||
|
||||||
|
entry.taskId.endsWith('-dialogue'));
|
||||||
|
|
||||||
|
/// Use outside the lesson it was taught in: a free scene dialogue, an AI
|
||||||
|
/// adaptive task, or a review answered in a changed situation.
|
||||||
|
static bool _isContextUse(AttemptEvidence entry) =>
|
||||||
|
entry.taskId.startsWith('dialogue-scene-') ||
|
||||||
|
entry.id.startsWith('adaptive-') ||
|
||||||
|
(entry.taskId == 'review-${entry.itemId}' && entry.variantIndex >= 1);
|
||||||
|
|
||||||
|
/// Learning engine 3.1: recognise = a recognition success; recall = an
|
||||||
|
/// unaided spoken or written success; use = unaided use in a different
|
||||||
|
/// situation; master = four checkpoints, all three kinds of evidence, and
|
||||||
|
/// the latest two valid answers both unaided.
|
||||||
|
MasteryStatus _statusFromEvidence(String id, int checkpoint) {
|
||||||
|
var recognized = false;
|
||||||
|
var recalled = false;
|
||||||
|
var usedInContext = false;
|
||||||
|
final validAnswers = <EvidenceKind>[];
|
||||||
|
final events = attemptEvidence.where((entry) => entry.itemId == id).toList()
|
||||||
|
..sort((left, right) => left.createdAt.compareTo(right.createdAt));
|
||||||
|
for (final event in events) {
|
||||||
|
final recognition = _recognitionSkills.contains(event.skill);
|
||||||
|
if (event.outcome == EvidenceKind.independentSuccess) {
|
||||||
|
if (recognition) {
|
||||||
|
recognized = true;
|
||||||
|
} else {
|
||||||
|
recalled = true;
|
||||||
|
if (_isContextUse(event)) usedInContext = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!recognition &&
|
||||||
|
!_isControlledPractice(event) &&
|
||||||
|
(event.outcome == EvidenceKind.independentSuccess ||
|
||||||
|
event.outcome == EvidenceKind.assisted ||
|
||||||
|
event.outcome == EvidenceKind.languageError)) {
|
||||||
|
validAnswers.add(event.outcome);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final latestTwoUnaided =
|
||||||
|
validAnswers.length >= 2 &&
|
||||||
|
validAnswers
|
||||||
|
.skip(validAnswers.length - 2)
|
||||||
|
.every((outcome) => outcome == EvidenceKind.independentSuccess);
|
||||||
|
if (checkpoint >= 4 &&
|
||||||
|
recognized &&
|
||||||
|
recalled &&
|
||||||
|
usedInContext &&
|
||||||
|
latestTwoUnaided) {
|
||||||
|
return MasteryStatus.master;
|
||||||
|
}
|
||||||
|
if (recalled && usedInContext) return MasteryStatus.use;
|
||||||
|
if (recalled) return MasteryStatus.recall;
|
||||||
|
if (recognized) return MasteryStatus.recognize;
|
||||||
|
return MasteryStatus.newItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _statusRank(MasteryStatus status) => switch (status) {
|
||||||
|
MasteryStatus.newItem => 0,
|
||||||
|
MasteryStatus.recognize => 1,
|
||||||
|
MasteryStatus.recall || MasteryStatus.needsReview => 2,
|
||||||
|
MasteryStatus.use => 3,
|
||||||
|
MasteryStatus.master => 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static MasteryStatus _higherStatus(MasteryStatus a, MasteryStatus b) =>
|
||||||
|
_statusRank(b) > _statusRank(a) ? b : a;
|
||||||
|
|
||||||
|
static MasteryStatus _lowerStatus(MasteryStatus a, MasteryStatus b) =>
|
||||||
|
_statusRank(b) < _statusRank(a) ? b : a;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,8 @@ extension _AppStateSnapshot on AppState {
|
|||||||
previewIndex = data['previewIndex'] as int? ?? previewIndex;
|
previewIndex = data['previewIndex'] as int? ?? previewIndex;
|
||||||
completedLessons = data['completedLessons'] as int? ?? completedLessons;
|
completedLessons = data['completedLessons'] as int? ?? completedLessons;
|
||||||
activeLessonId = data['activeLessonId'] as String? ?? activeLessonId;
|
activeLessonId = data['activeLessonId'] as String? ?? activeLessonId;
|
||||||
|
placementStartLessonId =
|
||||||
|
data['placementStartLessonId'] as String? ?? placementStartLessonId;
|
||||||
completedLessonIds
|
completedLessonIds
|
||||||
..clear()
|
..clear()
|
||||||
..addAll(
|
..addAll(
|
||||||
@@ -327,6 +329,7 @@ extension _AppStateSnapshot on AppState {
|
|||||||
'previewIndex': previewIndex,
|
'previewIndex': previewIndex,
|
||||||
'completedLessons': completedLessons,
|
'completedLessons': completedLessons,
|
||||||
'activeLessonId': activeLessonId,
|
'activeLessonId': activeLessonId,
|
||||||
|
'placementStartLessonId': placementStartLessonId,
|
||||||
'completedLessonIds': completedLessonIds.toList(),
|
'completedLessonIds': completedLessonIds.toList(),
|
||||||
'completedSegmentIds': completedSegmentIds.toList(),
|
'completedSegmentIds': completedSegmentIds.toList(),
|
||||||
'reportedAiVariantKeys': reportedAiVariantKeys.toList(),
|
'reportedAiVariantKeys': reportedAiVariantKeys.toList(),
|
||||||
|
|||||||
@@ -429,6 +429,7 @@ class LocalSnapshotStore {
|
|||||||
'previewIndex',
|
'previewIndex',
|
||||||
'completedLessons',
|
'completedLessons',
|
||||||
'activeLessonId',
|
'activeLessonId',
|
||||||
|
'placementStartLessonId',
|
||||||
'completedLessonIds',
|
'completedLessonIds',
|
||||||
'completedSegmentIds',
|
'completedSegmentIds',
|
||||||
'activeSegmentIndexes',
|
'activeSegmentIndexes',
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'a0_core.dart';
|
||||||
import 'models.dart';
|
import 'models.dart';
|
||||||
|
|
||||||
class ReviewCheckResult {
|
class ReviewCheckResult {
|
||||||
@@ -21,70 +22,20 @@ class ReviewFeedback {
|
|||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
static ReviewCheckResult check(ReviewItem item, String input) {
|
static ReviewCheckResult check(ReviewItem item, String input) {
|
||||||
final text = _normalize(input);
|
final complete = a0CoreItems.containsKey(item.id)
|
||||||
final tokens = RegExp(
|
// A word review checks the word it actually shows.
|
||||||
r"[a-z]+(?:'[a-z]+)?",
|
? coreItemUsedIn(item.id, input, word: item.target)
|
||||||
).allMatches(text).map((match) => match.group(0)!).toSet();
|
: _tokens(_normalize(input)).length >= 2;
|
||||||
bool has(String token) => tokens.contains(_normalize(token));
|
|
||||||
bool phrase(String value) => text.contains(_normalize(value));
|
|
||||||
bool hasAny(Iterable<String> values) => values.any(has);
|
|
||||||
final introduction =
|
|
||||||
phrase("i'm") || phrase('i am') || phrase('my name is');
|
|
||||||
final itIs = phrase("it's") || phrase('it is');
|
|
||||||
final numberWords = RegExp(
|
|
||||||
r'\b(zero|one|two|three|four|five|six|seven|eight|nine|ten)\b',
|
|
||||||
).allMatches(text).length;
|
|
||||||
|
|
||||||
final complete = switch (item.id) {
|
|
||||||
'A0-P01' => introduction && tokens.length >= 2,
|
|
||||||
'A0-P02' => (phrase("what's your name") || phrase('what is your name')),
|
|
||||||
'A0-P03' => phrase('nice to meet you'),
|
|
||||||
'A0-P04' => phrase('how do you spell'),
|
|
||||||
'A0-P05' => phrase('how are you'),
|
|
||||||
'A0-P06' => introduction && hasAny(['good', 'okay', 'tired']),
|
|
||||||
'A0-P07' =>
|
|
||||||
phrase("what's your phone number") ||
|
|
||||||
phrase('what is your phone number'),
|
|
||||||
'A0-P08' =>
|
|
||||||
(phrase('my number is') || numberWords >= 3) && numberWords >= 3,
|
|
||||||
'A0-P09' => phrase("what's this") || phrase('what is this'),
|
|
||||||
'A0-P10' => itIs && hasAny(['book', 'pen', 'bag', 'key']),
|
|
||||||
'A0-P11' => phrase('where are you from'),
|
|
||||||
'A0-P12' => introduction && has('from') && tokens.length >= 3,
|
|
||||||
'A0-P13' => phrase('this is my') && tokens.length >= 4,
|
|
||||||
'A0-P14' => phrase('what day is it'),
|
|
||||||
'A0-P15' =>
|
|
||||||
itIs &&
|
|
||||||
hasAny([
|
|
||||||
'monday',
|
|
||||||
'tuesday',
|
|
||||||
'wednesday',
|
|
||||||
'thursday',
|
|
||||||
'friday',
|
|
||||||
'saturday',
|
|
||||||
'sunday',
|
|
||||||
]),
|
|
||||||
'A0-P16' => phrase('what time is it'),
|
|
||||||
'A0-P17' =>
|
|
||||||
itIs &&
|
|
||||||
(has('clock') ||
|
|
||||||
has("o'clock") ||
|
|
||||||
has('oclock') ||
|
|
||||||
phrase("o'clock")),
|
|
||||||
'A0-P18' => introduction && has('like') && tokens.length >= 3,
|
|
||||||
'A0-P19' => phrase('do you like'),
|
|
||||||
'A0-P20' =>
|
|
||||||
phrase('please say that again') || phrase('please speak slowly'),
|
|
||||||
_ when item.id.startsWith('A0-W') =>
|
|
||||||
tokens.contains(_normalize(item.target)) || phrase(item.target),
|
|
||||||
_ => tokens.length >= 2,
|
|
||||||
};
|
|
||||||
return ReviewCheckResult(
|
return ReviewCheckResult(
|
||||||
complete: complete,
|
complete: complete,
|
||||||
message: complete ? '表达已完成,可以进入下一次复习安排。' : _hint(item.id),
|
message: complete ? '表达已完成,可以进入下一次复习安排。' : _hint(item.id),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Set<String> _tokens(String text) => RegExp(
|
||||||
|
r"[a-z]+(?:'[a-z]+)?",
|
||||||
|
).allMatches(text).map((match) => match.group(0)!).toSet();
|
||||||
|
|
||||||
static String _hint(String id) => switch (id) {
|
static String _hint(String id) => switch (id) {
|
||||||
'A0-P01' => '用 I’m / I am 或 My name is 介绍一个名字。',
|
'A0-P01' => '用 I’m / I am 或 My name is 介绍一个名字。',
|
||||||
'A0-P02' => '试着问:What’s your name?',
|
'A0-P02' => '试着问:What’s your name?',
|
||||||
@@ -97,3 +48,66 @@ class ReviewFeedback {
|
|||||||
_ => '再补充本次目标中的关键英文词或句型。',
|
_ => '再补充本次目标中的关键英文词或句型。',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether [input] actually contains A0 core item [id]. Lessons, reviews and
|
||||||
|
/// dialogues use the same rule, so evidence only goes to the items a learner
|
||||||
|
/// really produced, never to every target a task happens to be attached to.
|
||||||
|
bool coreItemUsedIn(String id, String input, {String? word}) {
|
||||||
|
final text = ReviewFeedback._normalize(input);
|
||||||
|
final tokens = ReviewFeedback._tokens(text);
|
||||||
|
bool has(String token) => tokens.contains(ReviewFeedback._normalize(token));
|
||||||
|
bool phrase(String value) => text.contains(ReviewFeedback._normalize(value));
|
||||||
|
bool hasAny(Iterable<String> values) => values.any(has);
|
||||||
|
final introduction = phrase("i'm") || phrase('i am') || phrase('my name is');
|
||||||
|
final itIs = phrase("it's") || phrase('it is');
|
||||||
|
final numberWords = RegExp(
|
||||||
|
r'\b(zero|one|two|three|four|five|six|seven|eight|nine|ten)\b',
|
||||||
|
).allMatches(text).length;
|
||||||
|
const weekdays = [
|
||||||
|
'monday',
|
||||||
|
'tuesday',
|
||||||
|
'wednesday',
|
||||||
|
'thursday',
|
||||||
|
'friday',
|
||||||
|
'saturday',
|
||||||
|
'sunday',
|
||||||
|
];
|
||||||
|
|
||||||
|
return switch (id) {
|
||||||
|
// "I'm from …" and "I'm good" are other items, not a name.
|
||||||
|
'A0-P01' =>
|
||||||
|
phrase('my name is') ||
|
||||||
|
RegExp(
|
||||||
|
r"\bi(?:'m| am) (?!from\b|good\b|okay\b|ok\b|fine\b|great\b|tired\b)[a-z]",
|
||||||
|
).hasMatch(text),
|
||||||
|
'A0-P02' => phrase("what's your name") || phrase('what is your name'),
|
||||||
|
'A0-P03' => phrase('nice to meet you'),
|
||||||
|
'A0-P04' => phrase('how do you spell'),
|
||||||
|
'A0-P05' => phrase('how are you'),
|
||||||
|
'A0-P06' => introduction && hasAny(['good', 'okay', 'tired']),
|
||||||
|
'A0-P07' =>
|
||||||
|
phrase("what's your phone number") || phrase('what is your phone number'),
|
||||||
|
'A0-P08' => numberWords >= 3,
|
||||||
|
'A0-P09' => phrase("what's this") || phrase('what is this'),
|
||||||
|
'A0-P10' => itIs && hasAny(['book', 'pen', 'bag', 'key', 'phone']),
|
||||||
|
'A0-P11' => phrase('where are you from'),
|
||||||
|
'A0-P12' => introduction && has('from') && tokens.length >= 3,
|
||||||
|
'A0-P13' => phrase('this is my') && tokens.length >= 4,
|
||||||
|
'A0-P14' => phrase('what day is it'),
|
||||||
|
'A0-P15' => itIs && hasAny(weekdays),
|
||||||
|
'A0-P16' => phrase('what time is it'),
|
||||||
|
'A0-P17' =>
|
||||||
|
itIs &&
|
||||||
|
(has('clock') ||
|
||||||
|
has("o'clock") ||
|
||||||
|
has('oclock') ||
|
||||||
|
phrase("o'clock")),
|
||||||
|
'A0-P18' => introduction && has('like') && tokens.length >= 3,
|
||||||
|
'A0-P19' => phrase('do you like'),
|
||||||
|
'A0-P20' =>
|
||||||
|
phrase('please say that again') || phrase('please speak slowly'),
|
||||||
|
_ when a0CoreItems.containsKey(id) && id.startsWith('A0-W') =>
|
||||||
|
has(word ?? a0CoreItems[id]!) || phrase(word ?? a0CoreItems[id]!),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1116,7 +1116,16 @@ bool matchesSegmentIndependent(String segmentId, String response) {
|
|||||||
.expand((segments) => segments)
|
.expand((segments) => segments)
|
||||||
.where((item) => item.id == segmentId)
|
.where((item) => item.id == segmentId)
|
||||||
.firstOrNull;
|
.firstOrNull;
|
||||||
if (segment == null) return true;
|
if (segment == null) {
|
||||||
|
// Single-segment lessons: every group of the lesson's independent task
|
||||||
|
// has to be present. Unknown segments never pass.
|
||||||
|
final lessonId = segmentId.replaceFirst(RegExp(r'-[a-z]$'), '');
|
||||||
|
final groups = a0IndependentRequiredTerms[lessonId];
|
||||||
|
if (groups == null) return false;
|
||||||
|
return groups.every(
|
||||||
|
(group) => group.any((term) => _containsTerm(text, term)),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (segmentId == 'a0-04-a' || segmentId == 'a0-04-b') {
|
if (segmentId == 'a0-04-a' || segmentId == 'a0-04-b') {
|
||||||
final count = segment.independentRequiredTerms
|
final count = segment.independentRequiredTerms
|
||||||
.where((term) => text.contains(_normalizeDialogueInput(term)))
|
.where((term) => text.contains(_normalizeDialogueInput(term)))
|
||||||
@@ -1129,10 +1138,64 @@ bool matchesSegmentIndependent(String segmentId, String response) {
|
|||||||
.length >=
|
.length >=
|
||||||
3;
|
3;
|
||||||
}
|
}
|
||||||
return segment.independentRequiredTerms
|
return segment.independentRequiredTerms.any(
|
||||||
.any((term) => text.contains(_normalizeDialogueInput(term)));
|
(term) => text.contains(_normalizeDialogueInput(term)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const _introductionTerms = [
|
||||||
|
"i'm + #word",
|
||||||
|
'i am + #word',
|
||||||
|
'my name is',
|
||||||
|
"my name's",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// What the independent attempt of each single-segment lesson has to contain.
|
||||||
|
/// Each inner list is one part of the task; a part passes when any of its
|
||||||
|
/// terms appears. Terms follow the same syntax as [LessonDialogue.requiredTerms].
|
||||||
|
const a0IndependentRequiredTerms = <String, List<List<String>>>{
|
||||||
|
// 介绍你的名字并回应 Nice to meet you
|
||||||
|
'a0-01': [
|
||||||
|
_introductionTerms,
|
||||||
|
['nice to meet you'],
|
||||||
|
],
|
||||||
|
// 介绍名字并拼读它
|
||||||
|
'a0-02': [
|
||||||
|
_introductionTerms,
|
||||||
|
['#spelling'],
|
||||||
|
],
|
||||||
|
// 问候并说出你的状态
|
||||||
|
'a0-03': [
|
||||||
|
['hello', 'hi', 'hey', 'how are you'],
|
||||||
|
['good', 'okay', 'ok', 'fine', 'great', 'tired'],
|
||||||
|
],
|
||||||
|
// 说出一个身边物品
|
||||||
|
'a0-05': [
|
||||||
|
["it's a", 'it is a', "it's an", 'it is an'],
|
||||||
|
],
|
||||||
|
// 说来自哪里并反问对方
|
||||||
|
'a0-06': [
|
||||||
|
["i'm from", 'i am from'],
|
||||||
|
['where are you from', '#question'],
|
||||||
|
],
|
||||||
|
// 介绍一位家人或朋友
|
||||||
|
'a0-07': [
|
||||||
|
['this is my'],
|
||||||
|
],
|
||||||
|
// 说一个喜好并反问
|
||||||
|
'a0-09': [
|
||||||
|
['i like', 'i love'],
|
||||||
|
['do you like', '#question'],
|
||||||
|
],
|
||||||
|
// 完成姓名、地点、喜好和反问
|
||||||
|
'a0-10': [
|
||||||
|
_introductionTerms,
|
||||||
|
["i'm from", 'i am from', 'from + #word'],
|
||||||
|
['i like', 'i love'],
|
||||||
|
['#question'],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
class LessonDialogue {
|
class LessonDialogue {
|
||||||
const LessonDialogue({
|
const LessonDialogue({
|
||||||
required this.goal,
|
required this.goal,
|
||||||
@@ -1172,8 +1235,17 @@ bool _containsTerm(String text, String term) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _matchesStructure(String text, String token) => switch (token) {
|
bool _matchesStructure(String text, String token) => switch (token) {
|
||||||
// Three or more letters said one by one, e.g. "S-H-E-N" or "s h e n".
|
// Letters said one by one ("S-H-E-N", "s h e n", "L-I"), or the spelled
|
||||||
'#spelling' => RegExp(r'(?:^|[^a-z])[a-z](?:[ -][a-z]){2,}').hasMatch(text),
|
// name typed as one word ("Shen", "aaa"). Case and hyphens are ignored.
|
||||||
|
'#spelling' =>
|
||||||
|
RegExp(
|
||||||
|
r'(?:^|[^a-z])[a-z](?:\s*[-\u2010-\u2015]\s*[a-z]|\s[a-z])+(?![a-z])',
|
||||||
|
).hasMatch(text) ||
|
||||||
|
RegExp(r'^[a-z]{2,}$').hasMatch(
|
||||||
|
text
|
||||||
|
.replaceAll(RegExp(r'[-\u2010-\u2015]'), '')
|
||||||
|
.replaceAll(RegExp(r'^[^a-z]+|[^a-z]+$'), ''),
|
||||||
|
),
|
||||||
// Any spoken or written digit.
|
// Any spoken or written digit.
|
||||||
'#digit' => RegExp(
|
'#digit' => RegExp(
|
||||||
r'(?<![a-z])(zero|one|two|three|four|five|six|seven|eight|nine|ten)(?![a-z])|[0-9]',
|
r'(?<![a-z])(zero|one|two|three|four|five|six|seven|eight|nine|ten)(?![a-z])|[0-9]',
|
||||||
@@ -1232,21 +1304,150 @@ const a0MeetDialogue = LessonDialogue(
|
|||||||
requiredTerms: [
|
requiredTerms: [
|
||||||
["i'm + #word", 'i am + #word', 'my name is', "my name's", 'name is'],
|
["i'm + #word", 'i am + #word', 'my name is', "my name's", 'name is'],
|
||||||
["i'm from", 'i am from', 'from + #word'],
|
["i'm from", 'i am from', 'from + #word'],
|
||||||
[
|
['good', 'okay', 'ok', 'fine', 'great', 'tired', 'i like', 'i love'],
|
||||||
'good',
|
|
||||||
'okay',
|
|
||||||
'ok',
|
|
||||||
'fine',
|
|
||||||
'great',
|
|
||||||
'tired',
|
|
||||||
'i like',
|
|
||||||
'i love',
|
|
||||||
],
|
|
||||||
['#question'],
|
['#question'],
|
||||||
],
|
],
|
||||||
taskLabels: ['介绍姓名', '说明来自哪里', '表达状态或喜好', '反问对方'],
|
taskLabels: ['介绍姓名', '说明来自哪里', '表达状态或喜好', '反问对方'],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// A free practice scene. It opens once [unlockAfterLessonId] is complete
|
||||||
|
/// (always open when null) and only uses language taught up to that lesson.
|
||||||
|
class DialogueScene {
|
||||||
|
const DialogueScene({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.summary,
|
||||||
|
required this.script,
|
||||||
|
required this.recapPrompt,
|
||||||
|
this.unlockAfterLessonId,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String title;
|
||||||
|
final String summary;
|
||||||
|
final LessonDialogue script;
|
||||||
|
|
||||||
|
/// The follow-up review prompt added after the scene is finished.
|
||||||
|
final String recapPrompt;
|
||||||
|
final String? unlockAfterLessonId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const a0Scenes = [
|
||||||
|
DialogueScene(
|
||||||
|
id: 'a0-meet',
|
||||||
|
title: '初次见面',
|
||||||
|
summary: '介绍姓名、地点、状态或喜好,并反问对方',
|
||||||
|
script: a0MeetDialogue,
|
||||||
|
recapPrompt: '再用英语介绍一次自己。',
|
||||||
|
),
|
||||||
|
DialogueScene(
|
||||||
|
id: 'a0-number',
|
||||||
|
title: '留个电话',
|
||||||
|
summary: '问候、报出号码、再说一遍,并反问对方',
|
||||||
|
unlockAfterLessonId: 'a0-04',
|
||||||
|
recapPrompt: '用英语报一次你的虚拟电话号码。',
|
||||||
|
script: LessonDialogue(
|
||||||
|
goal: '问候并交换一个虚拟电话号码',
|
||||||
|
prompts: [
|
||||||
|
'Hi! How are you today?',
|
||||||
|
'Good. What\u2019s your phone number? Use a fake number.',
|
||||||
|
'Sorry, please say it again.',
|
||||||
|
'Thanks! Now ask me one question.',
|
||||||
|
],
|
||||||
|
hints: [
|
||||||
|
'I\u2019m good, thanks.',
|
||||||
|
'My number is one-three-eight.',
|
||||||
|
'One-three-eight.',
|
||||||
|
'What\u2019s your phone number?',
|
||||||
|
],
|
||||||
|
translations: [
|
||||||
|
'嗨!你今天好吗?',
|
||||||
|
'好的。你的电话号码是多少?用一个虚拟号码。',
|
||||||
|
'抱歉,请再说一遍。',
|
||||||
|
'谢谢!现在问我一个问题。',
|
||||||
|
],
|
||||||
|
requiredTerms: [
|
||||||
|
['good', 'okay', 'ok', 'fine', 'great', 'tired'],
|
||||||
|
['#digit'],
|
||||||
|
['#digit'],
|
||||||
|
['#question'],
|
||||||
|
],
|
||||||
|
taskLabels: ['说出你今天的状态', '报出一个号码', '再说一次号码', '反问对方'],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DialogueScene(
|
||||||
|
id: 'a0-photo',
|
||||||
|
title: '看照片聊天',
|
||||||
|
summary: '介绍照片里的人和东西,说喜好,并反问对方',
|
||||||
|
unlockAfterLessonId: 'a0-07',
|
||||||
|
recapPrompt: '用英语介绍一位家人或朋友。',
|
||||||
|
script: LessonDialogue(
|
||||||
|
goal: '介绍照片里的人和物品',
|
||||||
|
prompts: [
|
||||||
|
'Nice photo! Who is this?',
|
||||||
|
'And what\u2019s this in the photo?',
|
||||||
|
'Where are you from?',
|
||||||
|
'Cool! Now ask me one question.',
|
||||||
|
],
|
||||||
|
hints: [
|
||||||
|
'This is my sister.',
|
||||||
|
'It\u2019s a bag.',
|
||||||
|
'I\u2019m from Hong Kong.',
|
||||||
|
'Who is this? / What\u2019s this?',
|
||||||
|
],
|
||||||
|
translations: ['照片真好!这是谁?', '照片里这个是什么?', '你来自哪里?', '真酷!现在问我一个问题。'],
|
||||||
|
requiredTerms: [
|
||||||
|
['this is my', 'this is'],
|
||||||
|
["it's a", 'it is a', "it's an", 'it is an'],
|
||||||
|
["i'm from", 'i am from', 'from + #word'],
|
||||||
|
['#question'],
|
||||||
|
],
|
||||||
|
taskLabels: ['介绍照片里的人', '说出照片里的物品', '说出你来自哪里', '反问对方'],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DialogueScene(
|
||||||
|
id: 'a0-plan',
|
||||||
|
title: '约个时间',
|
||||||
|
summary: '说星期和整点,回答喜好,并反问对方',
|
||||||
|
unlockAfterLessonId: 'a0-09',
|
||||||
|
recapPrompt: '用英语说今天星期几和现在几点。',
|
||||||
|
script: LessonDialogue(
|
||||||
|
goal: '说明星期、时间和喜好',
|
||||||
|
prompts: [
|
||||||
|
'Hi! What day is it today?',
|
||||||
|
'What time is it now?',
|
||||||
|
'Do you like coffee or tea?',
|
||||||
|
'Me too! Now ask me one question.',
|
||||||
|
],
|
||||||
|
hints: [
|
||||||
|
'It\u2019s Friday.',
|
||||||
|
'It\u2019s three o\u2019clock.',
|
||||||
|
'I like tea.',
|
||||||
|
'Do you like music?',
|
||||||
|
],
|
||||||
|
translations: ['嗨!今天星期几?', '现在几点?', '你喜欢咖啡还是茶?', '我也是!现在问我一个问题。'],
|
||||||
|
requiredTerms: [
|
||||||
|
[
|
||||||
|
'monday',
|
||||||
|
'tuesday',
|
||||||
|
'wednesday',
|
||||||
|
'thursday',
|
||||||
|
'friday',
|
||||||
|
'saturday',
|
||||||
|
'sunday',
|
||||||
|
],
|
||||||
|
["o'clock", 'oclock', "it's + #digit", 'it is + #digit'],
|
||||||
|
['i like', 'i love', 'yes', 'no', "i don't"],
|
||||||
|
['#question'],
|
||||||
|
],
|
||||||
|
taskLabels: ['说出今天星期几', '说出现在几点', '说出你的喜好', '反问对方'],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
DialogueScene sceneById(String id) =>
|
||||||
|
a0Scenes.where((scene) => scene.id == id).firstOrNull ?? a0Scenes.first;
|
||||||
|
|
||||||
const a0Dialogues = <String, LessonDialogue>{
|
const a0Dialogues = <String, LessonDialogue>{
|
||||||
'a0-01': LessonDialogue(
|
'a0-01': LessonDialogue(
|
||||||
goal: '问候、介绍姓名并回应见面问候',
|
goal: '问候、介绍姓名并回应见面问候',
|
||||||
@@ -1341,7 +1542,12 @@ const a0Dialogues = <String, LessonDialogue>{
|
|||||||
['my number is + #digit', "my number's + #digit", 'number is + #digit'],
|
['my number is + #digit', "my number's + #digit", 'number is + #digit'],
|
||||||
['yes', 'no', "that's right", 'right', 'correct'],
|
['yes', 'no', "that's right", 'right', 'correct'],
|
||||||
['#digit'],
|
['#digit'],
|
||||||
["what's your phone number", 'what is your phone number', 'your phone number', 'your number'],
|
[
|
||||||
|
"what's your phone number",
|
||||||
|
'what is your phone number',
|
||||||
|
'your phone number',
|
||||||
|
'your number',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
taskLabels: ['报出一个三位号码', '确认或纠正听到的号码', '再说一次这三个数字', '反问对方的号码'],
|
taskLabels: ['报出一个三位号码', '确认或纠正听到的号码', '再说一次这三个数字', '反问对方的号码'],
|
||||||
),
|
),
|
||||||
@@ -1422,7 +1628,12 @@ const a0Dialogues = <String, LessonDialogue>{
|
|||||||
['who is this', "who's this"],
|
['who is this', "who's this"],
|
||||||
['this is my', 'this is'],
|
['this is my', 'this is'],
|
||||||
],
|
],
|
||||||
taskLabels: ['介绍一位家人或朋友', '完整说出 This is my … 句型', '反问 Who is this', '再介绍一个人'],
|
taskLabels: [
|
||||||
|
'介绍一位家人或朋友',
|
||||||
|
'完整说出 This is my … 句型',
|
||||||
|
'反问 Who is this',
|
||||||
|
'再介绍一个人',
|
||||||
|
],
|
||||||
),
|
),
|
||||||
'a0-08': LessonDialogue(
|
'a0-08': LessonDialogue(
|
||||||
goal: '说明星期或整点',
|
goal: '说明星期或整点',
|
||||||
@@ -1440,7 +1651,15 @@ const a0Dialogues = <String, LessonDialogue>{
|
|||||||
],
|
],
|
||||||
translations: ['今天星期几?', '现在几点了?', '请说一个完整的时间句子。', '现在请问我今天是星期几!'],
|
translations: ['今天星期几?', '现在几点了?', '请说一个完整的时间句子。', '现在请问我今天是星期几!'],
|
||||||
requiredTerms: [
|
requiredTerms: [
|
||||||
['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'],
|
[
|
||||||
|
'monday',
|
||||||
|
'tuesday',
|
||||||
|
'wednesday',
|
||||||
|
'thursday',
|
||||||
|
'friday',
|
||||||
|
'saturday',
|
||||||
|
'sunday',
|
||||||
|
],
|
||||||
["o'clock", 'oclock', "#digit + o'clock"],
|
["o'clock", 'oclock', "#digit + o'clock"],
|
||||||
["it's + o'clock", "it is + o'clock", "it's + #digit", 'it is + #digit'],
|
["it's + o'clock", "it is + o'clock", "it's + #digit", 'it is + #digit'],
|
||||||
['what day is it', 'what day', '#question'],
|
['what day is it', 'what day', '#question'],
|
||||||
@@ -1488,7 +1707,13 @@ const a0Dialogues = <String, LessonDialogue>{
|
|||||||
requiredTerms: [
|
requiredTerms: [
|
||||||
['say that again', 'again', 'pardon'],
|
['say that again', 'again', 'pardon'],
|
||||||
['slowly', 'slow'],
|
['slowly', 'slow'],
|
||||||
["what's your name", 'what is your name', 'where are you from', 'your name', '#question'],
|
[
|
||||||
|
"what's your name",
|
||||||
|
'what is your name',
|
||||||
|
'where are you from',
|
||||||
|
'your name',
|
||||||
|
'#question',
|
||||||
|
],
|
||||||
['i like', 'i love'],
|
['i like', 'i love'],
|
||||||
],
|
],
|
||||||
taskLabels: ['请求对方重复', '请求对方放慢语速', '反问名字或来自哪里', '说出一件你喜欢的事物'],
|
taskLabels: ['请求对方重复', '请求对方放慢语速', '反问名字或来自哪里', '说出一件你喜欢的事物'],
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ class WritingFeedback {
|
|||||||
'a0-08-b' => '用 It is / It’s 加 Thursday、Friday、Saturday 或 Sunday。',
|
'a0-08-b' => '用 It is / It’s 加 Thursday、Friday、Saturday 或 Sunday。',
|
||||||
'a0-08-c' => '用 It is / It’s 加数字和 o’clock,例如 It’s three o’clock。',
|
'a0-08-c' => '用 It is / It’s 加数字和 o’clock,例如 It’s three o’clock。',
|
||||||
'a0-01' => '试着同时写一句问候和姓名,例如:Hello. I’m …',
|
'a0-01' => '试着同时写一句问候和姓名,例如:Hello. I’m …',
|
||||||
'a0-02' => '写出姓名,并把至少三个字母用空格或连字符拼出来。',
|
'a0-02' => '写出姓名并拼写一遍,例如 My name is Shen. S-H-E-N.',
|
||||||
'a0-03' => '用 I’m / I am 加上你的状态,例如 good 或 tired。',
|
'a0-03' => '用 I’m / I am 加上你的状态,例如 good 或 tired。',
|
||||||
'a0-04' => '用英文写出三个数字,例如 one-three-nine。',
|
'a0-04' => '用英文写出三个数字,例如 one-three-nine。',
|
||||||
'a0-05' => '用 It’s / It is 加一个物品,例如 a pen。',
|
'a0-05' => '用 It’s / It is 加一个物品,例如 a pen。',
|
||||||
|
|||||||
@@ -11,54 +11,93 @@ import '../../widgets/lexicon_lookup.dart';
|
|||||||
import '../../widgets/voice_answer.dart';
|
import '../../widgets/voice_answer.dart';
|
||||||
|
|
||||||
class DialogueScenePage extends StatelessWidget {
|
class DialogueScenePage extends StatelessWidget {
|
||||||
const DialogueScenePage({super.key, required this.onStart, this.onBack});
|
const DialogueScenePage({
|
||||||
final VoidCallback onStart;
|
super.key,
|
||||||
|
required this.state,
|
||||||
|
required this.onStart,
|
||||||
|
this.onBack,
|
||||||
|
});
|
||||||
|
final AppState state;
|
||||||
|
final ValueChanged<String> onStart;
|
||||||
final VoidCallback? onBack;
|
final VoidCallback? onBack;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => AppPage(
|
Widget build(BuildContext context) {
|
||||||
appBar: onBack != null
|
final recommended = state.recommendedScene;
|
||||||
? AppBar(
|
return AppPage(
|
||||||
leading: IconButton(
|
appBar: onBack != null
|
||||||
icon: const Icon(Icons.arrow_back),
|
? AppBar(
|
||||||
tooltip: "返回",
|
leading: IconButton(
|
||||||
onPressed: onBack,
|
icon: const Icon(Icons.arrow_back),
|
||||||
),
|
tooltip: "返回",
|
||||||
title: const Text("AI 情境对话"),
|
onPressed: onBack,
|
||||||
)
|
),
|
||||||
: null,
|
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(
|
child: SpacedColumn(
|
||||||
children: [
|
children: [
|
||||||
const Eyebrow('按当前水平推荐'),
|
Row(
|
||||||
Text('选一个场景,开口练习。', style: Theme.of(context).textTheme.headlineMedium),
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
Text(
|
children: [
|
||||||
'每轮 4 次回答,完成明确任务后结束。',
|
Text(
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
scene.title,
|
||||||
|
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
recommended ? 'A0 · 推荐' : 'A0',
|
||||||
|
style: const TextStyle(color: AppColors.green),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
SectionCard(
|
Text(scene.summary, style: Theme.of(context).textTheme.bodyMedium),
|
||||||
tint: AppColors.softGreen,
|
recommended
|
||||||
child: SpacedColumn(
|
? PrimaryButton(label: '开始对话', onPressed: onStart)
|
||||||
children: [
|
: SecondaryButton(label: '开始对话', onPressed: onStart),
|
||||||
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 · 后续版本开放'),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -94,10 +133,14 @@ class DialoguePage extends StatefulWidget {
|
|||||||
required this.state,
|
required this.state,
|
||||||
required this.onFinished,
|
required this.onFinished,
|
||||||
this.isLessonDialogue = false,
|
this.isLessonDialogue = false,
|
||||||
|
this.sceneId = 'a0-meet',
|
||||||
});
|
});
|
||||||
final AppState state;
|
final AppState state;
|
||||||
final ValueChanged<DialogueSummaryData?> onFinished;
|
final ValueChanged<DialogueSummaryData?> onFinished;
|
||||||
final bool isLessonDialogue;
|
final bool isLessonDialogue;
|
||||||
|
|
||||||
|
/// The free scene to run when this is not a lesson dialogue.
|
||||||
|
final String sceneId;
|
||||||
@override
|
@override
|
||||||
State<DialoguePage> createState() => _DialoguePageState();
|
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.
|
/// the dialogue ends: the spec forbids interrupting a beginner turn by turn.
|
||||||
String? latestFeedback;
|
String? latestFeedback;
|
||||||
|
|
||||||
/// The free scene stores its draft under its own id.
|
DialogueScene get _scene => sceneById(widget.sceneId);
|
||||||
static const _sceneDraftId = 'scene-a0-meet';
|
|
||||||
|
/// 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
|
/// Closing line for the turn after the last scripted prompt. It stays inside
|
||||||
/// taught A0 language instead of the old "Wonderful — nice meeting you!".
|
/// taught A0 language instead of the old "Wonderful — nice meeting you!".
|
||||||
@@ -143,7 +196,7 @@ class _DialoguePageState extends State<DialoguePage>
|
|||||||
.id,
|
.id,
|
||||||
widget.state.activeLessonId,
|
widget.state.activeLessonId,
|
||||||
)
|
)
|
||||||
: a0MeetDialogue;
|
: _scene.script;
|
||||||
|
|
||||||
/// Only exact sentence matches are trusted. The old positional fallback
|
/// Only exact sentence matches are trusted. The old positional fallback
|
||||||
/// attached `script.translations[stage]` to whatever the AI happened to say,
|
/// attached `script.translations[stage]` to whatever the AI happened to say,
|
||||||
@@ -286,8 +339,10 @@ class _DialoguePageState extends State<DialoguePage>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
widget.state.recordDialogueAttempt(
|
widget.state.recordDialogueAttempt(
|
||||||
taskId:
|
taskId: widget.isLessonDialogue
|
||||||
'dialogue-${widget.isLessonDialogue ? _lessonSegmentId : 'a0-meet'}-$stage',
|
? 'dialogue-$_lessonSegmentId-$stage'
|
||||||
|
: 'dialogue-scene-${_scene.id}-$stage',
|
||||||
|
sceneId: widget.isLessonDialogue ? null : _scene.id,
|
||||||
rawAnswer: text,
|
rawAnswer: text,
|
||||||
assisted: usedHelp,
|
assisted: usedHelp,
|
||||||
spoken: usedVoice && !transcriptEdited,
|
spoken: usedVoice && !transcriptEdited,
|
||||||
@@ -320,7 +375,10 @@ class _DialoguePageState extends State<DialoguePage>
|
|||||||
: 'nothing more, the conversation is finished',
|
: 'nothing more, the conversation is finished',
|
||||||
allowedLanguage: widget.isLessonDialogue
|
allowedLanguage: widget.isLessonDialogue
|
||||||
? taughtLanguageUpTo(widget.state.activeLessonId)
|
? 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
|
history: turns
|
||||||
.map(
|
.map(
|
||||||
(turn) => <String, String>{
|
(turn) => <String, String>{
|
||||||
@@ -409,7 +467,7 @@ class _DialoguePageState extends State<DialoguePage>
|
|||||||
final personalSentence = learnerTurns.isEmpty
|
final personalSentence = learnerTurns.isEmpty
|
||||||
? 'My name is …'
|
? 'My name is …'
|
||||||
: learnerTurns.first.text;
|
: learnerTurns.first.text;
|
||||||
widget.state.addDialogueRecap(personalSentence);
|
widget.state.addDialogueRecap(personalSentence, sceneId: _scene.id);
|
||||||
widget.onFinished(
|
widget.onFinished(
|
||||||
DialogueSummaryData(
|
DialogueSummaryData(
|
||||||
// Only the turns the learner actually passed are reported.
|
// Only the turns the learner actually passed are reported.
|
||||||
@@ -584,7 +642,7 @@ class _DialoguePageState extends State<DialoguePage>
|
|||||||
onPressed: () => widget.onFinished(null),
|
onPressed: () => widget.onFinished(null),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
'${widget.isLessonDialogue ? '课程对话' : '初次见面'} · ${finished ? totalStages : stage + 1} / $totalStages',
|
'${widget.isLessonDialogue ? '课程对话' : _scene.title} · ${finished ? totalStages : stage + 1} / $totalStages',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: SpacedColumn(
|
child: SpacedColumn(
|
||||||
|
|||||||
@@ -12,12 +12,16 @@ class HomePage extends StatelessWidget {
|
|||||||
required this.onStartPrimaryTask,
|
required this.onStartPrimaryTask,
|
||||||
required this.onOpenDialogue,
|
required this.onOpenDialogue,
|
||||||
required this.onResumeLessonDialogue,
|
required this.onResumeLessonDialogue,
|
||||||
|
required this.onStartReinforcement,
|
||||||
|
required this.onOpenAssessment,
|
||||||
});
|
});
|
||||||
|
|
||||||
final AppState state;
|
final AppState state;
|
||||||
final VoidCallback onStartPrimaryTask;
|
final VoidCallback onStartPrimaryTask;
|
||||||
final VoidCallback onOpenDialogue;
|
final VoidCallback onOpenDialogue;
|
||||||
final VoidCallback onResumeLessonDialogue;
|
final VoidCallback onResumeLessonDialogue;
|
||||||
|
final VoidCallback onStartReinforcement;
|
||||||
|
final ValueChanged<String> onOpenAssessment;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -26,16 +30,21 @@ class HomePage extends StatelessWidget {
|
|||||||
final activeLesson = lessonById(state.activeLessonId);
|
final activeLesson = lessonById(state.activeLessonId);
|
||||||
final activeSegment = state.activeSegmentIndexFor(activeLesson.id) + 1;
|
final activeSegment = state.activeSegmentIndexFor(activeLesson.id) + 1;
|
||||||
final resumeDialogue = state.hasResumableLessonDialogue;
|
final resumeDialogue = state.hasResumableLessonDialogue;
|
||||||
|
final stageStep = !resumeDialogue && !isReview && state.allLessonsComplete
|
||||||
|
? _stageStep()
|
||||||
|
: null;
|
||||||
final primaryTitle = resumeDialogue
|
final primaryTitle = resumeDialogue
|
||||||
? '继续第 ${activeLesson.number} 课的课程对话'
|
? '继续第 ${activeLesson.number} 课的课程对话'
|
||||||
: isReview
|
: isReview
|
||||||
? '先复习 $count 项'
|
? '先复习 $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
|
final primaryNote = resumeDialogue
|
||||||
? '已保留你的对话进度和提示状态。'
|
? '已保留你的对话进度和提示状态。'
|
||||||
: isReview
|
: isReview
|
||||||
? (state.reviewBacklog ? '有积压项目;先花几分钟清掉到期复习。' : '昨天练过的关键句,今天换个情境再用一次。')
|
? (state.reviewBacklog ? '有积压项目;先花几分钟清掉到期复习。' : '昨天练过的关键句,今天换个情境再用一次。')
|
||||||
: '预热词汇 → 听说读写 → 对话 → 独立尝试';
|
: stageStep?.note ?? '预热词汇 → 听说读写 → 对话 → 独立尝试';
|
||||||
|
final scene = state.recommendedScene;
|
||||||
|
|
||||||
return AppPage(
|
return AppPage(
|
||||||
child: SpacedColumn(
|
child: SpacedColumn(
|
||||||
@@ -80,7 +89,9 @@ class HomePage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
isReview ? '${(count * 2).clamp(2, 10)} 分钟' : '12 分钟',
|
isReview
|
||||||
|
? '${(count * 2).clamp(2, 10)} 分钟'
|
||||||
|
: stageStep?.minutes ?? '12 分钟',
|
||||||
style: const TextStyle(color: AppColors.green),
|
style: const TextStyle(color: AppColors.green),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -94,10 +105,10 @@ class HomePage extends StatelessWidget {
|
|||||||
? '继续对话'
|
? '继续对话'
|
||||||
: isReview
|
: isReview
|
||||||
? '开始复习'
|
? '开始复习'
|
||||||
: '开始今天的学习',
|
: stageStep?.action ?? '开始今天的学习',
|
||||||
onPressed: resumeDialogue
|
onPressed: resumeDialogue
|
||||||
? onResumeLessonDialogue
|
? onResumeLessonDialogue
|
||||||
: onStartPrimaryTask,
|
: stageStep?.onPressed ?? onStartPrimaryTask,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -110,7 +121,7 @@ class HomePage extends StatelessWidget {
|
|||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'初次见面 · 4 个任务 · 文字或语音输入',
|
'${scene.title} · ${scene.script.prompts.length} 个任务 · 文字或语音输入',
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
SecondaryButton(label: '开始情境对话', onPressed: onOpenDialogue),
|
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 {
|
class _FrameworkNote extends StatelessWidget {
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ class _LessonFlowState extends State<LessonFlow> {
|
|||||||
final writingController = TextEditingController();
|
final writingController = TextEditingController();
|
||||||
final independentController = TextEditingController();
|
final independentController = TextEditingController();
|
||||||
int selectedAnswer = -1;
|
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;
|
int previewIndex = 0;
|
||||||
bool listeningAudioPlayed = false;
|
bool listeningAudioPlayed = false;
|
||||||
bool showWritingHelp = true;
|
bool showWritingHelp = true;
|
||||||
@@ -108,7 +112,12 @@ class _LessonFlowState extends State<LessonFlow> {
|
|||||||
correctAnswer: activity.answers.first,
|
correctAnswer: activity.answers.first,
|
||||||
selectedAnswer: selectedAnswer,
|
selectedAnswer: selectedAnswer,
|
||||||
audioPlayed: listeningAudioPlayed,
|
audioPlayed: listeningAudioPlayed,
|
||||||
onSelected: (value) => setState(() => selectedAnswer = value),
|
onSelected: (value) => setState(() {
|
||||||
|
selectedAnswer = value;
|
||||||
|
if (listeningOptions[value] != activity.answers.first) {
|
||||||
|
listeningMissed = true;
|
||||||
|
}
|
||||||
|
}),
|
||||||
onPlayed: () => setState(() => listeningAudioPlayed = true),
|
onPlayed: () => setState(() => listeningAudioPlayed = true),
|
||||||
onLookup: () => showLexiconLookup(
|
onLookup: () => showLexiconLookup(
|
||||||
context,
|
context,
|
||||||
@@ -118,7 +127,8 @@ class _LessonFlowState extends State<LessonFlow> {
|
|||||||
onContinue:
|
onContinue:
|
||||||
selectedAnswer >= 0 &&
|
selectedAnswer >= 0 &&
|
||||||
listeningOptions[selectedAnswer] == activity.answers.first
|
listeningOptions[selectedAnswer] == activity.answers.first
|
||||||
? widget.state.completeListening
|
? () =>
|
||||||
|
widget.state.completeListening(recognized: !listeningMissed)
|
||||||
: null,
|
: null,
|
||||||
);
|
);
|
||||||
case LessonStep.speaking:
|
case LessonStep.speaking:
|
||||||
@@ -126,7 +136,7 @@ class _LessonFlowState extends State<LessonFlow> {
|
|||||||
state: widget.state,
|
state: widget.state,
|
||||||
text: activity.speaking,
|
text: activity.speaking,
|
||||||
keepRecording: widget.state.keepRecordings,
|
keepRecording: widget.state.keepRecordings,
|
||||||
onContinue: () => widget.state.completeSpeaking(assisted: true),
|
onContinue: widget.state.completeSpeaking,
|
||||||
);
|
);
|
||||||
case LessonStep.reading:
|
case LessonStep.reading:
|
||||||
content = _ReadingStep(
|
content = _ReadingStep(
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import 'package:flutter/material.dart';
|
|||||||
import '../../core/app_state.dart';
|
import '../../core/app_state.dart';
|
||||||
import '../../core/app_theme.dart';
|
import '../../core/app_theme.dart';
|
||||||
import '../../core/models.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/app_widgets.dart';
|
||||||
|
import '../../widgets/voice_answer.dart';
|
||||||
|
|
||||||
class WelcomePage extends StatefulWidget {
|
class WelcomePage extends StatefulWidget {
|
||||||
const WelcomePage({super.key, required this.state, required this.onContinue});
|
const WelcomePage({super.key, required this.state, required this.onContinue});
|
||||||
@@ -16,13 +20,11 @@ class WelcomePage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _WelcomePageState extends State<WelcomePage> {
|
class _WelcomePageState extends State<WelcomePage> {
|
||||||
late LearningGoal selectedGoal;
|
|
||||||
late int selectedMinutes;
|
late int selectedMinutes;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
selectedGoal = widget.state.goal;
|
|
||||||
selectedMinutes = widget.state.dailyMinutes;
|
selectedMinutes = widget.state.dailyMinutes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,16 +74,6 @@ class _WelcomePageState extends State<WelcomePage> {
|
|||||||
style: Theme.of(context).textTheme.headlineMedium,
|
style: Theme.of(context).textTheme.headlineMedium,
|
||||||
),
|
),
|
||||||
const Text('从真实生活场景开始,先做到听得懂、说得清。'),
|
const Text('从真实生活场景开始,先做到听得懂、说得清。'),
|
||||||
_ChoiceGroup<LearningGoal>(
|
|
||||||
title: '你的主要目标',
|
|
||||||
value: selectedGoal,
|
|
||||||
options: const {
|
|
||||||
LearningGoal.dailyLife: '日常生活',
|
|
||||||
LearningGoal.travel: '旅行',
|
|
||||||
LearningGoal.workStarter: '工作起步',
|
|
||||||
},
|
|
||||||
onChanged: (value) => setState(() => selectedGoal = value),
|
|
||||||
),
|
|
||||||
_ChoiceGroup<int>(
|
_ChoiceGroup<int>(
|
||||||
title: '每天学习多久?',
|
title: '每天学习多久?',
|
||||||
value: selectedMinutes,
|
value: selectedMinutes,
|
||||||
@@ -91,9 +83,7 @@ class _WelcomePageState extends State<WelcomePage> {
|
|||||||
PrimaryButton(
|
PrimaryButton(
|
||||||
label: '继续',
|
label: '继续',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
widget.state
|
widget.state.setDailyMinutes(selectedMinutes);
|
||||||
..setGoal(selectedGoal)
|
|
||||||
..setDailyMinutes(selectedMinutes);
|
|
||||||
widget.onContinue();
|
widget.onContinue();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -119,8 +109,25 @@ class PlacementPage extends StatefulWidget {
|
|||||||
State<PlacementPage> createState() => _PlacementPageState();
|
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;
|
late PlacementLevel selected;
|
||||||
|
var phase = _PlacementPhase.selfReport;
|
||||||
|
int listeningChoice = -1;
|
||||||
|
final answerController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppState get voiceState => widget.state;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -128,52 +135,287 @@ class _PlacementPageState extends State<PlacementPage> {
|
|||||||
selected = widget.state.placement;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
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 = {
|
const labels = {
|
||||||
PlacementLevel.beginner: ('完全零基础', '从你好、自我介绍开始'),
|
PlacementLevel.beginner: ('完全零基础', '从你好、自我介绍开始'),
|
||||||
PlacementLevel.someBasics: ('能说一点', '认识常见单词或短句'),
|
PlacementLevel.someBasics: ('能说一点', '认识常见单词或短句'),
|
||||||
PlacementLevel.simpleConversation: ('能简单对话', '想说得更自然、更有信心'),
|
PlacementLevel.simpleConversation: ('能简单对话', '想说得更自然、更有信心'),
|
||||||
};
|
};
|
||||||
return AppPage(
|
return SpacedColumn(
|
||||||
appBar: widget.onBack != null
|
spacing: 14,
|
||||||
? AppBar(
|
children: [
|
||||||
leading: IconButton(
|
const Eyebrow('第一步 · 约 3 分钟'),
|
||||||
icon: const Icon(Icons.arrow_back),
|
Text('从哪里开始?', style: Theme.of(context).textTheme.headlineMedium),
|
||||||
tooltip: "返回",
|
const Text('选择最接近的状态,之后随时能调整。'),
|
||||||
onPressed: widget.onBack,
|
for (final option in PlacementLevel.values)
|
||||||
),
|
_PlacementChoice(
|
||||||
title: const Text("基础定位"),
|
option: option,
|
||||||
)
|
labels: labels[option]!,
|
||||||
: null,
|
isSelected: selected == option,
|
||||||
child: SpacedColumn(
|
onTap: () => setState(() => selected = option),
|
||||||
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();
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
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(
|
Center(
|
||||||
child: TextButton(
|
child: TextButton(
|
||||||
onPressed: widget.onStart,
|
onPressed: () => _startFrom('a0-01'),
|
||||||
child: const Text('直接从第一课开始'),
|
child: const Text('从更简单内容开始'),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class _LearningShellState extends State<LearningShell> {
|
|||||||
var route = _ShellRoute.tab;
|
var route = _ShellRoute.tab;
|
||||||
_ShellRoute? previousRoute;
|
_ShellRoute? previousRoute;
|
||||||
bool dialogueInLesson = false;
|
bool dialogueInLesson = false;
|
||||||
|
String sceneId = 'a0-meet';
|
||||||
AssessmentPack? assessmentPack;
|
AssessmentPack? assessmentPack;
|
||||||
DialogueSummaryData? dialogueSummary;
|
DialogueSummaryData? dialogueSummary;
|
||||||
|
|
||||||
@@ -45,9 +46,10 @@ class _LearningShellState extends State<LearningShell> {
|
|||||||
route = _ShellRoute.scene;
|
route = _ShellRoute.scene;
|
||||||
});
|
});
|
||||||
|
|
||||||
void showDialogue({bool inLesson = false}) => setState(() {
|
void showDialogue({bool inLesson = false, String? scene}) => setState(() {
|
||||||
previousRoute = route;
|
previousRoute = route;
|
||||||
dialogueInLesson = inLesson;
|
dialogueInLesson = inLesson;
|
||||||
|
if (scene != null) sceneId = scene;
|
||||||
route = _ShellRoute.dialogue;
|
route = _ShellRoute.dialogue;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -125,13 +127,16 @@ class _LearningShellState extends State<LearningShell> {
|
|||||||
);
|
);
|
||||||
case _ShellRoute.scene:
|
case _ShellRoute.scene:
|
||||||
body = DialogueScenePage(
|
body = DialogueScenePage(
|
||||||
onStart: showDialogue,
|
state: widget.state,
|
||||||
|
onStart: (id) => showDialogue(scene: id),
|
||||||
onBack: () => showTab(tab),
|
onBack: () => showTab(tab),
|
||||||
);
|
);
|
||||||
case _ShellRoute.dialogue:
|
case _ShellRoute.dialogue:
|
||||||
body = DialoguePage(
|
body = DialoguePage(
|
||||||
|
key: ValueKey(dialogueInLesson ? 'lesson' : 'scene-$sceneId'),
|
||||||
state: widget.state,
|
state: widget.state,
|
||||||
isLessonDialogue: dialogueInLesson,
|
isLessonDialogue: dialogueInLesson,
|
||||||
|
sceneId: sceneId,
|
||||||
onFinished: (summary) {
|
onFinished: (summary) {
|
||||||
if (dialogueInLesson) {
|
if (dialogueInLesson) {
|
||||||
showLesson();
|
showLesson();
|
||||||
@@ -147,7 +152,7 @@ class _LearningShellState extends State<LearningShell> {
|
|||||||
summary: dialogueSummary!,
|
summary: dialogueSummary!,
|
||||||
onHome: () => showTab(AppTab.home),
|
onHome: () => showTab(AppTab.home),
|
||||||
onLesson: showLesson,
|
onLesson: showLesson,
|
||||||
onRetry: showDialogue,
|
onRetry: () => showDialogue(scene: sceneId),
|
||||||
onBack: () => showTab(tab),
|
onBack: () => showTab(tab),
|
||||||
);
|
);
|
||||||
case _ShellRoute.settings:
|
case _ShellRoute.settings:
|
||||||
@@ -194,7 +199,8 @@ class _LearningShellState extends State<LearningShell> {
|
|||||||
selectedIndex: tab.index,
|
selectedIndex: tab.index,
|
||||||
height: 70,
|
height: 70,
|
||||||
indicatorColor: AppColors.softGreen,
|
indicatorColor: AppColors.softGreen,
|
||||||
onDestinationSelected: (index) => showTab(AppTab.values[index]),
|
onDestinationSelected: (index) =>
|
||||||
|
showTab(AppTab.values[index]),
|
||||||
destinations: const [
|
destinations: const [
|
||||||
NavigationDestination(
|
NavigationDestination(
|
||||||
icon: Icon(Icons.home_outlined),
|
icon: Icon(Icons.home_outlined),
|
||||||
@@ -243,6 +249,16 @@ class _LearningShellState extends State<LearningShell> {
|
|||||||
},
|
},
|
||||||
onOpenDialogue: showDialogueScene,
|
onOpenDialogue: showDialogueScene,
|
||||||
onResumeLessonDialogue: () => showDialogue(inLesson: true),
|
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:
|
case AppTab.learn:
|
||||||
return _LearningMap(
|
return _LearningMap(
|
||||||
@@ -258,7 +274,10 @@ class _LearningShellState extends State<LearningShell> {
|
|||||||
state: widget.state,
|
state: widget.state,
|
||||||
);
|
);
|
||||||
case AppTab.dialogue:
|
case AppTab.dialogue:
|
||||||
return DialogueScenePage(onStart: showDialogue);
|
return DialogueScenePage(
|
||||||
|
state: widget.state,
|
||||||
|
onStart: (id) => showDialogue(scene: id),
|
||||||
|
);
|
||||||
case AppTab.review:
|
case AppTab.review:
|
||||||
return ReviewPage(
|
return ReviewPage(
|
||||||
state: widget.state,
|
state: widget.state,
|
||||||
|
|||||||
@@ -123,6 +123,17 @@ void main() {
|
|||||||
final state = AppState();
|
final state = AppState();
|
||||||
state.reviewQueue.add(dueItem());
|
state.reviewQueue.add(dueItem());
|
||||||
final item = state.reviewQueue.first;
|
final item = state.reviewQueue.first;
|
||||||
|
state.attemptEvidence.add(
|
||||||
|
AttemptEvidence(
|
||||||
|
id: 'recognized',
|
||||||
|
itemId: item.id,
|
||||||
|
taskId: 'lesson-a0-06-a-listening-check',
|
||||||
|
skill: '听辨识别',
|
||||||
|
inputMode: 'choice',
|
||||||
|
outcome: EvidenceKind.independentSuccess,
|
||||||
|
createdAt: DateTime.now().subtract(const Duration(days: 20)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
for (var index = 0; index < 4; index++) {
|
for (var index = 0; index < 4; index++) {
|
||||||
final current = state.reviewQueue.firstWhere(
|
final current = state.reviewQueue.firstWhere(
|
||||||
@@ -141,6 +152,13 @@ void main() {
|
|||||||
|
|
||||||
expect(state.mastery[item.id]!.checkpoint, 4);
|
expect(state.mastery[item.id]!.checkpoint, 4);
|
||||||
expect(state.mastery[item.id]!.status, MasteryStatus.master);
|
expect(state.mastery[item.id]!.status, MasteryStatus.master);
|
||||||
|
// Later checks rotate to other reviewed situations.
|
||||||
|
expect(
|
||||||
|
state.reviewQueue
|
||||||
|
.firstWhere((candidate) => candidate.id == item.id)
|
||||||
|
.variantIndex,
|
||||||
|
4,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
state.reviewQueue
|
state.reviewQueue
|
||||||
.firstWhere((candidate) => candidate.id == item.id)
|
.firstWhere((candidate) => candidate.id == item.id)
|
||||||
@@ -153,6 +171,17 @@ void main() {
|
|||||||
test('mastery can be rebuilt from spaced review evidence', () {
|
test('mastery can be rebuilt from spaced review evidence', () {
|
||||||
final state = AppState();
|
final state = AppState();
|
||||||
final base = DateTime.utc(2026, 9, 1);
|
final base = DateTime.utc(2026, 9, 1);
|
||||||
|
state.attemptEvidence.add(
|
||||||
|
AttemptEvidence(
|
||||||
|
id: 'recognized',
|
||||||
|
itemId: 'A0-P12',
|
||||||
|
taskId: 'lesson-a0-06-a-listening-check',
|
||||||
|
skill: '听辨识别',
|
||||||
|
inputMode: 'choice',
|
||||||
|
outcome: EvidenceKind.independentSuccess,
|
||||||
|
createdAt: base,
|
||||||
|
),
|
||||||
|
);
|
||||||
for (var index = 0; index < 4; index++) {
|
for (var index = 0; index < 4; index++) {
|
||||||
state.attemptEvidence.add(
|
state.attemptEvidence.add(
|
||||||
AttemptEvidence(
|
AttemptEvidence(
|
||||||
@@ -163,14 +192,75 @@ void main() {
|
|||||||
inputMode: 'text',
|
inputMode: 'text',
|
||||||
outcome: EvidenceKind.independentSuccess,
|
outcome: EvidenceKind.independentSuccess,
|
||||||
createdAt: base.add(Duration(days: index + 1)),
|
createdAt: base.add(Duration(days: index + 1)),
|
||||||
|
variantIndex: index,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.rebuildMasteryFromEvidence();
|
||||||
|
|
||||||
|
expect(state.mastery['A0-P12']!.checkpoint, 4);
|
||||||
|
expect(state.mastery['A0-P12']!.status, MasteryStatus.master);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('four checkpoints without every kind of evidence are not mastery', () {
|
||||||
|
final state = AppState();
|
||||||
|
final base = DateTime.utc(2026, 9, 1);
|
||||||
|
for (var index = 0; index < 4; index++) {
|
||||||
|
state.attemptEvidence.add(
|
||||||
|
AttemptEvidence(
|
||||||
|
id: 'same-context-$index',
|
||||||
|
itemId: 'A0-P12',
|
||||||
|
taskId: 'review-A0-P12',
|
||||||
|
skill: '口语表达',
|
||||||
|
inputMode: 'text',
|
||||||
|
outcome: EvidenceKind.independentSuccess,
|
||||||
|
createdAt: base.add(Duration(days: index + 1)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
state.rebuildMasteryFromEvidence();
|
state.rebuildMasteryFromEvidence();
|
||||||
|
|
||||||
|
// No recognition success and never used outside the original situation.
|
||||||
expect(state.mastery['A0-P12']!.checkpoint, 4);
|
expect(state.mastery['A0-P12']!.checkpoint, 4);
|
||||||
expect(state.mastery['A0-P12']!.status, MasteryStatus.master);
|
expect(state.mastery['A0-P12']!.status, MasteryStatus.recall);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a review success never lowers an existing status', () {
|
||||||
|
final state = AppState();
|
||||||
|
state.mastery['A0-P12'] = const MasteryItem(
|
||||||
|
id: 'A0-P12',
|
||||||
|
label: "I'm from [place].",
|
||||||
|
status: MasteryStatus.use,
|
||||||
|
evidence: [],
|
||||||
|
checkpoint: 1,
|
||||||
|
);
|
||||||
|
state.reviewQueue.add(dueItem());
|
||||||
|
|
||||||
|
state.completeReview(state.reviewQueue.single, assisted: false);
|
||||||
|
|
||||||
|
expect(state.mastery['A0-P12']!.status, MasteryStatus.use);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('review checkpoints follow days 1, 3, 7 and 14', () {
|
||||||
|
final state = AppState();
|
||||||
|
state.reviewQueue.add(dueItem());
|
||||||
|
final gaps = <int>[];
|
||||||
|
for (var index = 0; index < 3; index++) {
|
||||||
|
final current = state.reviewQueue.single;
|
||||||
|
state.reviewQueue[0] = current.copyWith(
|
||||||
|
dueAt: DateTime.now(),
|
||||||
|
lastProgressedAt: DateTime.now().subtract(const Duration(days: 1)),
|
||||||
|
);
|
||||||
|
state.completeReview(state.reviewQueue.single, assisted: false);
|
||||||
|
gaps.add(
|
||||||
|
state.reviewQueue.single.dueAt.difference(DateTime.now()).inHours ~/
|
||||||
|
24 +
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(gaps, [2, 4, 7]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
@@ -265,7 +355,7 @@ void main() {
|
|||||||
final state = AppState();
|
final state = AppState();
|
||||||
state.completePreview();
|
state.completePreview();
|
||||||
state.completeListening();
|
state.completeListening();
|
||||||
state.completeSpeaking(assisted: true);
|
state.completeSpeaking();
|
||||||
state.completeReading();
|
state.completeReading();
|
||||||
state.completeWriting(assisted: true);
|
state.completeWriting(assisted: true);
|
||||||
|
|
||||||
@@ -553,7 +643,9 @@ void main() {
|
|||||||
correct: true,
|
correct: true,
|
||||||
);
|
);
|
||||||
expect(state.attemptEvidence, hasLength(1));
|
expect(state.attemptEvidence, hasLength(1));
|
||||||
expect(state.mastery['A0-P12']!.status, MasteryStatus.recognize);
|
// An unaided adaptive answer is recall in a situation other than the
|
||||||
|
// lesson, which is what usable means.
|
||||||
|
expect(state.mastery['A0-P12']!.status, MasteryStatus.use);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
@@ -640,9 +732,51 @@ void main() {
|
|||||||
final speaking = state.attemptEvidence.where(
|
final speaking = state.attemptEvidence.where(
|
||||||
(entry) => entry.taskId == 'lesson-$segmentId-speaking',
|
(entry) => entry.taskId == 'lesson-$segmentId-speaking',
|
||||||
);
|
);
|
||||||
expect(speaking, hasLength(1));
|
// Follow-reading is assisted practice for every target, never
|
||||||
expect(speaking.single.itemId, targets.last);
|
// independent evidence for one of them.
|
||||||
expect(speaking.single.outcome, EvidenceKind.independentSuccess);
|
expect(speaking.map((entry) => entry.itemId), targets);
|
||||||
|
expect(
|
||||||
|
speaking.every((entry) => entry.outcome == EvidenceKind.assisted),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
|
||||||
|
state.completeReading();
|
||||||
|
state.completeWriting(rawAnswer: 'Hello. My name is Mia.');
|
||||||
|
final writing = state.attemptEvidence.where(
|
||||||
|
(entry) => entry.taskId == 'lesson-$segmentId-writing',
|
||||||
|
);
|
||||||
|
expect(writing.map((entry) => entry.itemId).toSet(), {'A0-W36', 'A0-P01'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a first-try listening choice is recognition evidence', () {
|
||||||
|
final state = AppState();
|
||||||
|
state.completePreview();
|
||||||
|
state.completeListening(recognized: true);
|
||||||
|
|
||||||
|
for (final id in lessonById('a0-01').targetItemIds) {
|
||||||
|
expect(state.mastery[id]!.status, MasteryStatus.recognize);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scene dialogue credits taught items the learner actually used', () {
|
||||||
|
final state = AppState();
|
||||||
|
state.completePreview();
|
||||||
|
state.completeListening();
|
||||||
|
|
||||||
|
state.recordDialogueAttempt(
|
||||||
|
taskId: 'dialogue-scene-a0-meet-0',
|
||||||
|
rawAnswer: 'Hi! My name is Mia.',
|
||||||
|
assisted: false,
|
||||||
|
sceneId: 'a0-meet',
|
||||||
|
);
|
||||||
|
|
||||||
|
final used = state.attemptEvidence
|
||||||
|
.where((entry) => entry.taskId == 'dialogue-scene-a0-meet-0')
|
||||||
|
.map((entry) => entry.itemId)
|
||||||
|
.toSet();
|
||||||
|
expect(used, {'A0-W37', 'A0-P01'});
|
||||||
|
expect(state.mastery['A0-P01']!.status, MasteryStatus.use);
|
||||||
|
expect(state.mastery['A0-P03']!.status, MasteryStatus.newItem);
|
||||||
});
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
@@ -659,13 +793,17 @@ void main() {
|
|||||||
assisted: false,
|
assisted: false,
|
||||||
rawAnswer: 'Hello. I am Mia.',
|
rawAnswer: 'Hello. I am Mia.',
|
||||||
);
|
);
|
||||||
final primary = lessonById('a0-01').targetItemIds.last;
|
const introduced = 'A0-P01';
|
||||||
|
|
||||||
state.mastery.clear();
|
state.mastery.clear();
|
||||||
state.rebuildMasteryFromEvidence();
|
state.rebuildMasteryFromEvidence();
|
||||||
|
|
||||||
expect(state.mastery[primary]!.firstTaughtAt, isNotNull);
|
expect(state.mastery[introduced]!.firstTaughtAt, isNotNull);
|
||||||
expect(state.mastery[primary]!.status, MasteryStatus.use);
|
// Recalled inside the lesson, not yet used in another situation.
|
||||||
|
expect(state.mastery[introduced]!.status, MasteryStatus.recall);
|
||||||
|
// The answer never said "Nice to meet you", so that target gets no
|
||||||
|
// production evidence from it.
|
||||||
|
expect(state.mastery['A0-P03']!.status, MasteryStatus.newItem);
|
||||||
expect(
|
expect(
|
||||||
state.mastery[lessonById('a0-01').targetItemIds.first]!.firstTaughtAt,
|
state.mastery[lessonById('a0-01').targetItemIds.first]!.firstTaughtAt,
|
||||||
isNotNull,
|
isNotNull,
|
||||||
@@ -770,6 +908,79 @@ void main() {
|
|||||||
expect(matchesSegmentIndependent('a0-08-b', 'It is Monday.'), isFalse);
|
expect(matchesSegmentIndependent('a0-08-b', 'It is Monday.'), isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('single-segment independent attempts reject off-task answers', () {
|
||||||
|
const offTask = 'abc';
|
||||||
|
for (final lessonId in [
|
||||||
|
'a0-01',
|
||||||
|
'a0-02',
|
||||||
|
'a0-03',
|
||||||
|
'a0-05',
|
||||||
|
'a0-06',
|
||||||
|
'a0-07',
|
||||||
|
'a0-09',
|
||||||
|
'a0-10',
|
||||||
|
]) {
|
||||||
|
expect(
|
||||||
|
matchesSegmentIndependent('$lessonId-a', offTask),
|
||||||
|
isFalse,
|
||||||
|
reason: lessonId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
matchesSegmentIndependent('a0-01-a', "Hi, I'm Mia. Nice to meet you."),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(matchesSegmentIndependent('a0-01-a', "Hi, I'm Mia."), isFalse);
|
||||||
|
expect(matchesSegmentIndependent('a0-05-a', "It's a pen."), isTrue);
|
||||||
|
expect(
|
||||||
|
matchesSegmentIndependent(
|
||||||
|
'a0-06-a',
|
||||||
|
"I'm from Guangzhou. Where are you from?",
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
matchesSegmentIndependent('a0-06-a', "I'm from Guangzhou."),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
matchesSegmentIndependent(
|
||||||
|
'a0-10-a',
|
||||||
|
"I'm Mia. I'm from Guangzhou. I like tea. Do you like tea?",
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each free scene keeps its own daily recap', () {
|
||||||
|
final state = AppState();
|
||||||
|
|
||||||
|
state.addDialogueRecap('My name is Alex.');
|
||||||
|
state.addDialogueRecap('It is Monday.', sceneId: 'a0-plan');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
state.reviewQueue.where(
|
||||||
|
(item) => item.id.startsWith('dialogue-a0-meet-'),
|
||||||
|
),
|
||||||
|
hasLength(1),
|
||||||
|
);
|
||||||
|
final plan = state.reviewQueue.singleWhere(
|
||||||
|
(item) => item.id.startsWith('dialogue-a0-plan-'),
|
||||||
|
);
|
||||||
|
expect(plan.prompt, sceneById('a0-plan').recapPrompt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scenes open with their lessons and rotate the recommendation', () {
|
||||||
|
final state = AppState();
|
||||||
|
expect(a0Scenes.where(state.isSceneUnlocked).map((scene) => scene.id), [
|
||||||
|
'a0-meet',
|
||||||
|
]);
|
||||||
|
state.completedLessonIds.addAll(['a0-01', 'a0-02', 'a0-03', 'a0-04']);
|
||||||
|
expect(state.recommendedScene.id, 'a0-number');
|
||||||
|
state.addDialogueRecap('My number is one two three.', sceneId: 'a0-number');
|
||||||
|
expect(state.recommendedScene.id, 'a0-meet');
|
||||||
|
});
|
||||||
|
|
||||||
test('completed independent dialogue creates one daily non-core recap', () {
|
test('completed independent dialogue creates one daily non-core recap', () {
|
||||||
final state = AppState();
|
final state = AppState();
|
||||||
|
|
||||||
@@ -797,7 +1008,7 @@ void main() {
|
|||||||
for (var segment = 0; segment < lesson.segments.length; segment++) {
|
for (var segment = 0; segment < lesson.segments.length; segment++) {
|
||||||
state.completePreview();
|
state.completePreview();
|
||||||
state.completeListening();
|
state.completeListening();
|
||||||
state.completeSpeaking(assisted: true);
|
state.completeSpeaking();
|
||||||
state.completeReading();
|
state.completeReading();
|
||||||
state.completeWriting(assisted: true);
|
state.completeWriting(assisted: true);
|
||||||
state.completeLessonDialogue();
|
state.completeLessonDialogue();
|
||||||
@@ -893,10 +1104,7 @@ void main() {
|
|||||||
checkOpenAssessmentAnswer(speaking[6], 'Please speak slowly.'),
|
checkOpenAssessmentAnswer(speaking[6], 'Please speak slowly.'),
|
||||||
isTrue,
|
isTrue,
|
||||||
);
|
);
|
||||||
expect(
|
expect(checkOpenAssessmentAnswer(speaking[6], 'Repeat, please.'), isTrue);
|
||||||
checkOpenAssessmentAnswer(speaking[6], 'Repeat, please.'),
|
|
||||||
isTrue,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('finishLesson advances activeLessonId to next available lesson', () {
|
test('finishLesson advances activeLessonId to next available lesson', () {
|
||||||
@@ -904,7 +1112,7 @@ void main() {
|
|||||||
expect(state.activeLessonId, 'a0-01');
|
expect(state.activeLessonId, 'a0-01');
|
||||||
state.completePreview();
|
state.completePreview();
|
||||||
state.completeListening();
|
state.completeListening();
|
||||||
state.completeSpeaking(assisted: false);
|
state.completeSpeaking();
|
||||||
state.completeReading();
|
state.completeReading();
|
||||||
state.completeWriting(assisted: false);
|
state.completeWriting(assisted: false);
|
||||||
state.completeLessonDialogue();
|
state.completeLessonDialogue();
|
||||||
@@ -916,6 +1124,68 @@ void main() {
|
|||||||
expect(state.activeLessonId, 'a0-02');
|
expect(state.activeLessonId, 'a0-02');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void finishActiveLesson(AppState state) {
|
||||||
|
state.completePreview();
|
||||||
|
state.completeListening();
|
||||||
|
state.completeSpeaking();
|
||||||
|
state.completeReading();
|
||||||
|
state.completeWriting(assisted: true);
|
||||||
|
state.completeLessonDialogue();
|
||||||
|
state.completeIndependentAttempt(assisted: true);
|
||||||
|
state.finishLesson();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('switching lessons clears step flags from the previous lesson', () {
|
||||||
|
final state = AppState();
|
||||||
|
state.completePreview();
|
||||||
|
state.completeListening();
|
||||||
|
state.completeSpeaking();
|
||||||
|
state.completeReading();
|
||||||
|
state.completeWriting(assisted: true);
|
||||||
|
state.completeLessonDialogue();
|
||||||
|
state.completeIndependentAttempt(assisted: true);
|
||||||
|
state.completedLessonIds.add('a0-01');
|
||||||
|
|
||||||
|
state.openLesson('a0-02');
|
||||||
|
|
||||||
|
expect(state.lessonCanComplete, isFalse);
|
||||||
|
expect(state.lessonListeningComplete, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('placement opens its start lesson without passing earlier ones', () {
|
||||||
|
final state = AppState();
|
||||||
|
state.setPlacementStartLesson('a0-02');
|
||||||
|
|
||||||
|
expect(state.activeLessonId, 'a0-02');
|
||||||
|
expect(state.isLessonUnlocked('a0-01'), isTrue);
|
||||||
|
expect(state.isLessonUnlocked('a0-02'), isTrue);
|
||||||
|
expect(state.isLessonUnlocked('a0-03'), isFalse);
|
||||||
|
expect(state.completedLessonIds, isEmpty);
|
||||||
|
|
||||||
|
finishActiveLesson(state);
|
||||||
|
expect(state.activeLessonId, 'a0-03');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('after the last lesson the learner is sent to unfinished lessons', () {
|
||||||
|
final state = AppState();
|
||||||
|
state.setPlacementStartLesson('a0-02');
|
||||||
|
for (var index = 1; index < a0SeedLessons.length; index++) {
|
||||||
|
final lesson = a0SeedLessons[index];
|
||||||
|
state.openLesson(lesson.id);
|
||||||
|
for (var segment = 0; segment < lesson.segments.length; segment++) {
|
||||||
|
state.completeSegment(lesson.id, segment);
|
||||||
|
}
|
||||||
|
finishActiveLesson(state);
|
||||||
|
}
|
||||||
|
expect(state.activeLessonId, 'a0-01');
|
||||||
|
expect(state.allLessonsComplete, isFalse);
|
||||||
|
|
||||||
|
finishActiveLesson(state);
|
||||||
|
expect(state.allLessonsComplete, isTrue);
|
||||||
|
expect(state.a0AssessmentReady, isFalse);
|
||||||
|
expect(state.nextAssessmentPackId, 'A0-E1');
|
||||||
|
});
|
||||||
|
|
||||||
test(
|
test(
|
||||||
'a passed assessment skill remains valid during the seven-day retest window',
|
'a passed assessment skill remains valid during the seven-day retest window',
|
||||||
() {
|
() {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ void main() {
|
|||||||
final body = await captureBody('https://api.deepseek.com');
|
final body = await captureBody('https://api.deepseek.com');
|
||||||
|
|
||||||
expect(body['thinking'], {'type': 'disabled'});
|
expect(body['thinking'], {'type': 'disabled'});
|
||||||
|
expect(body['response_format'], {'type': 'json_object'});
|
||||||
expect(body.containsKey('reasoning_effort'), isFalse);
|
expect(body.containsKey('reasoning_effort'), isFalse);
|
||||||
expect(body['max_tokens'], 200);
|
expect(body['max_tokens'], 200);
|
||||||
});
|
});
|
||||||
@@ -51,6 +52,9 @@ void main() {
|
|||||||
final body = await captureBody('https://api.deepseek.com/v1/responses');
|
final body = await captureBody('https://api.deepseek.com/v1/responses');
|
||||||
|
|
||||||
expect(body['reasoning'], {'effort': 'none'});
|
expect(body['reasoning'], {'effort': 'none'});
|
||||||
|
expect(body['text'], {
|
||||||
|
'format': {'type': 'json_object'},
|
||||||
|
});
|
||||||
expect(body.containsKey('reasoning_effort'), isFalse);
|
expect(body.containsKey('reasoning_effort'), isFalse);
|
||||||
expect(body.containsKey('thinking'), isFalse);
|
expect(body.containsKey('thinking'), isFalse);
|
||||||
});
|
});
|
||||||
@@ -59,6 +63,48 @@ void main() {
|
|||||||
final body = await captureBody('https://example.test/v1');
|
final body = await captureBody('https://example.test/v1');
|
||||||
|
|
||||||
expect(body.containsKey('thinking'), isFalse);
|
expect(body.containsKey('thinking'), isFalse);
|
||||||
|
expect(body.containsKey('response_format'), isFalse);
|
||||||
expect(body['reasoning_effort'], 'low');
|
expect(body['reasoning_effort'], 'low');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('dialogue history replays earlier AI lines as JSON', () async {
|
||||||
|
AiService.instance.setFallbackApiKey('test-key');
|
||||||
|
List<dynamic>? messages;
|
||||||
|
final reply = await http.runWithClient(
|
||||||
|
() => AiService.instance.dialogueReply(
|
||||||
|
provider: AiProviderType.compatible,
|
||||||
|
endpoint: 'https://api.deepseek.com',
|
||||||
|
model: 'deepseek-flash',
|
||||||
|
aiGoal: 'Ask where the learner is from.',
|
||||||
|
learnerTask: 'say where they are from',
|
||||||
|
history: const [
|
||||||
|
{'role': 'assistant', 'content': "Hi! What's your name?"},
|
||||||
|
{'role': 'user', 'content': 'My name is Alex.'},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
() => MockClient((request) async {
|
||||||
|
final body = jsonDecode(request.body) as Map<String, dynamic>;
|
||||||
|
messages = body['messages'] as List<dynamic>;
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'choices': [
|
||||||
|
{
|
||||||
|
'message': {'content': '{"reply":"Where are you from?"}'},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
headers: {'content-type': 'application/json; charset=utf-8'},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reply?.reply, 'Where are you from?');
|
||||||
|
expect(messages, hasLength(3));
|
||||||
|
expect(messages![0]['role'], 'system');
|
||||||
|
expect(jsonDecode(messages![1]['content'] as String), {
|
||||||
|
'reply': "Hi! What's your name?",
|
||||||
|
});
|
||||||
|
expect(messages![2], {'role': 'user', 'content': 'My name is Alex.'});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ void main() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
expect(a0MeetDialogue.taskLabels.length, a0MeetDialogue.prompts.length);
|
expect(a0MeetDialogue.taskLabels.length, a0MeetDialogue.prompts.length);
|
||||||
expect(a0MeetDialogue.requiredTerms.length, a0MeetDialogue.prompts.length);
|
expect(
|
||||||
|
a0MeetDialogue.requiredTerms.length,
|
||||||
|
a0MeetDialogue.prompts.length,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('示范答案能通过对应回合的校验', () {
|
test('示范答案能通过对应回合的校验', () {
|
||||||
@@ -46,37 +49,101 @@ void main() {
|
|||||||
|
|
||||||
test('跑题回答不再因为关键词沾边而通过', () {
|
test('跑题回答不再因为关键词沾边而通过', () {
|
||||||
// 旧实现用 text.contains('it'),下面这些句子全部会被判为完成任务。
|
// 旧实现用 text.contains('it'),下面这些句子全部会被判为完成任务。
|
||||||
expect(matchesDialogueStage(a0Dialogues['a0-05']!, 0, 'I did it.'), isFalse);
|
expect(
|
||||||
expect(matchesDialogueStage(a0Dialogues['a0-08']!, 0, 'It is good.'), isFalse);
|
matchesDialogueStage(a0Dialogues['a0-05']!, 0, 'I did it.'),
|
||||||
expect(matchesDialogueStage(a0Dialogues['a0-09']!, 2, 'I like tea.'), isFalse);
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
matchesDialogueStage(a0Dialogues['a0-08']!, 0, 'It is good.'),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
matchesDialogueStage(a0Dialogues['a0-09']!, 2, 'I like tea.'),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
expect(matchesDialogueStage(a0MeetDialogue, 0, 'Hello.'), isFalse);
|
expect(matchesDialogueStage(a0MeetDialogue, 0, 'Hello.'), isFalse);
|
||||||
expect(matchesDialogueStage(a0MeetDialogue, 1, 'I am good.'), isFalse);
|
expect(matchesDialogueStage(a0MeetDialogue, 1, 'I am good.'), isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('自由场景接受同样正确的其他说法', () {
|
test('自由场景接受同样正确的其他说法', () {
|
||||||
// 旧正则只认 good/okay/tired,也不认 my name's。
|
// 旧正则只认 good/okay/tired,也不认 my name's。
|
||||||
expect(matchesDialogueStage(a0MeetDialogue, 0, "My name's Shen."), isTrue);
|
expect(
|
||||||
|
matchesDialogueStage(a0MeetDialogue, 0, "My name's Shen."),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
expect(matchesDialogueStage(a0MeetDialogue, 0, 'I am Shen.'), isTrue);
|
expect(matchesDialogueStage(a0MeetDialogue, 0, 'I am Shen.'), isTrue);
|
||||||
expect(matchesDialogueStage(a0MeetDialogue, 2, "I'm fine, thanks."), isTrue);
|
expect(
|
||||||
|
matchesDialogueStage(a0MeetDialogue, 2, "I'm fine, thanks."),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
expect(matchesDialogueStage(a0MeetDialogue, 2, 'I am great!'), isTrue);
|
expect(matchesDialogueStage(a0MeetDialogue, 2, 'I am great!'), isTrue);
|
||||||
expect(matchesDialogueStage(a0MeetDialogue, 3, 'Where are you from?'), isTrue);
|
expect(
|
||||||
expect(matchesDialogueStage(a0MeetDialogue, 3, 'Do you like tea?'), isTrue);
|
matchesDialogueStage(a0MeetDialogue, 3, 'Where are you from?'),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
matchesDialogueStage(a0MeetDialogue, 3, 'Do you like tea?'),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('只写半句不算完成任务', () {
|
test('只写半句不算完成任务', () {
|
||||||
expect(matchesDialogueStage(a0MeetDialogue, 0, "I'm"), isFalse);
|
expect(matchesDialogueStage(a0MeetDialogue, 0, "I'm"), isFalse);
|
||||||
expect(matchesDialogueStage(a0Dialogues['a0-04']!, 0, 'My number is'), isFalse);
|
|
||||||
expect(
|
expect(
|
||||||
matchesDialogueStage(a0Dialogues['a0-04']!, 0, 'My number is one-three-eight.'),
|
matchesDialogueStage(a0Dialogues['a0-04']!, 0, 'My number is'),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
matchesDialogueStage(
|
||||||
|
a0Dialogues['a0-04']!,
|
||||||
|
0,
|
||||||
|
'My number is one-three-eight.',
|
||||||
|
),
|
||||||
isTrue,
|
isTrue,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('拼读和数字这类结构化要求可以识别', () {
|
test('拼读和数字这类结构化要求可以识别', () {
|
||||||
expect(matchesDialogueStage(a0Dialogues['a0-02']!, 1, 'S-H-E-N'), isTrue);
|
expect(matchesDialogueStage(a0Dialogues['a0-02']!, 1, 'S-H-E-N'), isTrue);
|
||||||
expect(matchesDialogueStage(a0Dialogues['a0-02']!, 1, 'Shen'), isFalse);
|
});
|
||||||
expect(matchesDialogueStage(a0Dialogues['a0-08']!, 2, "It's three o'clock."), isTrue);
|
|
||||||
expect(matchesDialogueStage(a0Dialogues['a0-08']!, 2, "It's Monday."), isFalse);
|
test('拼读忽略大小写和连字符', () {
|
||||||
|
final spelling = a0Dialogues['a0-02']!;
|
||||||
|
for (final answer in [
|
||||||
|
'A-A-A',
|
||||||
|
'aaa',
|
||||||
|
'AAA',
|
||||||
|
'a-a-a',
|
||||||
|
'A - A - A',
|
||||||
|
'a a a',
|
||||||
|
'Shen',
|
||||||
|
'S-H-E-N.',
|
||||||
|
'She-n',
|
||||||
|
'L-I',
|
||||||
|
'Li',
|
||||||
|
'My name is Alex. A-L-E-X.',
|
||||||
|
]) {
|
||||||
|
expect(
|
||||||
|
matchesDialogueStage(spelling, 1, answer),
|
||||||
|
isTrue,
|
||||||
|
reason: answer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (final answer in ['a', 'My name is Alex.', '123', '---']) {
|
||||||
|
expect(
|
||||||
|
matchesDialogueStage(spelling, 1, answer),
|
||||||
|
isFalse,
|
||||||
|
reason: answer,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
matchesDialogueStage(a0Dialogues['a0-08']!, 2, "It's three o'clock."),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
matchesDialogueStage(a0Dialogues['a0-08']!, 2, "It's Monday."),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('任务标签可用于提示和总结', () {
|
test('任务标签可用于提示和总结', () {
|
||||||
@@ -86,7 +153,11 @@ void main() {
|
|||||||
|
|
||||||
test('输入校验忽略多余空格与大小写', () {
|
test('输入校验忽略多余空格与大小写', () {
|
||||||
expect(
|
expect(
|
||||||
matchesDialogueStage(a0MeetDialogue, 0, " MY NAME IS SHEN "),
|
matchesDialogueStage(
|
||||||
|
a0MeetDialogue,
|
||||||
|
0,
|
||||||
|
" MY NAME IS SHEN ",
|
||||||
|
),
|
||||||
isTrue,
|
isTrue,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
@@ -94,11 +165,19 @@ void main() {
|
|||||||
isTrue,
|
isTrue,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
matchesDialogueStage(a0Dialogues["a0-04"]!, 0, "MY NUMBER IS ONE-THREE-EIGHT"),
|
matchesDialogueStage(
|
||||||
|
a0Dialogues["a0-04"]!,
|
||||||
|
0,
|
||||||
|
"MY NUMBER IS ONE-THREE-EIGHT",
|
||||||
|
),
|
||||||
isTrue,
|
isTrue,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
matchesDialogueStage(a0Dialogues["a0-08"]!, 2, "IT'S THREE O'CLOCK"),
|
matchesDialogueStage(
|
||||||
|
a0Dialogues["a0-08"]!,
|
||||||
|
2,
|
||||||
|
"IT'S THREE O'CLOCK",
|
||||||
|
),
|
||||||
isTrue,
|
isTrue,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -110,7 +189,10 @@ void main() {
|
|||||||
expect(later.length, greaterThan(first.length));
|
expect(later.length, greaterThan(first.length));
|
||||||
expect(first.every(later.contains), isTrue);
|
expect(first.every(later.contains), isTrue);
|
||||||
expect(later.length, lessThanOrEqualTo(allTaughtLanguage.length));
|
expect(later.length, lessThanOrEqualTo(allTaughtLanguage.length));
|
||||||
expect(first.any((word) => word.toLowerCase().contains('coffee')), isFalse);
|
expect(
|
||||||
|
first.any((word) => word.toLowerCase().contains('coffee')),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:kouyu_english/core/app_state.dart';
|
||||||
|
import 'package:kouyu_english/features/dialogue/dialogue_flow.dart';
|
||||||
|
import 'package:kouyu_english/features/onboarding/onboarding_pages.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('three-question placement suggests a starting lesson', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
final state = AppState();
|
||||||
|
var started = false;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: PlacementPage(state: state, onStart: () => started = true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.text('开始 3 分钟定位'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('定位 · 1 / 3'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('你好,我叫 Mia,很高兴认识你。'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('下一题'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('定位 · 2 / 3'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('现在不方便开口,跳过'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('定位 · 3 / 3'), findsOneWidget);
|
||||||
|
await tester.enterText(find.byType(TextField), 'My name is Mia.');
|
||||||
|
await tester.tap(find.text('查看建议'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('建议从 A0 第 2 课开始'), findsOneWidget);
|
||||||
|
expect(find.text('从更简单内容开始'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('开始今天学习'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(started, isTrue);
|
||||||
|
expect(state.activeLessonId, 'a0-02');
|
||||||
|
expect(state.placementStartLessonId, 'a0-02');
|
||||||
|
expect(state.completedLessonIds, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('an unanswered placement starts from lesson one', (tester) async {
|
||||||
|
final state = AppState();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: PlacementPage(state: state, onStart: () {}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.tap(find.text('开始 3 分钟定位'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('听不懂,下一题'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('我读完了'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('还不会说,查看建议'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('建议从 A0 第 1 课开始'), findsOneWidget);
|
||||||
|
expect(find.text('从更简单内容开始'), findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('scene page lists open and locked scenes', (tester) async {
|
||||||
|
final state = AppState()
|
||||||
|
..completedLessonIds.addAll(['a0-01', 'a0-02', 'a0-03', 'a0-04']);
|
||||||
|
String? startedScene;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: DialogueScenePage(
|
||||||
|
state: state,
|
||||||
|
onStart: (id) => startedScene = id,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(find.text('初次见面'), findsOneWidget);
|
||||||
|
expect(find.text('留个电话'), findsOneWidget);
|
||||||
|
expect(find.text('A0 · 学完第 7 课后开放'), findsOneWidget);
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '开始对话').first);
|
||||||
|
expect(startedScene, isNotNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user