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:
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user