整个课程和数据UI重新设计版本
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user