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

This commit is contained in:
shenlei
2026-09-18 17:55:44 +09:00
parent febfd30f49
commit 0a4f97d105
154 changed files with 12366 additions and 4784 deletions
@@ -0,0 +1,81 @@
import 'answer_rules.dart';
import 'core_items.dart';
import 'course_catalog.dart';
import 'course_models.dart';
/// A reply that is at least one real word; turns without a declared rule
/// accept it.
bool _isRealReply(String text) => RegExp(r'[a-z]{2,}').hasMatch(text);
/// Whether [input] actually contains core item [id], by the item's `match`
/// rule. 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) {
final rule = coreItemMatch[id];
if (rule == null || rule.isEmpty) return false;
return matchesRule(input, rule);
}
bool matchesSegmentDialogue(String segmentId, int stage, String response) {
final segment = findLessonSegment(segmentId);
if (segment == null || stage >= segment.dialogueRequiredTerms.length) {
return false;
}
final text = normalizeAnswer(response);
final terms = segment.dialogueRequiredTerms[stage];
// A turn that declares no reviewed term (adapted units) just needs a real
// reply, matching how free-scene turns are validated.
if (terms.isEmpty) return _isRealReply(text);
return groupSatisfied(text, terms);
}
bool matchesSegmentIndependent(String segmentId, String response) {
final rule = findLessonSegment(segmentId)?.independentRequiredTerms;
if (rule == null) return false;
// A segment without a reviewed rule accepts any real attempt.
if (rule.isEmpty) return _isRealReply(normalizeAnswer(response));
return matchesRule(response, rule);
}
/// Explains in Chinese what an independent attempt still lacks, or returns
/// null when [matchesSegmentIndependent] accepts it.
String? independentShortfall(String segmentId, String response) {
if (matchesSegmentIndependent(segmentId, response)) return null;
final rule = findLessonSegment(segmentId)?.independentRequiredTerms;
if (rule == null) return '这次还没有用上本段要练的内容。查看帮助后补充一次。';
if (rule.isEmpty) return '请写一个完整的英文句子。';
final text = normalizeAnswer(response);
if (rule.length == 1) {
final group = rule.single;
final minimum = groupMinimum(group);
final terms = groupTerms(group);
if (minimum != null) {
return '需要用上至少 $minimum 个本段的表达(${terms.take(6).join(' / ')}),'
'目前用上 ${groupTermsUsed(text, group)} 个。';
}
return '需要用上本段的表达之一:${terms.take(4).join(' / ')}。';
}
final missing = rule
.where((group) => !groupSatisfied(text, group))
.map(termGroupLabel);
return '还缺:${missing.join(';')}。';
}
/// Whole-lesson and free-scene dialogues validate the same way segment
/// dialogues do: the turn has to contain the language the turn is teaching.
bool matchesDialogueStage(LessonDialogue script, int stage, String response) {
final text = normalizeAnswer(response);
if (stage < 0 || stage >= script.requiredTerms.length) {
// No declared requirement: accept anything that is actually a word.
return _isRealReply(text);
}
final terms = script.requiredTerms[stage];
if (terms.isEmpty) return _isRealReply(text);
return groupSatisfied(text, terms);
}
String dialogueTaskLabel(LessonDialogue script, int stage) =>
stage >= 0 && stage < script.taskLabels.length
? script.taskLabels[stage]
: '完成本轮任务';
@@ -0,0 +1,174 @@
/// The one rule language every offline answer check uses: lesson writing,
/// independent attempts, controlled dialogues and core-item reviews. Rules
/// live in the course JSON; this file only interprets them.
///
/// A rule is a list of groups. Every group must be satisfied (AND); a group
/// is satisfied when any of its terms appears (OR). A group that starts with
/// `#min:N` instead needs N different terms of the group.
///
/// Terms:
/// * `text` — whole words, after [normalizeAnswer] on both sides, so
/// "I'm" and "I am" are the same term.
/// * `a + b` — every part has to appear.
/// * `/regex/` — a regular expression over the normalized answer.
/// * `#spelling`, `#digit`, `#word`, `#question` — structures, see
/// [_matchesStructure]; `#numbers:N` needs N English number words and
/// `#words:N` needs N different words.
library;
/// Lowercases, unifies quotes and spaces, and expands contractions, so an
/// answer and a rule term compare the same way however either was typed.
String normalizeAnswer(String input) {
var text = input
.toLowerCase()
.replaceAll('’', "'")
.replaceAll('‘', "'")
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
for (final (pattern, replacement) in _contractions) {
text = text.replaceAll(pattern, replacement);
}
return text;
}
final List<(RegExp, String)> _contractions = [
(RegExp(r"\bwon't\b"), 'will not'),
(RegExp(r"\bcan't\b"), 'can not'),
(RegExp(r'\bcannot\b'), 'can not'),
(RegExp(r"n't\b"), ' not'),
(RegExp(r"'m\b"), ' am'),
(RegExp(r"'re\b"), ' are'),
(RegExp(r"'s\b"), ' is'),
(RegExp(r"'ll\b"), ' will'),
(RegExp(r"'ve\b"), ' have'),
(RegExp(r"'d\b"), ' would'),
];
/// Whether [input] satisfies every group of [rule].
bool matchesRule(String input, List<List<String>> rule) {
final text = normalizeAnswer(input);
return rule.every((group) => groupSatisfied(text, group));
}
/// Whether the normalized [text] satisfies [group].
bool groupSatisfied(String text, List<String> group) {
final minimum = groupMinimum(group);
if (minimum == null) return group.any((term) => containsTerm(text, term));
return groupTermsUsed(text, group) >= minimum;
}
/// N for a `#min:N` group, otherwise null.
int? groupMinimum(List<String> group) => group.isNotEmpty
? int.tryParse(
RegExp(r'^#min:(\d+)$').firstMatch(group.first)?.group(1) ?? '',
)
: null;
/// The terms of [group] a learner can pick from, without a `#min:N` marker.
List<String> groupTerms(List<String> group) =>
groupMinimum(group) == null ? group : group.sublist(1);
/// How many different terms of [group] appear in the normalized [text].
int groupTermsUsed(String text, List<String> group) =>
groupTerms(group).where((term) => containsTerm(text, term)).length;
/// Whether the normalized [text] contains [term] (see the library docs).
bool containsTerm(String text, String term) {
if (term.contains(' + ')) {
// Structures are checked on what is left after the literal parts, so in
// "i'm + #word" the "am" of the frame is not taken for the name.
var rest = text;
final parts = term.split(' + ').map((part) => part.trim()).toList();
for (final part in parts.where((part) => !part.startsWith('#'))) {
if (!containsTerm(rest, part)) return false;
rest = rest.replaceFirst(_termPattern(part), ' ');
}
return parts
.where((part) => part.startsWith('#'))
.every((part) => _matchesStructure(rest, part));
}
if (term.startsWith('#')) return _matchesStructure(text, term);
return _termPattern(term).hasMatch(text);
}
RegExp _termPattern(String term) {
if (term.length > 2 && term.startsWith('/') && term.endsWith('/')) {
return RegExp(term.substring(1, term.length - 1));
}
final escaped = RegExp.escape(normalizeAnswer(term));
return RegExp('(?<![a-z])$escaped(?![a-z])');
}
final _numberWord = RegExp(
r'(?<![a-z])(zero|one|two|three|four|five|six|seven|eight|nine|ten)(?![a-z])',
);
final _wordToken = RegExp(r"[a-z]+(?:'[a-z]+)?");
bool _matchesStructure(String text, String token) {
final counted = RegExp(r'^#(numbers|words):(\d+)$').firstMatch(token);
if (counted != null) {
final needed = int.parse(counted.group(2)!);
return switch (counted.group(1)) {
'numbers' => _numberWord.allMatches(text).length >= needed,
_ =>
_wordToken.allMatches(text).map((m) => m.group(0)).toSet().length >=
needed,
};
}
return switch (token) {
// Letters said one by one ("S-H-E-N", "s h e n", "L-I"), or the spelled
// name typed as one word ("Shen", "aaa"). Case and hyphens are ignored.
'#spelling' =>
RegExp(
r'(?:^|[^a-z])[a-z](?:\s*[-‐-―]\s*[a-z]|\s[a-z])+(?![a-z])',
).hasMatch(text) ||
RegExp(r'^[a-z]{2,}$').hasMatch(
text
.replaceAll(RegExp(r'[-‐-―]'), '')
.replaceAll(RegExp(r'^[^a-z]+|[^a-z]+$'), ''),
),
// Any spoken or written digit.
'#digit' => _numberWord.hasMatch(text) || RegExp('[0-9]').hasMatch(text),
// A real word after the frame, so "I'm" alone is not a name.
'#word' => RegExp(r'[a-z]{2,}').hasMatch(text),
// Any question addressed to the partner.
'#question' =>
text.contains('?') ||
RegExp(
r"(?<![a-z])(what|where|who|when|how|why|do you|are you|can you)(?![a-z])",
).hasMatch(text),
_ => false,
};
}
/// A short Chinese label for [group], used when telling a learner what an
/// attempt still lacks.
String termGroupLabel(List<String> group) {
final labels = <String>[];
for (final term in groupTerms(group)) {
final label = _termLabel(term);
if (!labels.contains(label)) labels.add(label);
}
return labels.take(4).join(' / ');
}
String _termLabel(String term) => term
.split(' + ')
.map((part) {
final trimmed = part.trim();
final counted = RegExp(r'^#(numbers|words):(\d+)$').firstMatch(trimmed);
if (counted != null) {
return counted.group(1) == 'numbers'
? '至少 ${counted.group(2)} 个英文数字'
: '至少 ${counted.group(2)} 个词';
}
if (trimmed.startsWith('/')) return '…';
return switch (trimmed) {
'#spelling' => '把名字逐个字母拼出来(如 A-L-E-X)',
'#digit' => '一个数字',
'#word' => '…',
'#question' => '一句反问(如 What about you?)',
final word => word,
};
})
.join(' ');
@@ -0,0 +1,132 @@
import '../models.dart';
import 'answer_rules.dart';
/// One stage-assessment task, from the level's `assessment` JSON. Choice
/// tasks carry `choices` and `answerIndex`; open tasks (writing, speaking)
/// carry an `accept` rule in the language of `answer_rules.dart` and the
/// `guide` shown after a wrong answer.
class AssessmentTask {
const AssessmentTask({
required this.id,
required this.skill,
required this.prompt,
this.audio,
this.choices = const [],
this.answerIndex,
this.accept = const [],
this.guide = '',
});
factory AssessmentTask.fromJson(Map<String, dynamic> json) => AssessmentTask(
id: json['id'] as String,
skill: AssessmentSkill.values.byName(json['skill'] as String),
prompt: json['prompt'] as String? ?? '',
audio: json['audio'] as String?,
choices: [for (final choice in json['choices'] as List? ?? const []) '$choice'],
answerIndex: json['answerIndex'] as int?,
accept: [
for (final group in json['accept'] as List? ?? const [])
[for (final term in group as List) '$term'],
],
guide: json['guide'] as String? ?? '',
);
final String id;
final AssessmentSkill skill;
final String prompt;
final String? audio;
final List<String> choices;
final int? answerIndex;
final List<List<String>> accept;
final String guide;
}
extension AssessmentSkillLabel on AssessmentSkill {
String get label => switch (this) {
AssessmentSkill.listening => '听力',
AssessmentSkill.speaking => '口语',
AssessmentSkill.reading => '阅读',
AssessmentSkill.writing => '写作',
};
}
/// A stage-assessment pack. A replacement pack ([replaces] set) assesses the
/// same abilities as its original with different people, places and values,
/// and its results count toward the original.
class AssessmentPack {
const AssessmentPack({
required this.id,
required this.level,
required this.tasks,
this.replaces,
});
factory AssessmentPack.fromJson(Map<String, dynamic> json) => AssessmentPack(
id: json['id'] as String,
level: json['level'] as String? ?? '',
replaces: json['replaces'] as String?,
tasks: [
for (final task in json['tasks'] as List? ?? const [])
AssessmentTask.fromJson(task as Map<String, dynamic>),
],
);
final String id;
final String level;
final String? replaces;
final List<AssessmentTask> tasks;
List<AssessmentTask> forSkill(AssessmentSkill skill) =>
tasks.where((task) => task.skill == skill).toList();
}
/// Every loaded assessment pack, in file order; filled by `CourseRepository`.
final List<AssessmentPack> assessmentPacks = <AssessmentPack>[];
/// The A0 stage assessments, in the order they unlock.
List<AssessmentPack> get a0AssessmentPacks => [
for (final pack in assessmentPacks)
if (pack.level == 'A0' && pack.replaces == null) pack,
];
/// The A0 replacement packs.
List<AssessmentPack> get a0ReplacementPacks => [
for (final pack in assessmentPacks)
if (pack.level == 'A0' && pack.replaces != null) pack,
];
/// The replacement pack of [packId], if it has one.
AssessmentPack? replacementFor(String packId) =>
assessmentPacks.where((pack) => pack.replaces == packId).firstOrNull;
/// The pack whose results [packId] counts toward: the original of a
/// replacement pack, otherwise [packId] itself.
String canonicalAssessmentPackId(String packId) =>
assessmentPacks
.where((pack) => pack.id == packId)
.firstOrNull
?.replaces ??
packId;
/// Local check of an open task with the same rule engine as the lessons: it
/// accepts variable names and places, but requires the communicative
/// information the task names.
bool checkOpenAssessmentAnswer(AssessmentTask task, String input) =>
task.accept.isNotEmpty && matchesRule(input, task.accept);
/// What a correct answer to [task] looks like, shown when reviewing a
/// wrong answer after the assessment. [answer] is what the learner gave.
String assessmentAnswerGuide(AssessmentTask task, String answer) {
final index = task.answerIndex;
if (index != null && index >= 0 && index < task.choices.length) {
final correct = task.choices[index];
if (task.skill == AssessmentSkill.listening && answer == correct) {
return '正确答案:$correct(选对了,但作答前没有播放音频,所以不计分)';
}
return '正确答案:$correct';
}
if (task.guide.isNotEmpty) return task.guide;
return task.skill == AssessmentSkill.writing
? '需要写一个完整的英文句子。'
: '需要用完整英文回答题目要求。';
}
@@ -0,0 +1,132 @@
import 'course_pack.dart';
/// A produced core item as the registries see it: the level of the pack that
/// teaches it and its English and Chinese forms.
typedef RegisteredCoreItem = ({String level, String en, String zh});
/// Every produced core item across A0–B1, in course order, filled from the
/// packs' `coreItems` by `CourseRepository`. IDs are separate from the
/// wording and never change when a course accepts a slot value or
/// contraction, because saved progress refers to them.
final Map<String, RegisteredCoreItem> coreItemRegistry =
<String, RegisteredCoreItem>{};
/// Whether [id] is an A0 core item. A0 items make up the frozen A0 v1
/// upgrade denominator from A0-STAGE-STANDARD.md.
bool isA0CoreItem(String id) => coreItemRegistry[id]?.level == 'A0';
/// The A0 core items, id → English, in course order.
Map<String, String> get a0CoreItems => {
for (final entry in coreItemRegistry.entries)
if (entry.value.level == 'A0') entry.key: entry.value.en,
};
class CoreReviewTemplate {
const CoreReviewTemplate({
required this.prompt,
required this.hint,
required this.skill,
});
final String prompt;
final String hint;
final String skill;
}
/// Review data of every core item, straight from the course JSON: the acceptance rule (see `answer_rules.dart`), the offline prompt,
/// the hint after a miss and an optional dictation sentence.
final Map<String, List<List<String>>> coreItemMatch =
<String, List<List<String>>>{};
final Map<String, CoreReviewTemplate> coreReviewTemplates =
<String, CoreReviewTemplate>{};
final Map<String, String> coreReviewHints = <String, String>{};
final Map<String, DictationSentence> coreDictationSentences =
<String, DictationSentence>{};
/// Registers [item], taught at [level], with its review data.
void registerCoreItem(CoreItem item, {required String level}) {
coreItemRegistry[item.id] = (level: level, en: item.en, zh: item.zh);
coreItemMatch[item.id] = item.match;
coreReviewTemplates[item.id] = CoreReviewTemplate(
prompt: item.reviewPrompt.isNotEmpty
? item.reviewPrompt
: '用英文表达“${item.zh}”。',
hint: item.en,
skill: item.type == 'word' ? '词汇回忆' : '回忆表达',
);
if (item.reviewHint.isNotEmpty) coreReviewHints[item.id] = item.reviewHint;
if (item.dictationSentence.isNotEmpty) {
coreDictationSentences[item.id] = (
sentence: item.dictationSentence,
meaning: item.dictationMeaning,
);
}
}
/// Empties every core-item registry, for `CourseRepository.resetForTest`.
void clearCoreItems() {
coreItemRegistry.clear();
coreItemMatch.clear();
coreReviewTemplates.clear();
coreReviewHints.clear();
coreDictationSentences.clear();
}
/// The label a learner sees for a core item, falling back to its id. A0
/// items are labelled by their English (a beginner recognises the phrase);
/// later levels by their Chinese meaning.
String coreItemLabel(String id) {
final item = coreItemRegistry[id];
if (item == null) return id;
return item.level == 'A0' ? item.en : item.zh;
}
/// The English form of any core item; prompts that constrain the AI's
/// English use this.
String coreItemEnglish(String id) => coreItemRegistry[id]?.en ?? id;
/// Whether [id] is a produced core item the course tracks for mastery.
bool isCoreItem(String id) => coreItemRegistry.containsKey(id);
/// Every produced core item id across A0–B1, in course order.
Iterable<String> get allCoreItemIds => coreItemRegistry.keys;
/// Offline, reviewed prompts keep initial reviews usable without an AI service.
/// The prompt supplies a meaning or situation, never the English target.
CoreReviewTemplate coreReviewTemplate(String id) =>
coreReviewTemplates[id] ??
CoreReviewTemplate(prompt: '把“这个已学词”写成英文。', hint: id, skill: '词汇回忆');
/// Rotates an approved offline prompt family without changing the core target
/// ID. The rotation changes the skill, not just the wording: recall in
/// writing, dictation from audio, then saying it aloud.
CoreReviewTemplate coreReviewVariant(String id, int variantIndex) {
final base = coreReviewTemplate(id);
final dictation = coreDictationSentences[id];
return switch (variantIndex % 3) {
1 when dictation != null => CoreReviewTemplate(
prompt: '听一句话,把听到的整句英文写下来。',
hint: dictation.meaning,
skill: dictationSkill,
),
2 => CoreReviewTemplate(
prompt: '用英文说出来:${base.prompt}',
hint: base.hint,
skill: spokenRecallSkill,
),
_ => base,
};
}
const dictationSkill = '听写';
const spokenRecallSkill = '口头回忆';
/// A concrete sentence built only from taught language, used to play a core
/// item in context and to check dictation.
typedef DictationSentence = ({String sentence, String meaning});
/// What review audio should play for [item]: the dictation sentence for a
/// core item, otherwise the target itself when it has no slot to fill.
String? reviewAudioText(String id, String target) =>
coreDictationSentences[id]?.sentence ??
(target.contains('[') ? null : target);
@@ -0,0 +1,129 @@
import 'dart:math';
import '../models.dart';
import 'assessment.dart';
import 'core_items.dart';
import 'course_models.dart';
// Course registries, filled by `CourseRepository` from the content packs in
// assets/courses. Every level, A0 included, goes through the same adapter, so
// these hold one kind of data per key whatever level it came from.
final List<SeedLesson> courseLessons = <SeedLesson>[];
final Map<String, LessonSegment> courseSegments = <String, LessonSegment>{};
final Map<String, LessonActivity> segmentActivities =
<String, LessonActivity>{};
final Map<String, List<VocabularyItem>> segmentVocabulary =
<String, List<VocabularyItem>>{};
final Map<String, LessonDialogue> segmentDialogues = <String, LessonDialogue>{};
final Map<String, String> segmentGrammarNotes = <String, String>{};
final List<DialogueScene> practiceScenes = <DialogueScene>[];
/// Empties every registry, for `CourseRepository.resetForTest`.
void clearCourseCatalog() {
courseLessons.clear();
courseSegments.clear();
segmentActivities.clear();
segmentVocabulary.clear();
segmentDialogues.clear();
segmentGrammarNotes.clear();
practiceScenes.clear();
assessmentPacks.clear();
}
/// Every lesson the app can teach, in learning order: A0 first, then the
/// A1→A2→B1 units.
List<SeedLesson> get allLessons => courseLessons;
/// The A0 lessons, which lead to the A0 stage assessment.
List<SeedLesson> get a0SeedLessons =>
courseLessons.where((lesson) => lesson.level == 'A0').toList();
/// Every free-practice scene, in course order.
List<DialogueScene> get allScenes => practiceScenes;
/// Finds a teaching segment by id.
LessonSegment? findLessonSegment(String segmentId) => courseSegments[segmentId];
SeedLesson lessonById(String id) => allLessons.firstWhere(
(lesson) => lesson.id == id,
orElse: () => allLessons.first,
);
DialogueScene sceneById(String id) =>
allScenes.where((scene) => scene.id == id).firstOrNull ?? allScenes.first;
LessonActivity activityBySegmentId(String segmentId) =>
segmentActivities[segmentId] ?? _emptyActivity;
const LessonActivity _emptyActivity = LessonActivity(
listening: '',
listeningQuestion: '',
answers: [''],
speaking: '',
reading: '',
readingQuestion: '',
readingAnswer: '',
writingPrompt: '',
writingExample: '',
independentPrompt: '',
independentHelp: '',
);
List<VocabularyItem> vocabularyBySegmentId(String segmentId) =>
segmentVocabulary[segmentId] ?? const <VocabularyItem>[];
/// One practical rule per segment: a reminder for using a sentence in
/// context, not an abstract grammar lecture.
String grammarNoteForSegment(String segmentId) =>
segmentGrammarNotes[segmentId] ?? '';
LessonDialogue dialogueBySegmentId(String segmentId) =>
segmentDialogues[segmentId] ?? _emptyDialogue;
const LessonDialogue _emptyDialogue = LessonDialogue(
goal: '',
prompts: [],
hints: [],
);
/// How many A1+ units, the current one included, the AI partner's word list
/// spans. Earlier units stay usable as ordinary easy English; listing every
/// unit would resend hundreds of items with each dialogue turn.
const int taughtLanguageUnitWindow = 3;
/// The English a learner has actually met up to and including [lessonId].
/// The AI partner is told to build its lines from this list so a beginner
/// never gets an answer built from words the course has not taught yet. In A0
/// it is everything taught so far; from A1 on it is the recent units only.
/// The order is fixed so repeated prompts share a cacheable prefix.
List<String> taughtLanguageUpTo(String lessonId) {
final lessons = allLessons;
final found = lessons.indexWhere((lesson) => lesson.id == lessonId);
final end = found < 0 ? lessons.length - 1 : found;
final start = lessons[end].level == 'A0'
? 0
: max(end - taughtLanguageUnitWindow + 1, a0SeedLessons.length);
return <String>{
for (final lesson in lessons.sublist(start, end + 1))
for (final id in lesson.targetItemIds) coreItemEnglish(id),
}.toList();
}
/// Every A0 item, for the always-open free scene that mixes all A0 topics.
List<String> get a0TaughtLanguage => a0CoreItems.values.toList();
/// The CEFR level of the lesson that teaches [itemId], A0 when it is unknown.
String itemLevel(String itemId) {
for (final lesson in allLessons) {
if (lesson.targetItemIds.contains(itemId)) return lesson.level;
}
return 'A0';
}
/// The CEFR level of [lessonId], A0 when it is unknown.
String lessonLevel(String lessonId) {
for (final lesson in allLessons) {
if (lesson.id == lessonId) return lesson.level;
}
return 'A0';
}
@@ -0,0 +1,177 @@
import '../models.dart';
/// A topic may contain multiple independently resumable teaching segments.
/// Segment IDs are stable even if later wording or exercises change.
class LessonSegment {
const LessonSegment({
required this.id,
required this.targetItemIds,
required this.previewItemIds,
this.dialogueRequiredTerms = const [],
this.independentRequiredTerms = const [],
this.estimatedMinutes = 12,
});
final String id;
final List<String> targetItemIds;
final List<String> previewItemIds;
/// One accepted term group per controlled dialogue turn. A learner only
/// needs to use one term from the current group; the surrounding wording
/// may vary naturally.
final List<List<String>> dialogueRequiredTerms;
/// The rule an independent attempt has to satisfy (see
/// `answer_rules.dart`); empty accepts any real attempt.
final List<List<String>> independentRequiredTerms;
final int estimatedMinutes;
}
class SeedLesson {
const SeedLesson({
required this.id,
required this.number,
required this.title,
required this.outcome,
required this.vocabulary,
required this.targetItemIds,
this.schemaVersion = '1.0',
this.revision = 1,
this.stageVersion = 'A0-1.0',
this.source = ContentSource.builtInOriginal,
this.status = ContentStatus.approved,
this.estimatedMinutes = 12,
this.level = 'A0',
this.explicitSegments,
});
final String id;
final int number;
final String title;
final String outcome;
final List<VocabularyItem> vocabulary;
final List<String> targetItemIds;
final String schemaVersion;
final int revision;
final String stageVersion;
final ContentSource source;
final ContentStatus status;
final int estimatedMinutes;
/// CEFR level this lesson belongs to (`A0`, `A1`, `A2`, `B1`).
final String level;
/// Set when the lesson is split into several teaching segments; otherwise
/// the whole lesson is taught as one implicit segment.
final List<LessonSegment>? explicitSegments;
List<String> get abilityIds => ['A0-C${number.toString().padLeft(2, '0')}'];
List<String> get prerequisiteIds => number == 1
? const []
: ['a0-${(number - 1).toString().padLeft(2, '0')}'];
List<LessonSegment> get segments =>
explicitSegments ??
[
LessonSegment(
id: '$id-a',
targetItemIds: targetItemIds,
previewItemIds: targetItemIds,
estimatedMinutes: estimatedMinutes,
),
];
}
class LessonActivity {
const LessonActivity({
required this.listening,
required this.listeningQuestion,
required this.answers,
required this.speaking,
this.speakingTip = '',
required this.reading,
required this.readingQuestion,
required this.readingAnswer,
this.readingOptions = const [],
required this.writingPrompt,
required this.writingExample,
this.writingRequiredTerms = const [],
this.writingHint = '',
required this.independentPrompt,
required this.independentHelp,
});
final String listening;
final String listeningQuestion;
final List<String> answers;
final String speaking;
/// 这句跟读的发音提示;为空时不显示,避免给每一句套用同一条提示。
final String speakingTip;
final String reading;
final String readingQuestion;
final String readingAnswer;
final List<String> readingOptions;
final String writingPrompt;
final String writingExample;
/// The rule a written answer has to satisfy (see `answer_rules.dart`);
/// empty means any sentence of two or more words.
final List<List<String>> writingRequiredTerms;
/// What to tell a learner whose written answer misses the rule.
final String writingHint;
final String independentPrompt;
final String independentHelp;
}
class LessonDialogue {
const LessonDialogue({
required this.goal,
required this.prompts,
required this.hints,
this.translations = const [],
this.requiredTerms = const [],
this.taskLabels = const [],
});
final String goal;
final List<String> prompts;
final List<String> hints;
final List<String> translations;
/// One accepted term group per learner turn. The learner only needs to hit
/// one term in the current group, so natural wording still passes. A term
/// may be a plain word or phrase, an `a + b` conjunction (both parts are
/// required), or a structural token starting with `#`.
final List<List<String>> requiredTerms;
/// Chinese label of what each learner turn has to do. It drives the
/// "还没完成本轮任务" message and the summary of completed tasks, so it must
/// describe the task without giving away the model answer.
final List<String> 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;
}
@@ -0,0 +1,480 @@
/// Runtime models for the course content packs (`assets/courses/`), A0
/// through B1: every level uses the same structure.
///
/// These mirror the JSON described in `Doc/COURSE-PACK-JSON.md`. They are pure
/// data with tolerant `fromJson` factories: a missing or malformed field never
/// throws, it degrades to an empty value so a single bad unit can be skipped
/// without taking down the loader.
///
/// Answer rules (`accept`, `match`) use the language of `answer_rules.dart`.
/// Where a pack gives no explicit rule, the loader derives one from the core
/// items the part teaches (`itemIds`).
library;
import '../models.dart';
/// One multiple-choice question on a listening/reading material or segment text.
/// The first option is always the correct answer (the UI shuffles positions).
class PackQuestion {
const PackQuestion({
required this.type,
required this.question,
required this.options,
});
/// `gist` / `detail` / `inference`.
final String type;
final String question;
final List<String> options;
String get answer => options.isNotEmpty ? options.first : '';
factory PackQuestion.fromJson(Map<String, dynamic> json) => PackQuestion(
type: json['type'] as String? ?? 'detail',
question: json['question'] as String? ?? '',
options: _stringList(json['options']),
);
}
/// A short listening or reading comprehension text with glosses and questions.
class PackMaterial {
const PackMaterial({
required this.id,
required this.mode,
required this.title,
required this.text,
required this.glosses,
required this.questions,
});
final String id;
/// `listening` or `reading`.
final String mode;
final String title;
final String text;
/// Untaught word -> Chinese gloss shown alongside the text.
final Map<String, String> glosses;
final List<PackQuestion> questions;
factory PackMaterial.fromJson(Map<String, dynamic> json) => PackMaterial(
id: json['id'] as String? ?? '',
mode: json['mode'] as String? ?? 'reading',
title: json['title'] as String? ?? '',
text: json['text'] as String? ?? '',
glosses: _stringMap(json['glosses']),
questions: _objectList(
json['questions'],
).map(PackQuestion.fromJson).toList(),
);
}
/// One turn of a controlled scene. `model` is the learner's demonstrated line.
class SceneTurn {
const SceneTurn({
required this.ai,
required this.goal,
required this.model,
required this.zh,
required this.itemIds,
this.aiZh = '',
this.accept,
});
final String ai;
final String goal;
final String model;
/// Chinese of [model].
final String zh;
final List<String> itemIds;
/// Chinese of [ai], offered as the translation of the partner's line.
final String aiZh;
/// The terms that pass this turn (one term group); null derives them from
/// [itemIds].
final List<String>? accept;
factory SceneTurn.fromJson(Map<String, dynamic> json) => SceneTurn(
ai: json['ai'] as String? ?? '',
goal: json['goal'] as String? ?? '',
model: json['model'] as String? ?? '',
zh: json['zh'] as String? ?? '',
itemIds: _stringList(json['itemIds']),
aiZh: json['aiZh'] as String? ?? '',
accept: json['accept'] is List ? _stringList(json['accept']) : null,
);
}
/// Marks a scene as a free-practice scene on the practice page. Empty fields
/// fall back to values built from the scene and its unit.
class ScenePractice {
const ScenePractice({
this.title = '',
this.summary = '',
this.recapPrompt = '',
this.alwaysOpen = false,
});
final String title;
final String summary;
/// The review prompt added after the scene is finished.
final String recapPrompt;
/// Open from the start instead of after the unit is finished.
final bool alwaysOpen;
factory ScenePractice.fromJson(Map<String, dynamic> json) => ScenePractice(
title: json['title'] as String? ?? '',
summary: json['summary'] as String? ?? '',
recapPrompt: json['recapPrompt'] as String? ?? '',
alwaysOpen: json['alwaysOpen'] == true,
);
}
/// A role-play scene (`kind` is `main` or `variant`).
class PackScene {
const PackScene({
required this.id,
required this.kind,
required this.title,
required this.learnerRole,
required this.aiRole,
required this.setting,
required this.turns,
this.goal = '',
this.practice,
});
final String id;
final String kind;
final String title;
final String learnerRole;
final String aiRole;
final String setting;
final List<SceneTurn> turns;
/// What the whole dialogue practises; falls back to [setting] or [title].
final String goal;
/// Set when the scene is also offered as free practice.
final ScenePractice? practice;
factory PackScene.fromJson(Map<String, dynamic> json) => PackScene(
id: json['id'] as String? ?? '',
kind: json['kind'] as String? ?? 'variant',
title: json['title'] as String? ?? '',
learnerRole: json['learnerRole'] as String? ?? '',
aiRole: json['aiRole'] as String? ?? '',
setting: json['setting'] as String? ?? '',
turns: _objectList(json['turns']).map(SceneTurn.fromJson).toList(),
goal: json['goal'] as String? ?? '',
practice: json['practice'] is Map<String, dynamic>
? ScenePractice.fromJson(json['practice'] as Map<String, dynamic>)
: null,
);
}
/// An open production task attached to a scene.
class PackTask {
const PackTask({
required this.id,
required this.sceneId,
required this.goal,
required this.itemIds,
required this.slots,
});
final String id;
final String sceneId;
final String goal;
final List<String> itemIds;
final List<String> slots;
factory PackTask.fromJson(Map<String, dynamic> json) => PackTask(
id: json['id'] as String? ?? '',
sceneId: json['sceneId'] as String? ?? '',
goal: json['goal'] as String? ?? '',
itemIds: _stringList(json['itemIds']),
slots: _stringList(json['slots']),
);
}
/// A resumable teaching segment with its listening/speaking/reading/writing/
/// independent activities.
class PackSegment {
const PackSegment({
required this.id,
required this.title,
required this.minutes,
required this.itemIds,
required this.listeningText,
required this.listeningQuestion,
required this.listeningOptions,
required this.speakingText,
required this.speakingTip,
required this.readingText,
required this.readingQuestion,
required this.readingOptions,
required this.writingPrompt,
required this.writingExample,
required this.writingItemIds,
required this.independentPrompt,
required this.independentItemIds,
this.grammarNote = '',
this.vocabulary,
this.sceneId = '',
this.writingAccept,
this.writingHint = '',
this.independentAccept,
this.independentHelp = '',
});
final String id;
final String title;
final int minutes;
final List<String> itemIds;
final String listeningText;
final String listeningQuestion;
final List<String> listeningOptions;
final String speakingText;
final String speakingTip;
final String readingText;
final String readingQuestion;
final List<String> readingOptions;
final String writingPrompt;
final String writingExample;
final List<String> writingItemIds;
final String independentPrompt;
final List<String> independentItemIds;
/// One practical usage reminder shown while writing.
final String grammarNote;
/// The preview words; null derives them from the core items in [itemIds].
final List<VocabularyItem>? vocabulary;
/// The scene practised in this segment; empty picks the scene at the same
/// position (or the main scene).
final String sceneId;
/// Explicit writing / independent rules and messages. Null or empty values
/// are derived from [writingItemIds] / [independentItemIds].
final List<List<String>>? writingAccept;
final String writingHint;
final List<List<String>>? independentAccept;
final String independentHelp;
factory PackSegment.fromJson(Map<String, dynamic> json) {
final listening = _objectOrEmpty(json['listening']);
final reading = _objectOrEmpty(json['reading']);
final writing = _objectOrEmpty(json['writing']);
final independent = _objectOrEmpty(json['independent']);
final speaking = _objectList(json['speaking']);
final firstSpeaking = speaking.isNotEmpty ? speaking.first : const {};
return PackSegment(
id: json['id'] as String? ?? '',
title: json['title'] as String? ?? '',
minutes: (json['minutes'] as num?)?.toInt() ?? 12,
itemIds: _stringList(json['itemIds']),
listeningText: listening['text'] as String? ?? '',
listeningQuestion: listening['question'] as String? ?? '',
listeningOptions: _stringList(listening['options']),
speakingText: firstSpeaking['text'] as String? ?? '',
speakingTip: firstSpeaking['tip'] as String? ?? '',
readingText: reading['text'] as String? ?? '',
readingQuestion: reading['question'] as String? ?? '',
readingOptions: _stringList(reading['options']),
writingPrompt: writing['prompt'] as String? ?? '',
writingExample: writing['example'] as String? ?? '',
writingItemIds: _stringList(writing['itemIds']),
independentPrompt: independent['prompt'] as String? ?? '',
independentItemIds: _stringList(independent['itemIds']),
grammarNote: json['grammarNote'] as String? ?? '',
vocabulary: _vocabulary(json['vocabulary']),
sceneId: json['sceneId'] as String? ?? '',
writingAccept: writing['accept'] is List
? _stringMatrix(writing['accept'])
: null,
writingHint: writing['hint'] as String? ?? '',
independentAccept: independent['accept'] is List
? _stringMatrix(independent['accept'])
: null,
independentHelp: independent['help'] as String? ?? '',
);
}
}
/// A produced core item (word / phrase / pattern) with its acceptance rule.
class CoreItem {
const CoreItem({
required this.id,
required this.type,
required this.en,
required this.zh,
required this.match,
required this.exampleEn,
required this.exampleZh,
required this.reviewPrompt,
this.reviewHint = '',
this.dictationSentence = '',
this.dictationMeaning = '',
});
final String id;
final String type;
final String en;
final String zh;
/// Outer list = AND groups; inner list = OR alternatives.
final List<List<String>> match;
final String exampleEn;
final String exampleZh;
final String reviewPrompt;
/// What to tell a learner whose review answer missed the item.
final String reviewHint;
/// A sentence built only from taught language, played in context and used
/// as a dictation review. Empty when the item has none.
final String dictationSentence;
final String dictationMeaning;
factory CoreItem.fromJson(Map<String, dynamic> json) {
final example = _objectOrEmpty(json['example']);
final review = _objectOrEmpty(json['review']);
final dictation = _objectOrEmpty(review['dictation']);
return CoreItem(
id: json['id'] as String? ?? '',
type: json['type'] as String? ?? 'word',
en: json['en'] as String? ?? '',
zh: json['zh'] as String? ?? '',
match: _stringMatrix(json['match']),
exampleEn: example['en'] as String? ?? '',
exampleZh: example['zh'] as String? ?? '',
reviewPrompt: review['prompt'] as String? ?? '',
reviewHint: review['hint'] as String? ?? '',
dictationSentence: dictation['sentence'] as String? ?? '',
dictationMeaning: dictation['meaning'] as String? ?? '',
);
}
}
/// A recognition-only word.
class ReceptiveWord {
const ReceptiveWord({required this.id, required this.en, required this.zh});
final String id;
final String en;
final String zh;
factory ReceptiveWord.fromJson(Map<String, dynamic> json) => ReceptiveWord(
id: json['id'] as String? ?? '',
en: json['en'] as String? ?? '',
zh: json['zh'] as String? ?? '',
);
}
/// One unit content pack (a0-01, A1-U01 …).
class CoursePack {
const CoursePack({
required this.id,
required this.level,
this.status = 'draft',
required this.title,
required this.canDo,
required this.categories,
required this.coreItems,
required this.receptiveWords,
required this.segments,
required this.scenes,
required this.tasks,
required this.materials,
this.vocabulary,
});
final String id;
final String level;
/// `approved` for reviewed content, otherwise `draft`.
final String status;
final String title;
final String canDo;
final List<String> categories;
final List<CoreItem> coreItems;
final List<ReceptiveWord> receptiveWords;
final List<PackSegment> segments;
final List<PackScene> scenes;
final List<PackTask> tasks;
final List<PackMaterial> materials;
/// The unit's word list for lookup; null derives it from [coreItems] and
/// [receptiveWords].
final List<VocabularyItem>? vocabulary;
PackScene? get mainScene =>
scenes.where((scene) => scene.kind == 'main').firstOrNull ??
(scenes.isNotEmpty ? scenes.first : null);
factory CoursePack.fromJson(Map<String, dynamic> json) => CoursePack(
id: json['id'] as String? ?? '',
level: json['level'] as String? ?? '',
status: json['status'] as String? ?? 'draft',
title: json['title'] as String? ?? '',
canDo: json['canDo'] as String? ?? '',
categories: _stringList(json['categories']),
coreItems: _objectList(json['coreItems']).map(CoreItem.fromJson).toList(),
receptiveWords: _objectList(
json['receptiveWords'],
).map(ReceptiveWord.fromJson).toList(),
segments: _objectList(json['segments']).map(PackSegment.fromJson).toList(),
scenes: _objectList(json['scenes']).map(PackScene.fromJson).toList(),
tasks: _objectList(json['tasks']).map(PackTask.fromJson).toList(),
materials: _objectList(
json['materials'],
).map(PackMaterial.fromJson).toList(),
vocabulary: _vocabulary(json['vocabulary']),
);
}
// --- tolerant parsing helpers -------------------------------------------------
List<String> _stringList(dynamic value) => value is List
? [
for (final item in value)
if (item != null) item.toString(),
]
: const <String>[];
List<List<String>> _stringMatrix(dynamic value) => value is List
? [for (final row in value) _stringList(row)]
: const <List<String>>[];
Map<String, String> _stringMap(dynamic value) => value is Map
? {
for (final entry in value.entries)
entry.key.toString(): entry.value?.toString() ?? '',
}
: const <String, String>{};
List<Map<String, dynamic>> _objectList(dynamic value) => value is List
? [
for (final item in value)
if (item is Map<String, dynamic>) item,
]
: const <Map<String, dynamic>>[];
List<VocabularyItem>? _vocabulary(dynamic value) => value is List
? _objectList(value).map(VocabularyItem.fromJson).toList()
: null;
Map<String, dynamic> _objectOrEmpty(dynamic value) =>
value is Map<String, dynamic> ? value : const <String, dynamic>{};
@@ -0,0 +1,336 @@
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart' show rootBundle, AssetBundle;
import 'assessment.dart';
import 'core_items.dart';
import 'course_catalog.dart';
import 'course_models.dart';
import '../models.dart';
import 'course_pack.dart';
/// Loads the JSON content packs of every level, A0 through B1, and adapts
/// each unit into the runtime models the learning flow consumes
/// (`SeedLesson`, `LessonActivity`, `LessonDialogue`, `DialogueScene`,
/// `VocabularyItem`).
///
/// Every level has the same pack structure and goes through the same
/// adapter, so learning and review data follow one set of rules. The adapted
/// data is registered into the registries in `course_catalog.dart` /
/// `core_items.dart`, so the rest of the app keeps calling synchronous
/// accessors (`lessonById`, `activityBySegmentId`, …).
///
/// `main` loads A0 before `runApp`, so the offline baseline is always there;
/// the later levels load with the app state. Loading is best-effort: a
/// malformed unit is skipped.
class CourseRepository {
CourseRepository._();
static final CourseRepository instance = CourseRepository._();
static const _mapAsset = 'assets/courses/course-map.json';
static const _assetRoot = 'assets/courses/';
final List<CoursePack> _units = [];
final Map<String, CoursePack> _unitsById = {};
final Set<String> _assessmentLevels = {};
bool _loadedAll = false;
List<CoursePack> get units => List.unmodifiable(_units);
CoursePack? unitById(String id) => _unitsById[id];
/// Parses the course map and the units it lists, then registers the adapted
/// content. [levels] limits the load to those levels (`main` loads `A0`
/// first). Units already loaded are skipped, so the call is safe to repeat.
/// Units are read in parallel but registered in course order, because
/// lessons are numbered in the order they are registered; levels therefore
/// have to load in course order too.
Future<void> load({Set<String>? levels, AssetBundle? bundle}) async {
if (_loadedAll) return;
final loader = bundle ?? rootBundle;
try {
final mapRaw = await loader.loadString(_mapAsset);
final map = jsonDecode(mapRaw) as Map<String, dynamic>;
await _loadAssessments(map, levels, loader);
final paths = <String>[
for (final level in (map['levels'] as List? ?? const []))
if (level is Map && (levels == null || levels.contains(level['id'])))
for (final unit in (level['units'] as List? ?? const []))
if (unit is Map &&
unit['file'] is String &&
!_unitsById.containsKey(unit['id']))
'$_assetRoot${unit['file']}',
];
final packs = await Future.wait(
paths.map((path) => _readUnit(path, loader)),
);
for (final pack in packs) {
if (pack == null || _unitsById.containsKey(pack.id)) continue;
_add(pack);
}
if (levels == null) _loadedAll = true;
} catch (error, stack) {
// A missing or broken map leaves whatever units parsed before the
// failure in place.
debugPrint('CourseRepository: failed to load course map: $error\n$stack');
}
}
/// Reads the stage assessments of the levels being loaded, from each
/// level's `assessment` file in the course map.
Future<void> _loadAssessments(
Map<String, dynamic> map,
Set<String>? levels,
AssetBundle loader,
) async {
for (final level in (map['levels'] as List? ?? const [])) {
if (level is! Map || level['assessment'] is! String) continue;
if (levels != null && !levels.contains(level['id'])) continue;
if (!_assessmentLevels.add(level['id'] as String)) continue;
final path = '$_assetRoot${level['assessment']}';
try {
final json = jsonDecode(await loader.loadString(path)) as Map;
for (final pack in json['packs'] as List? ?? const []) {
assessmentPacks.add(
AssessmentPack.fromJson(pack as Map<String, dynamic>),
);
}
} catch (error) {
debugPrint('CourseRepository: skipped assessment $path: $error');
}
}
}
/// Reads one unit; null when it is missing or malformed.
Future<CoursePack?> _readUnit(String assetPath, AssetBundle loader) async {
try {
final raw = await loader.loadString(assetPath);
final pack = CoursePack.fromJson(jsonDecode(raw) as Map<String, dynamic>);
return pack.id.isEmpty ? null : pack;
} catch (error) {
debugPrint('CourseRepository: skipped unit $assetPath: $error');
return null;
}
}
void _add(CoursePack pack) {
_units.add(pack);
_unitsById[pack.id] = pack;
_register(pack);
}
/// Adapts one pack into the shared runtime registries. Explicit rules and
/// texts in the pack win; anything left out is derived from the core items
/// the part teaches.
void _register(CoursePack pack) {
final byId = {for (final item in pack.coreItems) item.id: item};
final scenesById = {for (final scene in pack.scenes) scene.id: scene};
// Core items: labels, acceptance rules and offline review prompts.
for (final item in pack.coreItems) {
registerCoreItem(item, level: pack.level);
}
final segments = <LessonSegment>[];
for (var i = 0; i < pack.segments.length; i++) {
final seg = pack.segments[i];
final scene = seg.sceneId.isNotEmpty
? scenesById[seg.sceneId]
: i < pack.scenes.length
? pack.scenes[i]
: pack.mainScene;
final dialogue = scene == null ? null : _dialogueFromScene(scene, byId);
final independentTerms = _acceptedTerms(seg.independentItemIds, byId);
final writingTerms = _acceptedTerms(seg.writingItemIds, byId);
final lessonSegment = LessonSegment(
id: seg.id,
targetItemIds: seg.itemIds,
previewItemIds: seg.itemIds,
dialogueRequiredTerms: dialogue?.requiredTerms ?? const [],
independentRequiredTerms:
seg.independentAccept ??
[if (independentTerms.isNotEmpty) independentTerms],
estimatedMinutes: seg.minutes,
);
segments.add(lessonSegment);
courseSegments[seg.id] = lessonSegment;
// Preview vocabulary: the pack's own list, or the core items this
// segment teaches.
segmentVocabulary[seg.id] =
seg.vocabulary ??
[
for (final id in seg.itemIds)
if (byId[id] case final item?) _vocabularyOf(item),
];
if (seg.grammarNote.isNotEmpty) {
segmentGrammarNotes[seg.id] = seg.grammarNote;
}
segmentActivities[seg.id] = LessonActivity(
listening: seg.listeningText,
listeningQuestion: seg.listeningQuestion,
answers: seg.listeningOptions.isNotEmpty
? seg.listeningOptions
: const [''],
speaking: seg.speakingText,
speakingTip: seg.speakingTip,
reading: seg.readingText,
readingQuestion: seg.readingQuestion,
readingAnswer: seg.readingOptions.isNotEmpty
? seg.readingOptions.first
: '',
readingOptions: seg.readingOptions,
writingPrompt: seg.writingPrompt,
writingExample: seg.writingExample,
writingRequiredTerms:
seg.writingAccept ?? [if (writingTerms.isNotEmpty) writingTerms],
writingHint: seg.writingHint.isNotEmpty
? seg.writingHint
: writingTerms.isEmpty
? ''
: '用上本段的表达之一:${_joinModels(seg.writingItemIds, byId)}。',
independentPrompt: seg.independentPrompt,
independentHelp: seg.independentHelp.isNotEmpty
? seg.independentHelp
: _joinModels(seg.independentItemIds, byId),
);
if (dialogue != null) segmentDialogues[seg.id] = dialogue;
}
courseLessons.add(
SeedLesson(
id: pack.id,
number: courseLessons.length + 1,
title: pack.title,
outcome: pack.canDo,
// Lesson-level words feed the offline lexicon lookup.
vocabulary:
pack.vocabulary ??
[
for (final item in pack.coreItems) _vocabularyOf(item),
for (final word in pack.receptiveWords)
VocabularyItem(
id: word.id,
word: word.en,
meaning: word.zh,
example: '',
exampleMeaning: '',
),
],
level: pack.level,
status: pack.status == 'approved'
? ContentStatus.approved
: ContentStatus.draft,
estimatedMinutes: pack.segments.fold(
0,
(sum, seg) => sum + seg.minutes,
),
explicitSegments: segments,
targetItemIds: [for (final seg in pack.segments) ...seg.itemIds],
),
);
// Scenes marked `practice` are also free-practice scenes, unlocked once
// the unit is finished unless they are always open.
for (final scene in pack.scenes) {
final practice = scene.practice;
if (practice == null) continue;
practiceScenes.add(
DialogueScene(
id: scene.id,
title: practice.title.isNotEmpty
? practice.title
: '${pack.title} · ${scene.title}',
summary: practice.summary.isNotEmpty
? practice.summary
: scene.setting.isNotEmpty
? scene.setting
: scene.title,
script: _dialogueFromScene(scene, byId),
recapPrompt: practice.recapPrompt.isNotEmpty
? practice.recapPrompt
: '再用英语完成一次「${scene.title}」的对话。',
unlockAfterLessonId: practice.alwaysOpen ? null : pack.id,
),
);
}
}
VocabularyItem _vocabularyOf(CoreItem item) => VocabularyItem(
id: item.id,
word: item.en,
meaning: item.zh,
example: item.exampleEn,
exampleMeaning: item.exampleZh,
);
/// One accepted-term group per turn: the turn's own `accept`, or the terms
/// of the core items the turn teaches.
LessonDialogue _dialogueFromScene(
PackScene scene,
Map<String, CoreItem> byId,
) => LessonDialogue(
goal: scene.goal.isNotEmpty
? scene.goal
: scene.setting.isNotEmpty
? scene.setting
: scene.title,
prompts: [for (final turn in scene.turns) turn.ai],
hints: [for (final turn in scene.turns) turn.model],
translations: scene.turns.every((turn) => turn.aiZh.isEmpty)
? const []
: [for (final turn in scene.turns) turn.aiZh],
requiredTerms: [
for (final turn in scene.turns)
turn.accept ?? _acceptedTerms(turn.itemIds, byId),
],
taskLabels: [for (final turn in scene.turns) turn.goal],
);
List<String> _acceptedTerms(
Iterable<String> itemIds,
Map<String, CoreItem> byId,
) {
final terms = <String>[];
for (final id in itemIds) {
final item = byId[id];
if (item == null) continue;
for (final group in item.match) {
terms.addAll(group);
}
if (item.match.isEmpty && item.en.isNotEmpty) terms.add(item.en);
}
return terms;
}
String _joinModels(Iterable<String> itemIds, Map<String, CoreItem> byId) {
final phrases = [
for (final id in itemIds)
if (byId[id] != null && byId[id]!.en.isNotEmpty) byId[id]!.en,
];
return phrases.join(' / ');
}
/// Test-only reset back to A0 alone, so a test can start without the later
/// levels or load them again.
@visibleForTesting
void resetForTest() {
final a0 = _units.where((pack) => pack.level == 'A0').toList();
final a0Assessments = [
for (final pack in assessmentPacks)
if (pack.level == 'A0') pack,
];
_units.clear();
_unitsById.clear();
_loadedAll = false;
clearCourseCatalog();
clearCoreItems();
a0.forEach(_add);
assessmentPacks.addAll(a0Assessments);
_assessmentLevels.removeWhere((level) => level != 'A0');
}
}
@@ -0,0 +1,11 @@
/// Course content and lookups, A0 through B1, from the JSON packs loaded by
/// `CourseRepository`.
library;
export 'answer_matching.dart';
export 'answer_rules.dart';
export 'assessment.dart';
export 'core_items.dart';
export 'course_catalog.dart';
export 'course_models.dart';
export 'option_order.dart';
@@ -0,0 +1,25 @@
int _seedHash(String seed) {
var hash = 0x811c9dc5;
for (final code in seed.codeUnits) {
hash = ((hash ^ code) * 0x01000193) & 0x7fffffff;
}
return hash;
}
/// 选项顺序固定会让学习者养成“永远选第一个”的习惯。[options] 的第一项是标准
/// 答案,这里按题目内容确定性地把它挪到某个位置,并打乱其余干扰项:同一道题
/// 每次进入顺序都一样,但答案在三个位置上分布均匀。
List<String> shuffledOptions(List<String> options, String seed) {
if (options.length < 2) return options;
final distractors = [...options.skip(1)];
var hash = _seedHash(seed);
for (var i = distractors.length - 1; i > 0; i--) {
hash = (hash * 1103515245 + 12345) & 0x7fffffff;
final j = hash % (i + 1);
final swap = distractors[i];
distractors[i] = distractors[j];
distractors[j] = swap;
}
return distractors
..insert(_seedHash('$seed#slot') % options.length, options.first);
}