feat: 独立词库、三向认词与当日快闪复习

## 独立词库

理解词原先只能跟着课程单元走,学完 A0 十课词汇量只增加约 22 个实词,
不足以解决"记不住单词"。新增一份独立词库 assets/words/wordbank.json
(2748 词,A1–B1),挂进 receptiveWordRegistry 的合成单元 bank-A1/A2/B1,
完全复用理解词已有的状态机,不依赖课程进度,第一天就能用。

数据来源、许可与合成规则记在 tool/words/DATA-NOTE.md:CEFR-J 定等级、
公开词书提供音标、AI 重写全部释义并生成例句、OpenSubtitles 提供口语词频。
词书部分为 CC BY-NC-SA 4.0 且上游权利不明,仅供个人非商用;
若要分发或上架,须替换音标那一列。

## 背单词机制

- 间隔阶梯 1/3/7/15/30/60/120 天,连续答对上一级,答错回第一级。
  原先首次答对后要等 7 天才复习,正是"第二天就忘"的成因。
- 每日新词上限(10 分钟 8 个 / 20 分钟 15 个 / 30 分钟 20 个)。
  阶梯第一级是次日,今天引入的新词就是明天的工作量。
- 新词按口语频率发放,不再按字母序 —— A1 从 a.m./ability 变成 no/not/know/just。
- 三个方向按层级轮转:看词(英→中)→ 听词(音→中)→ 想词(中→英)。
  想词题仍是选择题,不要求产出,理解词定位不变,不进升级分母。
- 单词页独立成 tab,首页今日任务卡下方给一张认词入口卡。

## 用法对照

课程 JSON 增加 usage 字段(when/reply/swap/confuse):一个句型用在什么场合、
对方通常怎么答、还能怎么说、跟哪个学过的句型容易混。
知道 How are you? 的意思,不等于知道它不是用来问名字的。

## 复习流

- 当日快闪(recap)独立成队列,不占复习预算,也不计入积压。
- 只发放当日预算内的量,其余保持到期状态等下次,不悄悄丢弃或改期。
- 答错的项隔几题后回来,而不是立刻重问。

测试 296 通过,flutter analyze 干净。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-20 23:54:17 +09:00
co-authored by Claude Opus 5
parent 1a10ca88e0
commit 6b42b7abc3
52 changed files with 4044 additions and 82 deletions
+25 -1
View File
@@ -21,6 +21,7 @@ part 'app_state_assessment.dart';
part 'app_state_lesson.dart';
part 'app_state_review.dart';
part 'app_state_snapshot.dart';
part 'app_state_words.dart';
/// Everything the app persists. Behaviour lives in the domain mixins that
/// [AppState] composes.
@@ -88,11 +89,31 @@ abstract class _AppStateData extends ChangeNotifier {
final Map<String, MasteryItem> mastery = {};
/// Recognition-only words, kept apart from [mastery] so they never enter a
/// level's upgrade denominator (learning engine 3.5).
final Map<String, WordKnowledge> wordKnowledge = {};
/// Words saved from lookup. They live outside any unit, so they are kept
/// here to be put back into the word registry on the next start.
final Map<String, RegisteredReceptiveWord> savedWords = {};
void _syncInBackground();
}
class AppState extends _AppStateData
with _ReviewAndMastery, _AssessmentProgress, _LessonProgress, _AiContent {
with _ReviewAndMastery,
_ReceptiveWords,
_AssessmentProgress,
_LessonProgress,
_AiContent {
/// Round-trip seam for tests: the real save and load go through
/// `LocalSnapshotStore`, which needs a database.
@visibleForTesting
Map<String, dynamic> snapshotForTest() => _toSnapshotJson();
@visibleForTesting
void restoreForTest(Map<String, dynamic> data) => _restore(data);
static const _storageKey = 'learning_state_v1';
bool isLoaded = false;
bool _writing = false;
@@ -114,6 +135,9 @@ class AppState extends _AppStateData
// Adapt the A1–B1 JSON packs into the runtime registries before the saved
// snapshot is restored, so progress that points at those lessons resolves.
await CourseRepository.instance.load();
// The word bank is independent of the packs, so it loads whatever the
// learner's lesson progress is.
await WordBank.instance.load();
final config = await AiConfigFile.loadFromAsset();
if (config != null) {
if (config.apiKey != null && config.apiKey!.trim().isNotEmpty) {
+10 -2
View File
@@ -2,7 +2,8 @@ part of 'app_state.dart';
/// Position in the seed course, the in-lesson step flow, and the evidence
/// lesson tasks produce.
mixin _LessonProgress on _AppStateData, _ReviewAndMastery, _AssessmentProgress {
mixin _LessonProgress
on _AppStateData, _ReviewAndMastery, _ReceptiveWords, _AssessmentProgress {
bool get hasResumableLessonDialogue =>
dialogueDraft != null &&
dialogueDraft!.lessonId == activeLessonId &&
@@ -259,6 +260,10 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery, _AssessmentProgress {
recordingPath: recordingPath,
assisted: assisted,
);
// The independent attempt closes the teaching segment, so this is where
// the same-day recall is scheduled: the first retrieval happens hours
// later today instead of a full day after teaching.
scheduleSameDayRecap(_activeTargetItemIds);
notifyListeners();
}
@@ -266,6 +271,9 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery, _AssessmentProgress {
if (!lessonCanComplete && !isLessonSegmentsAllComplete(activeLessonId)) {
return;
}
// The unit's recognition-only words count as met once its lesson is
// done; they start at 「新学」 and are asked on the word page.
meetWordsOfUnit(activeLessonId);
completedLessonIds.add(activeLessonId);
completedLessons = completedLessonIds.length;
final next = _nextIncompleteLessonId();
@@ -540,7 +548,7 @@ mixin _LessonProgress on _AppStateData, _ReviewAndMastery, _AssessmentProgress {
if (introduced.firstTaughtAt == null) {
mastery[id] = introduced.copyWith(firstTaughtAt: DateTime.now());
}
if (reviewQueue.any((item) => item.id == id)) continue;
if (_hasCheckpointTask(id)) continue;
final template = coreReviewTemplate(id);
reviewQueue.add(
ReviewItem(
+241 -32
View File
@@ -2,13 +2,55 @@ part of 'app_state.dart';
/// Review queue scheduling and the mastery derived from attempt evidence.
mixin _ReviewAndMastery on _AppStateData {
/// The spaced checkpoint reviews that are due. Same-day recaps are a
/// separate, unbudgeted queue ([dueRecaps]) and never count as backlog.
List<ReviewItem> get dueReviews {
final now = DateTime.now();
final due = reviewQueue.where((item) => !item.dueAt.isAfter(now)).toList()
..sort((a, b) => a.dueAt.compareTo(b.dueAt));
final due =
reviewQueue
.where(
(item) =>
item.kind == ReviewKind.checkpoint &&
!item.dueAt.isAfter(now),
)
.toList()
..sort((a, b) => a.dueAt.compareTo(b.dueAt));
return due;
}
/// Same-day recalls and in-session retries that are ready to be asked. They
/// only strengthen encoding, so they are offered after the checkpoint plan
/// and are dropped once the day they belong to has passed.
List<ReviewItem> get dueRecaps {
final now = DateTime.now();
final cutoff = now.subtract(const Duration(days: 1));
final due =
reviewQueue
.where(
(item) =>
item.kind == ReviewKind.recap &&
!item.dueAt.isAfter(now) &&
item.dueAt.isAfter(cutoff),
)
.toList()
..sort((a, b) => a.dueAt.compareTo(b.dueAt));
return due;
}
/// Learning engine 2.2a: only what fits in today's budget is offered. The
/// rest stay due and come up next time; nothing is dropped or rescheduled
/// behind the learner's back.
List<ReviewItem> get todayReviewPlan =>
dueReviews.take(reviewBudgetSeconds ~/ reviewSecondsPerTask).toList();
/// Due checkpoint items left over after today's budget.
int get postponedReviewCount => dueReviews.length - todayReviewPlan.length;
/// What this review session works through: the budgeted checkpoint tasks
/// first, then the same-day recaps. A retry of an item missed earlier in
/// the session therefore comes back a few tasks later, not immediately.
List<ReviewItem> get reviewSession => [...todayReviewPlan, ...dueRecaps];
int get dueReviewCount => dueReviews.length;
bool get reviewIsPrimary => dueReviewCount > 0;
int get reviewBudgetSeconds => switch (dailyMinutes) {
@@ -16,7 +58,10 @@ mixin _ReviewAndMastery on _AppStateData {
30 => 8 * 60,
_ => 5 * 60,
};
int get dueReviewEstimatedSeconds => dueReviewCount * 60;
/// Planned length of one review task, used for the budget and the backlog.
static const reviewSecondsPerTask = 60;
int get dueReviewEstimatedSeconds => dueReviewCount * reviewSecondsPerTask;
bool get reviewBacklog {
final sevenDaysAgo = DateTime.now().subtract(const Duration(days: 7));
return dueReviewEstimatedSeconds > reviewBudgetSeconds * 2 ||
@@ -80,6 +125,17 @@ mixin _ReviewAndMastery on _AppStateData {
addSavedWord(item);
}
/// The queue slot holding [item]. An item can have both a checkpoint task
/// and a same-day recap in flight, so the kind is part of the key.
int _queueIndexOf(ReviewItem item) => reviewQueue.indexWhere(
(candidate) => candidate.id == item.id && candidate.kind == item.kind,
);
/// Whether [id] already has a spaced checkpoint task queued.
bool _hasCheckpointTask(String id) => reviewQueue.any(
(item) => item.id == id && item.kind == ReviewKind.checkpoint,
);
void completeReview(
ReviewItem item, {
required bool assisted,
@@ -88,12 +144,21 @@ mixin _ReviewAndMastery on _AppStateData {
String? originalTranscript,
String? recordingPath,
}) {
final index = reviewQueue.indexWhere(
(candidate) => candidate.id == item.id,
);
final index = _queueIndexOf(item);
if (index < 0) return;
final current = reviewQueue[index];
final now = DateTime.now();
if (current.kind == ReviewKind.recap) {
_finishRecap(
current,
rawAnswer: rawAnswer,
inputMode: inputMode,
originalTranscript: originalTranscript,
recordingPath: recordingPath,
assisted: assisted,
);
return;
}
// UI/network retries may still hold an old item instance. Only the
// currently due queue entry is allowed to produce evidence.
if (current.dueAt.isAfter(now)) return;
@@ -161,10 +226,14 @@ mixin _ReviewAndMastery on _AppStateData {
}
void reportReviewFailure(ReviewItem item) {
final index = reviewQueue.indexWhere(
(candidate) => candidate.id == item.id,
);
final index = _queueIndexOf(item);
if (index < 0) return;
if (item.kind == ReviewKind.recap) {
// Learning engine 3.1: a same-day recall is encoding practice. Missing
// it shows the answer again and never counts against the item.
_finishRecap(item);
return;
}
final existing =
mastery[item.id] ??
MasteryItem(
@@ -194,13 +263,151 @@ mixin _ReviewAndMastery on _AppStateData {
: item.successfulReviews,
);
_addAttemptEvidence(item, outcome: EvidenceKind.languageError);
_scheduleSessionRetry(item);
notifyListeners();
}
void postponeReview(ReviewItem item) {
final index = reviewQueue.indexWhere(
(candidate) => candidate.id == item.id,
/// Learning engine 3.2: an item missed now comes back once more before the
/// session ends, after the remaining due tasks. That re-ask is practice —
/// it neither advances a checkpoint nor counts as a second failure — so the
/// next real check still happens tomorrow on a different task.
void _scheduleSessionRetry(ReviewItem item) {
if (reviewQueue.any(
(candidate) => candidate.id == item.id && candidate.kind == ReviewKind.recap,
)) {
return;
}
reviewQueue.add(
ReviewItem(
id: item.id,
target: item.target,
prompt: item.prompt,
hint: item.hint,
dueAt: DateTime.now(),
skill: item.skill,
kind: ReviewKind.recap,
),
);
}
/// Learning engine 2.2/3.1: one short recall of what was just taught, a
/// couple of hours later on the same day. It is the item's first retrieval
/// outside the lesson it was taught in; the first checkpoint still falls on
/// the next day. Only exposure is recorded, so no checkpoint moves and the
/// day's single checkpoint advance stays available for the real review.
void scheduleSameDayRecap(Iterable<String> itemIds) {
final now = DateTime.now();
// Yesterday's uncollected recaps are gone: they belonged to that day.
reviewQueue.removeWhere(
(item) =>
item.kind == ReviewKind.recap &&
item.dueAt.isBefore(now.subtract(const Duration(days: 1))),
);
var added = false;
for (final id in itemIds) {
if (!isCoreItem(id)) continue;
if (reviewQueue.any(
(item) => item.id == id && item.kind == ReviewKind.recap,
)) {
continue;
}
final template = coreReviewTemplate(id);
reviewQueue.add(
ReviewItem(
id: id,
target: coreItemLabel(id),
prompt: template.prompt,
hint: template.hint,
dueAt: now.add(const Duration(hours: 2)),
skill: template.skill,
kind: ReviewKind.recap,
),
);
added = true;
}
if (added) {
notifyListeners();
_syncInBackground();
}
}
/// Learning engine 3.1: the recognition step in front of the first
/// checkpoint review — hear or read the item, pick its meaning. Passing it
/// records 「认识」 evidence, which nothing else in the review flow produced;
/// the checkpoint itself is still earned by producing the item right after.
/// A miss is not a language failure: it shows the meaning and the learner
/// continues to the production task with help.
void recordRecognitionGate(ReviewItem item, {required bool correct}) {
final outcome = correct
? EvidenceKind.independentSuccess
: EvidenceKind.exposure;
_addAttemptEvidence(
item.copyWith(skill: recognitionGateSkill),
outcome: outcome,
assisted: !correct,
);
// Exposure neither raises the status nor clears a pending re-check, so a
// miss here leaves the item exactly where it was.
_recordEvidence(item.id, outcome);
notifyListeners();
}
/// Whether [item] should open with the recognition question: the first
/// checkpoint of a core item, one day after it was taught.
bool needsRecognitionGate(ReviewItem item) =>
item.kind == ReviewKind.checkpoint &&
isCoreItem(item.id) &&
(mastery[item.id]?.checkpoint ?? 0) == 0 &&
recognitionDistractors(item.id).isNotEmpty;
/// The remediation after a missed review: the course explains when the item
/// is used, then asks which taught sentence a situation calls for. Answering
/// it is 「认识」 evidence and never advances a checkpoint — the miss that led
/// here has already been recorded, and re-asking on the spot is not an
/// independent interval (learning engine 3.1).
void recordContrastAnswer(ReviewItem item, {required bool correct}) {
final outcome = correct
? EvidenceKind.independentSuccess
: EvidenceKind.exposure;
_addAttemptEvidence(
item.copyWith(skill: contrastSkill),
outcome: outcome,
assisted: !correct,
);
_recordEvidence(item.id, outcome);
notifyListeners();
}
/// Closes a recap: it records the attempt as exposure and leaves the queue.
/// Exposure keeps it out of both the checkpoint count and the mastery
/// status, which is what makes a same-day recall safe to repeat.
void _finishRecap(
ReviewItem item, {
String? rawAnswer,
String inputMode = 'text',
String? originalTranscript,
String? recordingPath,
bool assisted = false,
}) {
reviewQueue.removeWhere(
(candidate) =>
candidate.id == item.id && candidate.kind == ReviewKind.recap,
);
_addAttemptEvidence(
item,
outcome: EvidenceKind.exposure,
assisted: assisted,
rawAnswer: rawAnswer,
inputMode: inputMode,
originalTranscript: originalTranscript,
recordingPath: recordingPath,
);
notifyListeners();
_syncInBackground();
}
void postponeReview(ReviewItem item) {
final index = _queueIndexOf(item);
if (index < 0) return;
reviewQueue[index] = item.copyWith(
dueAt: DateTime.now().add(const Duration(days: 1)),
@@ -241,20 +448,9 @@ mixin _ReviewAndMastery on _AppStateData {
);
}
void addSavedWord(VocabularyItem item) {
if (reviewQueue.any((review) => review.id == item.id)) return;
reviewQueue.add(
ReviewItem(
id: item.id,
target: item.word,
prompt: item.example,
hint: item.meaning,
dueAt: DateTime.now().add(const Duration(days: 1)),
skill: '认识与回忆',
),
);
notifyListeners();
}
/// Implemented by [_ReceptiveWords]: a saved word is recognition-only, so
/// it never joins the checkpoint queue.
void addSavedWord(VocabularyItem item);
/// Adds one low-priority, non-core recap based on a completed independent
/// dialogue. It is deliberately separate from A0 denominator items.
@@ -263,7 +459,7 @@ mixin _ReviewAndMastery on _AppStateData {
final day =
'${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}';
final id = 'dialogue-$sceneId-$day';
if (reviewQueue.any((item) => item.id == id)) return;
if (_hasCheckpointTask(id)) return;
reviewQueue.add(
ReviewItem(
id: id,
@@ -294,7 +490,9 @@ mixin _ReviewAndMastery on _AppStateData {
});
if (candidates.isEmpty) return;
final id = candidates.first;
final index = reviewQueue.indexWhere((item) => item.id == id);
final index = reviewQueue.indexWhere(
(item) => item.id == id && item.kind == ReviewKind.checkpoint,
);
if (index >= 0) {
final current = reviewQueue[index];
final template = coreReviewVariant(id, current.variantIndex + 1);
@@ -324,7 +522,9 @@ mixin _ReviewAndMastery on _AppStateData {
void applyGeneratedReviewVariant(GeneratedReviewVariant variant) {
final index = reviewQueue.indexWhere(
(item) => item.id == variant.targetItemId,
(item) =>
item.id == variant.targetItemId &&
item.kind == ReviewKind.checkpoint,
);
if (index < 0) return;
final current = reviewQueue[index];
@@ -346,9 +546,7 @@ mixin _ReviewAndMastery on _AppStateData {
/// its checkpoint contribution is removed; the stable core item remains.
void reportGeneratedReviewVariant(ReviewItem item) {
if (!item.isAiGenerated) return;
final index = reviewQueue.indexWhere(
(candidate) => candidate.id == item.id,
);
final index = _queueIndexOf(item);
if (index < 0) return;
final key = '${item.id}:${item.variantIndex}';
if (!reportedAiVariantKeys.add(key)) return;
@@ -393,6 +591,7 @@ mixin _ReviewAndMastery on _AppStateData {
}
for (var index = 0; index < reviewQueue.length; index++) {
final item = reviewQueue[index];
if (item.kind == ReviewKind.recap) continue;
final rebuilt = mastery[item.id];
if (rebuilt != null) {
reviewQueue[index] = item.copyWith(
@@ -419,6 +618,12 @@ mixin _ReviewAndMastery on _AppStateData {
firstTaughtAt = event.createdAt;
}
if (!isReview) continue;
// The recognition gate and the contrast question are 「认识」 evidence
// only; producing the item is what earns a checkpoint.
if (event.skill == recognitionGateSkill ||
event.skill == contrastSkill) {
continue;
}
if (event.outcome == EvidenceKind.independentSuccess) {
final day =
'${event.createdAt.year}-${event.createdAt.month}-${event.createdAt.day}';
@@ -487,6 +692,10 @@ mixin _ReviewAndMastery on _AppStateData {
'听力理解',
'阅读理解',
dictationSkill,
// Picking the right sentence for a situation shows the learner knows when
// to use it. Producing it unaided is a separate, higher bar, so this is
// recognition evidence and never stands in for recall.
contrastSkill,
};
/// Controlled lesson steps (follow-reading, scripted dialogue) are practice,
@@ -194,6 +194,63 @@ extension _AppStateSnapshot on AppState {
),
);
}
final storedWords = data['savedWords'] as List<dynamic>?;
if (storedWords != null) {
savedWords
..clear()
..addEntries(
storedWords.whereType<Map<String, dynamic>>().map(
(item) => MapEntry(item['id'] as String, (
level: item['level'] as String? ?? 'A0',
unit: '',
en: item['en'] as String? ?? '',
zh: item['zh'] as String? ?? '',
ipa: item['ipa'] as String? ?? '',
)),
),
);
restoreSavedWords();
}
final savedWordStates = data['wordKnowledge'] as List<dynamic>?;
if (savedWordStates != null) {
wordKnowledge
..clear()
..addEntries(
savedWordStates.whereType<Map<String, dynamic>>().map((item) {
final id = item['id'] as String;
return MapEntry(
id,
WordKnowledge(
id: id,
status: _enumValue(
WordStatus.values,
item['status'] as String?,
WordStatus.newWord,
),
recognizedAt: DateTime.tryParse(
item['recognizedAt'] as String? ?? '',
),
dueAt: DateTime.tryParse(item['dueAt'] as String? ?? ''),
misses: item['misses'] as int? ?? 0,
// Snapshots written before the spacing ladder carry no step;
// place them on the rung their status implies.
step: item['step'] as int? ?? switch (_enumValue(
WordStatus.values,
item['status'] as String?,
WordStatus.newWord,
)) {
WordStatus.newWord => 0,
WordStatus.recognized => 1,
WordStatus.familiar => 4,
},
startedAt: DateTime.tryParse(
item['startedAt'] as String? ?? '',
),
),
);
}),
);
}
final savedMastery = data['mastery'] as List<dynamic>?;
if (savedMastery != null) {
mastery
@@ -357,6 +414,7 @@ extension _AppStateSnapshot on AppState {
'hint': item.hint,
'dueAt': item.dueAt.toIso8601String(),
'skill': item.skill,
'kind': item.kind.name,
'attempts': item.attempts,
'successfulReviews': item.successfulReviews,
'variantIndex': item.variantIndex,
@@ -365,6 +423,17 @@ extension _AppStateSnapshot on AppState {
},
)
.toList(),
'wordKnowledge': wordKnowledge.values.map((w) => w.toJson()).toList(),
'savedWords': [
for (final entry in savedWords.entries)
{
'id': entry.key,
'en': entry.value.en,
'zh': entry.value.zh,
'level': entry.value.level,
'ipa': entry.value.ipa,
},
],
'mastery': mastery.values
.map(
(item) => {
@@ -480,6 +549,7 @@ ReviewItem _reviewFromJson(Map<String, dynamic> data) => ReviewItem(
hint: data['hint'] as String,
dueAt: DateTime.tryParse(data['dueAt'] as String? ?? '') ?? DateTime.now(),
skill: data['skill'] as String,
kind: _enumValue(ReviewKind.values, data['kind'] as String?, ReviewKind.checkpoint),
attempts: data['attempts'] as int? ?? 0,
successfulReviews: data['successfulReviews'] as int? ?? 0,
variantIndex: data['variantIndex'] as int? ?? 0,
+354
View File
@@ -0,0 +1,354 @@
part of 'app_state.dart';
/// Learning engine 3.5: the recognition-only layer. These words are never
/// asked to be produced, they never enter a level's upgrade denominator, and
/// they get their own three states instead of the five of a core item.
mixin _ReceptiveWords on _AppStateData {
/// 「认识」 holds for a week before a second, different question can turn it
/// into 「熟悉」; after that the word is only spot-checked monthly.
/// Days to the next check after each success in a row. The early rungs are
/// short on purpose: a word asked once and then not again for a week is a
/// word that has to be learned from scratch the second time.
static const _ladderDays = [1, 3, 7, 15, 30, 60, 120];
/// The rung at which recognition counts as 「熟悉」. Reaching it takes
/// 1 + 3 + 7 = 11 days at the fastest, which satisfies the spec's "a second
/// success at least seven days later".
static const _familiarStep = 4;
static const _firstGap = Duration(days: 7);
/// One recognition question is 5–10 seconds, so the plan is sized in those
/// rather than in the minute a produced review takes.
static const wordSecondsPerTask = 10;
/// Records that a word was met in a lesson. It starts as 「新学」 with no
/// question scheduled: meeting a word is exposure, not recognition.
void meetWord(String id) {
if (!isReceptiveWord(id) || wordKnowledge.containsKey(id)) return;
wordKnowledge[id] = WordKnowledge(
id: id,
status: WordStatus.newWord,
startedAt: DateTime.now(),
);
notifyListeners();
}
/// Learning engine 3.5: a word saved from lookup is an extension word. It
/// follows the same recognition-only rules as a unit's receptive words and
/// never enters the checkpoint queue.
void addSavedWord(VocabularyItem item) {
if (savedWords.containsKey(item.id)) return;
final level = allLessons
.where((lesson) => lesson.id == activeLessonId)
.map((lesson) => lesson.level)
.firstOrNull;
savedWords[item.id] = (
level: level ?? 'A0',
unit: '',
en: item.word,
zh: item.meaning,
ipa: item.ipa ?? '',
);
_registerSavedWords();
wordKnowledge[item.id] = WordKnowledge(
id: item.id,
status: WordStatus.newWord,
);
notifyListeners();
}
/// Puts the saved words back into the registry, which is otherwise built
/// from the course packs alone.
void _registerSavedWords() {
for (final entry in savedWords.entries) {
registerSavedWord(
entry.key,
en: entry.value.en,
zh: entry.value.zh,
level: entry.value.level,
);
}
}
void restoreSavedWords() => _registerSavedWords();
void meetWordsOfUnit(String unit) {
var added = false;
for (final id in receptiveWordsOfUnit(unit)) {
if (wordKnowledge.containsKey(id)) continue;
wordKnowledge[id] = WordKnowledge(id: id, status: WordStatus.newWord);
added = true;
}
if (added) notifyListeners();
}
/// One independent recognition attempt.
///
/// A success moves 「新学」 to 「认识」; it only reaches 「熟悉」 when the
/// success comes at least seven days after the first one. A miss drops the
/// word back to 「认识」 and asks again tomorrow with another question —
/// there is no diagnosis and no further downgrade.
void recordWordRecognition(String id, {required bool correct}) {
if (!isReceptiveWord(id)) return;
final now = DateTime.now();
final current =
wordKnowledge[id] ?? WordKnowledge(id: id, status: WordStatus.newWord);
if (!correct) {
// A miss drops the word to the bottom of the ladder and asks again
// tomorrow. It never falls below 「认识」 once earned: a single lapse
// does not mean the word was never known (learning engine 3.5).
wordKnowledge[id] = WordKnowledge(
id: id,
status: current.recognizedAt == null
? WordStatus.newWord
: WordStatus.recognized,
recognizedAt: current.recognizedAt,
dueAt: _tomorrow(now),
misses: current.misses + 1,
startedAt: current.startedAt,
);
notifyListeners();
return;
}
final step = current.step + 1;
final earned = current.recognizedAt ?? now;
wordKnowledge[id] = WordKnowledge(
id: id,
status: _statusFor(step, earned, now),
recognizedAt: earned,
dueAt: now.add(Duration(days: _gapDays(step))),
misses: current.misses,
step: step,
// Carried over, or answering a word would take it back out of today's
// count and the daily cap could be spent over and over in one sitting.
startedAt: current.startedAt,
);
notifyListeners();
}
/// The ladder rung for the nth success in a row, holding at the last one.
static int _gapDays(int step) =>
_ladderDays[(step < _ladderDays.length ? step : _ladderDays.length) - 1];
/// 「熟悉」 needs both enough rungs and enough calendar: a learner who let
/// the word sit for weeks between checks gets there on the same rung, but
/// nobody gets there in three days.
static WordStatus _statusFor(int step, DateTime earned, DateTime now) {
if (step == 0) return WordStatus.newWord;
if (step >= _familiarStep && !now.isBefore(earned.add(_firstGap))) {
return WordStatus.familiar;
}
return WordStatus.recognized;
}
DateTime _tomorrow(DateTime now) =>
DateTime(now.year, now.month, now.day).add(const Duration(days: 1, hours: 9));
/// Words whose check has come due, weakest first.
List<String> get dueWords {
final now = DateTime.now();
final due = wordKnowledge.values
.where((word) => word.dueAt != null && !word.dueAt!.isAfter(now))
.toList()
..sort((a, b) {
if (a.misses != b.misses) return b.misses.compareTo(a.misses);
return a.dueAt!.compareTo(b.dueAt!);
});
return [for (final word in due) word.id];
}
/// Words met but never asked yet — the queue the word page offers first.
List<String> get unaskedWords => [
for (final word in wordKnowledge.values)
if (word.status == WordStatus.newWord && word.dueAt == null) word.id,
];
/// Words that have been missed and are not 「熟悉」 yet.
List<String> get shakyWords {
final shaky = wordKnowledge.values
.where((word) => word.misses > 0 && word.status != WordStatus.familiar)
.toList()
..sort((a, b) => b.misses.compareTo(a.misses));
return [for (final word in shaky) word.id];
}
/// Learning engine 3.5: word recognition takes no more than a third of the
/// daily review budget. This caps only what is *inserted into a review
/// session*; the word page is opened on purpose and is not budgeted.
int get wordBudgetSeconds => reviewBudgetSeconds ~/ 3;
int get wordPlanSize => wordBudgetSeconds ~/ wordSecondsPerTask;
/// Which way round this word's next question is asked.
///
/// A word is always introduced in writing: hearing a word you have never
/// seen spelled gives you nothing to hold on to, and the day after a miss is
/// no time to take the spelling away either. From the first success on the
/// three rotate — see it, hear it, then produce it from the meaning — so a
/// word is not called known on the strength of one direction. Recognising a
/// word on the page is not the same as catching it in speech, and neither
/// one means it comes to mind when you need it.
WordAskMode wordAskMode(String id) {
final step = wordKnowledge[id]?.step ?? 0;
return WordAskMode.values[step % WordAskMode.values.length];
}
/// How many questions one round on the word page asks.
static const wordSessionSize = 20;
/// New words the bank may hand out in a day.
///
/// Without a cap, one sitting can start a hundred words and every one of
/// them comes back the next day, which is the pile that made them not stick
/// in the first place. The ladder's first rung is one day, so today's new
/// words are tomorrow's workload.
int get newWordsPerDay => switch (dailyMinutes) {
10 => 8,
30 => 20,
_ => 15,
};
/// New words already started today, whatever they came from.
int get newWordsStartedToday {
final now = DateTime.now();
return wordKnowledge.values.where((word) {
final started = word.startedAt;
return started != null &&
started.year == now.year &&
started.month == now.month &&
started.day == now.day;
}).length;
}
/// New words the bank may still hand out today.
int get newWordQuota {
final left = newWordsPerDay - newWordsStartedToday;
return left > 0 ? left : 0;
}
/// Bank words not started yet: easiest level first, and inside a level the
/// most common in spoken English first. The bank is independent of lesson
/// progress, so these are available from day one.
List<String> get unstartedBankWords {
final byLevel = <String, List<String>>{};
for (final entry in receptiveWordRegistry.entries) {
if (!WordBank.isBankUnit(entry.value.unit)) continue;
if (wordKnowledge.containsKey(entry.key)) continue;
byLevel.putIfAbsent(entry.value.level, () => []).add(entry.key);
}
final bank = WordBank.instance;
final ordered = <String>[];
for (final level in const ['A1', 'A2', 'B1']) {
final words = byLevel[level];
if (words == null) continue;
words.sort((a, b) {
final byRank = bank.rankOf(a).compareTo(bank.rankOf(b));
return byRank != 0 ? byRank : a.compareTo(b);
});
ordered.addAll(words);
}
return ordered;
}
/// Counted without building and sorting the ordered list: the home card asks
/// for this on every rebuild and the bank is a few thousand words.
int get unstartedBankWordCount {
var count = 0;
for (final entry in receptiveWordRegistry.entries) {
if (!WordBank.isBankUnit(entry.value.unit)) continue;
if (wordKnowledge.containsKey(entry.key)) continue;
count += 1;
}
return count;
}
/// Minutes a round of [size] questions takes, rounded up, for the cards that
/// offer one.
int wordSessionMinutes(int size) => (size * wordSecondsPerTask + 59) ~/ 60;
/// How many questions [wordSession] would hand out, without starting any
/// new word: the list view needs the number before the learner taps.
int get wordSessionPreviewSize {
final fresh = unstartedBankWordCount < newWordQuota
? unstartedBankWordCount
: newWordQuota;
final ready = {...dueWords, ...unaskedWords}.length + fresh;
return ready < wordSessionSize ? ready : wordSessionSize;
}
/// One round on the word page: what is due first, then words met in a unit
/// but never asked, then new words from the bank.
///
/// A new word is registered as it is handed out, so a round the learner
/// never finishes does not fill the list with words they have not seen.
List<WordQuestion> wordSession({int size = wordSessionSize}) {
final plan = <WordQuestion>[];
final seen = <String>{};
var quota = newWordQuota;
void take(Iterable<String> ids, {bool start = false}) {
for (final id in ids) {
if (plan.length >= size) return;
if (start && quota <= 0) return;
if (!seen.add(id)) continue;
final question = receptiveQuestion(
id,
variant: wordKnowledge[id]?.misses ?? 0,
mode: wordAskMode(id),
);
if (question == null) continue;
if (start) {
wordKnowledge[id] = WordKnowledge(
id: id,
status: WordStatus.newWord,
startedAt: DateTime.now(),
);
quota -= 1;
}
plan.add(question);
}
}
take(dueWords);
take(unaskedWords);
take(unstartedBankWords, start: true);
return plan;
}
/// Today's word questions: what is due first, then words never asked.
List<WordQuestion> get todayWordPlan {
final plan = <WordQuestion>[];
for (final id in [...dueWords, ...unaskedWords]) {
if (plan.length >= wordPlanSize) break;
final question = receptiveQuestion(
id,
variant: wordKnowledge[id]?.misses ?? 0,
mode: wordAskMode(id),
);
if (question != null) plan.add(question);
}
return plan;
}
int get dueWordCount => dueWords.length;
WordStatus wordStatus(String id) =>
wordKnowledge[id]?.status ?? WordStatus.newWord;
/// Every word met, grouped by the unit that taught it, in unit order.
Map<String, List<String>> get wordsByUnit {
final grouped = <String, List<String>>{};
for (final id in wordKnowledge.keys) {
grouped.putIfAbsent(wordUnit(id), () => []).add(id);
}
final units = grouped.keys.toList()..sort();
return {for (final unit in units) unit: grouped[unit]!..sort()};
}
int get familiarWordCount => wordKnowledge.values
.where((word) => word.status == WordStatus.familiar)
.length;
int get recognizedWordCount => wordKnowledge.values
.where((word) => word.status == WordStatus.recognized)
.length;
int get reviewBudgetSeconds;
}
+352 -6
View File
@@ -1,8 +1,13 @@
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});
/// A produced core item as the registries see it: the pack that teaches it,
/// its level, and its English and Chinese forms.
typedef RegisteredCoreItem = ({
String level,
String unit,
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
@@ -21,6 +26,192 @@ Map<String, String> get a0CoreItems => {
if (entry.value.level == 'A0') entry.key: entry.value.en,
};
/// A recognition-only word as the registries see it. Learning engine 3.5
/// keeps these apart from produced core items: they are heard and read, never
/// required to be said or spelled, and they do not count toward a level's
/// upgrade denominator.
typedef RegisteredReceptiveWord = ({
String level,
String unit,
String en,
String zh,
String ipa,
});
final Map<String, RegisteredReceptiveWord> receptiveWordRegistry =
<String, RegisteredReceptiveWord>{};
void registerReceptiveWord(
ReceptiveWord word, {
required String level,
required String unit,
}) {
receptiveWordRegistry[word.id] = (
level: level,
unit: unit,
en: word.en,
zh: word.zh,
ipa: '',
);
}
/// Registers a word saved from lookup. It has no unit, so its distractors
/// come from the same level.
void registerSavedWord(
String id, {
required String en,
required String zh,
required String level,
}) {
receptiveWordRegistry[id] = (
level: level,
unit: '',
en: en,
zh: zh,
ipa: '',
);
}
/// Registers one word of the standalone word bank. Unlike a unit's receptive
/// words, these do not wait for a lesson to be finished.
void registerBankWord(
String id, {
required String en,
required String zh,
required String ipa,
required String level,
required String unit,
}) {
receptiveWordRegistry[id] = (
level: level,
unit: unit,
en: en,
zh: zh,
ipa: ipa,
);
}
/// The IPA of a word, empty when none is known.
String wordIpa(String id) => receptiveWordRegistry[id]?.ipa ?? '';
/// Whether [id] is a recognition-only word.
bool isReceptiveWord(String id) => receptiveWordRegistry.containsKey(id);
/// The English of a word of either layer.
String wordEnglish(String id) =>
receptiveWordRegistry[id]?.en ?? coreItemEnglish(id);
/// The Chinese of a word of either layer.
String wordMeaning(String id) =>
receptiveWordRegistry[id]?.zh ?? coreItemMeaning(id);
/// The unit that teaches [id], in either layer; empty when nothing does.
String wordUnit(String id) =>
receptiveWordRegistry[id]?.unit ?? coreItemRegistry[id]?.unit ?? '';
/// The receptive words a unit teaches, in pack order.
List<String> receptiveWordsOfUnit(String unit) => [
for (final entry in receptiveWordRegistry.entries)
if (entry.value.unit == unit) entry.key,
];
/// Wrong answers for a receptive word's recognition question, as word ids,
/// from the same unit first, then the same level (learning engine 3.5:
/// distractors come from the same unit or from near meanings). The caller
/// takes the meaning or the spelling off each, depending on which way round
/// it is asking.
///
/// Words whose meaning overlaps the target's are skipped, and no two options
/// share a meaning, so a question never has two right answers. That matters
/// most when the choice is between spellings: the meaning on screen is then
/// the only thing to go on.
///
/// A word saved from lookup can be the only one of its level, so anything else
/// in the registry is taken rather than leaving the word unaskable.
List<String> receptiveDistractors(String id, {int count = 2}) {
final target = receptiveWordRegistry[id];
if (target == null) return const [];
final sameUnit = <String>[];
final sameLevel = <String>[];
final rest = <String>[];
final taken = <String>{};
for (final entry in receptiveWordRegistry.entries) {
if (entry.key == id) continue;
final option = entry.value.zh.trim();
if (option.isEmpty || entry.value.en.trim().isEmpty) continue;
if (option == target.zh.trim()) continue;
if (option.contains(target.zh) || target.zh.contains(option)) continue;
if (!taken.add(option)) continue;
if (entry.value.unit == target.unit) {
sameUnit.add(entry.key);
} else if (entry.value.level == target.level) {
sameLevel.add(entry.key);
} else if (rest.length < count) {
rest.add(entry.key);
}
}
return [...sameUnit, ...sameLevel, ...rest].take(count).toList();
}
/// Which way round the word is asked.
///
/// Learning engine 3.5 counts 「听辨或阅读」 as recognition, and a word that
/// can only be recognised in writing is half learned — spoken English arrives
/// as sound. [recall] goes the other way, from the meaning to the word; it
/// stays a choice among spellings rather than something to write, so the word
/// is still only ever recognised and never enters the production ladder.
enum WordAskMode { read, listen, recall }
/// A 5–10 second recognition question for a receptive word (learning engine
/// 3.5).
///
/// [shown] is the prompt: the English for [WordAskMode.read], the Chinese for
/// [WordAskMode.recall]. A [WordAskMode.listen] question carries the English
/// too — it is what the synthesiser speaks and what the feedback shows — and
/// only hides it while the question is open. The English is always available
/// from the id through [wordEnglish], which is what the page speaks.
typedef WordQuestion = ({
String id,
String shown,
String answer,
List<String> options,
WordAskMode mode,
});
/// Null when the word has no distractors to offer, which means it cannot be
/// asked as a choice yet.
WordQuestion? receptiveQuestion(
String id, {
int variant = 0,
WordAskMode mode = WordAskMode.read,
}) {
final word = receptiveWordRegistry[id];
if (word == null || word.en.isEmpty || word.zh.isEmpty) return null;
final wrong = receptiveDistractors(id, count: 3);
if (wrong.isEmpty) return null;
// A different variant asks the same word against other meanings, which is
// what 「换题、换语境」 means for a word this small.
final rotated = [...wrong.skip(variant % wrong.length), ...wrong.take(variant % wrong.length)]
.take(2)
.toList();
// Asked from the meaning, the options are spellings and the prompt is the
// Chinese; asked either other way, it is the other way round.
final asksSpelling = mode == WordAskMode.recall;
String optionOf(String other) => asksSpelling
? receptiveWordRegistry[other]!.en
: receptiveWordRegistry[other]!.zh;
final answer = asksSpelling ? word.en : word.zh;
final options = [answer, ...rotated.map(optionOf)];
final offset = (id.hashCode.abs() + variant) % options.length;
return (
id: id,
shown: asksSpelling ? word.zh : word.en,
answer: answer,
options: [...options.skip(offset), ...options.take(offset)],
mode: mode,
);
}
class CoreReviewTemplate {
const CoreReviewTemplate({
required this.prompt,
@@ -43,9 +234,23 @@ 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);
/// How to use each core item that needs explaining, straight from the pack's
/// `usage`. Items whose meaning is the whole story (`book`, `Monday`) have no
/// entry.
final Map<String, CoreUsage> coreUsages = <String, CoreUsage>{};
/// Registers [item], taught in [unit] at [level], with its review data.
void registerCoreItem(
CoreItem item, {
required String level,
String unit = '',
}) {
coreItemRegistry[item.id] = (
level: level,
unit: unit,
en: item.en,
zh: item.zh,
);
coreItemMatch[item.id] = item.match;
coreReviewTemplates[item.id] = CoreReviewTemplate(
prompt: item.reviewPrompt.isNotEmpty
@@ -55,6 +260,7 @@ void registerCoreItem(CoreItem item, {required String level}) {
skill: item.type == 'word' ? '词汇回忆' : '回忆表达',
);
if (item.reviewHint.isNotEmpty) coreReviewHints[item.id] = item.reviewHint;
if (!item.usage.isEmpty) coreUsages[item.id] = item.usage;
if (item.dictationSentence.isNotEmpty) {
coreDictationSentences[item.id] = (
sentence: item.dictationSentence,
@@ -70,6 +276,8 @@ void clearCoreItems() {
coreReviewTemplates.clear();
coreReviewHints.clear();
coreDictationSentences.clear();
coreUsages.clear();
receptiveWordRegistry.clear();
}
/// The label a learner sees for a core item, falling back to its id. A0
@@ -85,6 +293,12 @@ String coreItemLabel(String id) {
/// English use this.
String coreItemEnglish(String id) => coreItemRegistry[id]?.en ?? id;
/// The item as a learner would say it. A pattern is registered with its slot
/// showing — `I'm [state].` — which is right for a matching rule and wrong to
/// read, so the pack's own example sentence stands in when there is one.
String coreItemSpoken(String id) =>
coreDictationSentences[id]?.sentence ?? coreItemEnglish(id);
/// Whether [id] is a produced core item the course tracks for mastery.
bool isCoreItem(String id) => coreItemRegistry.containsKey(id);
@@ -121,6 +335,138 @@ CoreReviewTemplate coreReviewVariant(String id, int variantIndex) {
const dictationSkill = '听写';
const spokenRecallSkill = '口头回忆';
/// The skill of the recognition question asked before the first checkpoint
/// review. Passing it is 「认识」 evidence and never advances a checkpoint.
const recognitionGateSkill = '听辨识别';
/// The skill of a contrast question: a situation is described and the learner
/// picks which taught sentence it calls for. It shows the learner knows *when*
/// to say something, which is not the same as being able to produce it, so it
/// counts as recognition evidence and never advances a checkpoint on its own.
const contrastSkill = '情境辨析';
/// Whether two core items are taught in the same unit.
bool _sameUnit(String a, String b) {
final unit = coreItemRegistry[a]?.unit ?? '';
return unit.isNotEmpty && unit == coreItemRegistry[b]?.unit;
}
/// The confusables of [id] worth showing a learner now. A pack may pair an
/// item with one taught several units later — `What's your name?` against
/// `Where are you from?` — and naming a sentence the learner has never seen
/// explains nothing, so a pairing only appears once the other item is in the
/// same unit or has been taught. Passing no [isTaught] keeps them all, which
/// is what content checks want.
List<Confusable> visibleConfusables(
String id, {
bool Function(String id)? isTaught,
}) => [
for (final other in coreUsages[id]?.confuse ?? const <Confusable>[])
if (coreItemRegistry.containsKey(other.id) &&
(isTaught == null || _sameUnit(id, other.id) || isTaught(other.id)))
other,
];
/// How to use [id], or null when the pack explains nothing about it.
CoreUsage? coreUsage(String id) => coreUsages[id];
/// A contrast question built from an item's `confuse` list: the situation from
/// its own `usage.when`, the taught sentences as options, and the note that
/// explains the difference after an answer.
typedef ContrastQuestion = ({
String situation,
String answer,
List<String> options,
String note,
});
ContrastQuestion? coreContrastQuestion(
String id, {
bool Function(String id)? isTaught,
}) {
final usage = coreUsages[id];
if (usage == null || usage.when.isEmpty) return null;
final answer = coreItemSpoken(id);
final notes = <String>[];
final options = <String>{answer};
for (final other in visibleConfusables(id, isTaught: isTaught)) {
// Two wrong options is already a real choice; a longer list turns telling
// two sentences apart into a reading exercise.
if (options.length >= 3) break;
final english = coreItemSpoken(other.id);
if (english.isEmpty || english == answer) continue;
options.add(english);
if (other.note.isNotEmpty) notes.add(other.note);
}
if (options.length < 2) return null;
final ordered = options.toList();
final offset = id.hashCode.abs() % ordered.length;
return (
situation: usage.when,
answer: answer,
options: [...ordered.skip(offset), ...ordered.take(offset)],
note: notes.join('\n'),
);
}
/// What the item means, as a learner would read it: the Chinese form when the
/// pack has one, otherwise the reviewed situation the phrase is used in.
String coreItemMeaning(String id) {
final zh = coreItemRegistry[id]?.zh.trim() ?? '';
if (zh.isNotEmpty) return zh;
final prompt = coreReviewTemplates[id]?.prompt.trim() ?? '';
return prompt.isNotEmpty ? prompt : coreItemEnglish(id);
}
/// A recognition question: what the learner hears or reads, and what the
/// right option says. Learning engine 3.5 asks for recognition inside a
/// sentence or a situation, so the item's dictation sentence is used whenever
/// the pack has one. That also keeps near-synonyms apart: `hello` and `hi`
/// both mean 你好 on their own, but `Hello, how are you?` and
/// `Hi, what's your name?` do not.
typedef RecognitionQuestion = ({String shown, String answer});
RecognitionQuestion coreRecognitionQuestion(String id) {
final dictation = coreDictationSentences[id];
if (dictation != null && dictation.meaning.trim().isNotEmpty) {
return (shown: dictation.sentence, answer: dictation.meaning);
}
return (shown: coreItemEnglish(id), answer: coreItemMeaning(id));
}
/// Wrong options for a recognition question, taken from other core items in
/// the same unit (learning engine 3.5: distractors come from the same unit or
/// from near meanings, never from obviously unrelated words). Options are
/// asked in the same form as the answer — sentence against sentence — so the
/// right one cannot be spotted by its length alone.
List<String> recognitionDistractors(String id, {int count = 2}) {
final target = coreItemRegistry[id];
if (target == null) return const [];
final question = coreRecognitionQuestion(id);
final sentenceForm = coreDictationSentences[id] != null;
final sameUnit = <String>[];
final sameLevel = <String>[];
for (final entry in coreItemRegistry.entries) {
if (entry.key == id) continue;
if ((coreDictationSentences[entry.key] != null) != sentenceForm) continue;
final option = coreRecognitionQuestion(entry.key).answer;
if (option.isEmpty) continue;
// A distractor that contains the answer, or is contained by it, would be
// right too — `嗨,你叫什么名字?` against `你叫什么名字?`.
if (option.contains(question.answer) || question.answer.contains(option)) {
continue;
}
if (target.unit.isNotEmpty && entry.value.unit == target.unit) {
sameUnit.add(option);
} else if (entry.value.level == target.level) {
sameLevel.add(option);
}
}
final options = <String>{...sameUnit, ...sameLevel};
return options.take(count).toList();
}
/// 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});
@@ -313,6 +313,56 @@ class PackSegment {
}
}
/// One taught item that is easy to mistake for another, with the difference
/// spelled out. `A0-P02` (`What's your name?`) lists `A0-P05`
/// (`How are you?`), because knowing both sentences is not the same as
/// knowing which one a situation calls for.
class Confusable {
const Confusable({required this.id, required this.note});
final String id;
final String note;
factory Confusable.fromJson(Map<String, dynamic> json) => Confusable(
id: json['id'] as String? ?? '',
note: json['note'] as String? ?? '',
);
}
/// How a pattern is used, as opposed to what it means: the situation it
/// belongs to, the reply it invites, an alternative wording, and the taught
/// items it is confused with. The course JSON carries this; nothing here is
/// generated at runtime.
class CoreUsage {
const CoreUsage({
this.when = '',
this.reply = '',
this.swap = '',
this.confuse = const [],
});
/// The situation the item is used in, in one line.
final String when;
/// What the other person usually answers, or how to answer it.
final String reply;
/// Another taught way to say the same thing.
final String swap;
final List<Confusable> confuse;
bool get isEmpty =>
when.isEmpty && reply.isEmpty && swap.isEmpty && confuse.isEmpty;
factory CoreUsage.fromJson(Map<String, dynamic> json) => CoreUsage(
when: json['when'] as String? ?? '',
reply: json['reply'] as String? ?? '',
swap: json['swap'] as String? ?? '',
confuse: _objectList(json['confuse']).map(Confusable.fromJson).toList(),
);
}
/// A produced core item (word / phrase / pattern) with its acceptance rule.
class CoreItem {
const CoreItem({
@@ -327,6 +377,7 @@ class CoreItem {
this.reviewHint = '',
this.dictationSentence = '',
this.dictationMeaning = '',
this.usage = const CoreUsage(),
});
final String id;
@@ -348,6 +399,9 @@ class CoreItem {
final String dictationSentence;
final String dictationMeaning;
/// When to use the item, empty for anything that needs no explaining.
final CoreUsage usage;
factory CoreItem.fromJson(Map<String, dynamic> json) {
final example = _objectOrEmpty(json['example']);
final review = _objectOrEmpty(json['review']);
@@ -364,6 +418,7 @@ class CoreItem {
reviewHint: review['hint'] as String? ?? '',
dictationSentence: dictation['sentence'] as String? ?? '',
dictationMeaning: dictation['meaning'] as String? ?? '',
usage: CoreUsage.fromJson(_objectOrEmpty(json['usage'])),
);
}
}
@@ -176,7 +176,10 @@ class CourseRepository {
// Core items: labels, acceptance rules and offline review prompts.
for (final item in pack.coreItems) {
registerCoreItem(item, level: pack.level);
registerCoreItem(item, level: pack.level, unit: pack.id);
}
for (final word in pack.receptiveWords) {
registerReceptiveWord(word, level: pack.level, unit: pack.id);
}
final segments = <LessonSegment>[];
@@ -9,3 +9,4 @@ export 'core_items.dart';
export 'course_catalog.dart';
export 'course_models.dart';
export 'option_order.dart';
export 'word_bank.dart';
@@ -0,0 +1,80 @@
import 'dart:convert';
import 'package:flutter/services.dart';
import 'core_items.dart';
/// A bank word used in a sentence, with the sentence's Chinese.
typedef WordExample = ({String en, String zh});
/// The standalone word bank (`assets/words/wordbank.json`).
///
/// It is deliberately independent of the course packs: the words a learner can
/// study must not be capped by how many units they have finished, which was
/// the whole problem with growing vocabulary out of `receptiveWords` alone.
/// CEFR-J decides which words and at what level; the bank only carries the
/// IPA and the Chinese gloss.
class WordBank {
WordBank._();
static final instance = WordBank._();
static const _asset = 'assets/words/wordbank.json';
/// The unit a bank word is filed under, e.g. `bank-A1`.
static String unitOf(String level) => 'bank-$level';
static bool isBankUnit(String unit) => unit.startsWith('bank-');
static String levelOfUnit(String unit) => unit.substring('bank-'.length);
bool _loaded = false;
final Map<String, String> _extraSenses = {};
final Map<String, WordExample> _examples = {};
final Map<String, int> _ranks = {};
/// Senses beyond the one used as the quiz answer; empty when there are none.
String moreSenses(String id) => _extraSenses[id] ?? '';
/// The word in a sentence, which is what the meaning is shown against once
/// the answer is out. Null when the bank has none for this word.
WordExample? exampleOf(String id) => _examples[id];
/// How common the word is in spoken English, 0 being the most common. New
/// words are handed out in this order so the bank does not start with a
/// whole week of words beginning with `a`.
static const unranked = 99999;
int rankOf(String id) => _ranks[id] ?? unranked;
Future<void> load({AssetBundle? bundle}) async {
if (_loaded) return;
final raw = await (bundle ?? rootBundle).loadString(_asset);
final data = jsonDecode(raw) as Map<String, dynamic>;
for (final entry in (data['words'] as List<dynamic>)) {
final word = entry as Map<String, dynamic>;
final id = word['id'] as String;
final level = word['level'] as String? ?? 'A1';
registerBankWord(
id,
en: word['en'] as String? ?? '',
zh: word['zh'] as String? ?? '',
ipa: word['ipa'] as String? ?? '',
level: level,
unit: unitOf(level),
);
final more = word['more'] as String? ?? '';
if (more.isNotEmpty) _extraSenses[id] = more;
_ranks[id] = word['rank'] as int? ?? unranked;
final en = word['ex'] as String? ?? '';
if (en.isNotEmpty) {
_examples[id] = (en: en, zh: word['exZh'] as String? ?? '');
}
}
_loaded = true;
}
/// Test seam: drops what was loaded so another bundle can be loaded.
void resetForTest() {
_loaded = false;
_extraSenses.clear();
_examples.clear();
_ranks.clear();
}
}
+78 -1
View File
@@ -2,7 +2,7 @@ enum LearningGoal { dailyLife, travel, workStarter }
enum PlacementLevel { beginner, someBasics, simpleConversation }
enum AppTab { learn, dialogue, review, profile }
enum AppTab { learn, dialogue, review, words, profile }
enum LessonStep {
preview,
@@ -18,6 +18,72 @@ enum LessonStep {
enum MasteryStatus { newItem, recognize, recall, use, master, needsReview }
/// Learning engine 3.5: a recognition-only word has its own three states,
/// deliberately shorter than [MasteryStatus]. It is never asked to be spelled
/// or said, and it never counts toward a level's upgrade denominator.
enum WordStatus { newWord, recognized, familiar }
/// What the app knows about one recognition-only word.
class WordKnowledge {
const WordKnowledge({
required this.id,
required this.status,
this.recognizedAt,
this.dueAt,
this.misses = 0,
this.step = 0,
this.startedAt,
});
final String id;
final WordStatus status;
/// When it first became 「认识」. 「熟悉」 needs a second success at least
/// seven days after this, on a different question.
final DateTime? recognizedAt;
/// When it comes up for a check again; null once nothing is scheduled.
final DateTime? dueAt;
/// How often recognition has failed, used to surface the weak ones.
final int misses;
/// Successes in a row, which is the rung of the spacing ladder this word is
/// on. A miss puts it back to 0.
final int step;
/// When the word entered the learner's list, which is what the daily cap on
/// new words is counted against.
final DateTime? startedAt;
WordKnowledge copyWith({
WordStatus? status,
DateTime? recognizedAt,
DateTime? dueAt,
int? misses,
int? step,
DateTime? startedAt,
}) => WordKnowledge(
id: id,
status: status ?? this.status,
recognizedAt: recognizedAt ?? this.recognizedAt,
dueAt: dueAt ?? this.dueAt,
misses: misses ?? this.misses,
step: step ?? this.step,
startedAt: startedAt ?? this.startedAt,
);
Map<String, dynamic> toJson() => {
'id': id,
'status': status.name,
'recognizedAt': recognizedAt?.toIso8601String(),
'dueAt': dueAt?.toIso8601String(),
'misses': misses,
'step': step,
'startedAt': startedAt?.toIso8601String(),
};
}
enum EvidenceKind {
exposure,
assisted,
@@ -392,6 +458,13 @@ class SentenceAnalysisResult {
);
}
/// What a queued review is for. A `checkpoint` task carries the four spaced
/// checkpoints of learning engine 3.1. A `recap` task is the same-day recall
/// after a segment, or the re-ask of an item missed earlier in the session:
/// it only strengthens encoding, so it never advances a checkpoint and a miss
/// is never counted as a language failure.
enum ReviewKind { checkpoint, recap }
class ReviewItem {
const ReviewItem({
required this.id,
@@ -400,6 +473,7 @@ class ReviewItem {
required this.hint,
required this.dueAt,
required this.skill,
this.kind = ReviewKind.checkpoint,
this.attempts = 0,
this.successfulReviews = 0,
this.variantIndex = 0,
@@ -413,6 +487,7 @@ class ReviewItem {
final String hint;
final DateTime dueAt;
final String skill;
final ReviewKind kind;
final int attempts;
final int successfulReviews;
final int variantIndex;
@@ -421,6 +496,7 @@ class ReviewItem {
ReviewItem copyWith({
DateTime? dueAt,
ReviewKind? kind,
int? attempts,
int? successfulReviews,
int? variantIndex,
@@ -436,6 +512,7 @@ class ReviewItem {
hint: hint ?? this.hint,
dueAt: dueAt ?? this.dueAt,
skill: skill ?? this.skill,
kind: kind ?? this.kind,
attempts: attempts ?? this.attempts,
successfulReviews: successfulReviews ?? this.successfulReviews,
variantIndex: variantIndex ?? this.variantIndex,