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,
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../core/app_theme.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
import 'lesson_path.dart';
|
||||
import 'today_task_card.dart';
|
||||
import 'word_task_card.dart';
|
||||
|
||||
/// 学习 tab:今天的任务在上,完整课程路径在下。
|
||||
class HomePage extends StatelessWidget {
|
||||
@@ -17,6 +18,7 @@ class HomePage extends StatelessWidget {
|
||||
required this.onResumeLessonDialogue,
|
||||
required this.onStartReinforcement,
|
||||
required this.onOpenAssessment,
|
||||
required this.onOpenWords,
|
||||
});
|
||||
|
||||
final AppState state;
|
||||
@@ -26,6 +28,7 @@ class HomePage extends StatelessWidget {
|
||||
final VoidCallback onResumeLessonDialogue;
|
||||
final VoidCallback onStartReinforcement;
|
||||
final ValueChanged<String> onOpenAssessment;
|
||||
final VoidCallback onOpenWords;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AppPage(
|
||||
@@ -41,6 +44,7 @@ class HomePage extends StatelessWidget {
|
||||
onStartReinforcement: onStartReinforcement,
|
||||
onOpenAssessment: onOpenAssessment,
|
||||
),
|
||||
WordTaskCard(state: state, onOpenWords: onOpenWords),
|
||||
LessonPath(state: state, onOpenLesson: onOpenLesson),
|
||||
const _FrameworkNote(),
|
||||
],
|
||||
|
||||
@@ -69,15 +69,29 @@ class TodayTaskCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
if (state.reviewIsPrimary) {
|
||||
final count = state.dueReviewCount;
|
||||
final planned = state.todayReviewPlan.length;
|
||||
final postponed = state.postponedReviewCount;
|
||||
return _TodayTask(
|
||||
label: '今日复习',
|
||||
title: '先复习 $count 项',
|
||||
note: state.reviewBacklog
|
||||
title: '先复习 $planned 项',
|
||||
note: postponed > 0
|
||||
? '今天的复习时间安排 $planned 项,其余 $postponed 项明天接着做,不会丢。'
|
||||
: state.reviewBacklog
|
||||
? '有积压项目;先花几分钟清掉到期复习,再开启新课。'
|
||||
: '之前练过的关键句,今天换个情境再用一次。',
|
||||
action: '开始复习',
|
||||
minutes: '${(count * 2).clamp(2, 10)} 分钟',
|
||||
minutes: '${(planned * 2).clamp(2, 10)} 分钟',
|
||||
onPressed: onStartReview,
|
||||
);
|
||||
}
|
||||
// 学完一段后隔一会儿再想起来一次,明天的第一次检查才不会从零开始。
|
||||
if (state.dueRecaps.isNotEmpty) {
|
||||
return _TodayTask(
|
||||
label: '课后快闪',
|
||||
title: '把刚学的 ${state.dueRecaps.length} 项再想一遍',
|
||||
note: '趁还记得再提取一次,明天更容易想起来。答不出也不算错,不计检查点。',
|
||||
action: '开始快闪',
|
||||
minutes: '1 分钟',
|
||||
onPressed: onStartReview,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/app_state.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
import '../words/words_page.dart';
|
||||
|
||||
/// 首页的认词入口。
|
||||
///
|
||||
/// 认词跟今日任务是并行的,不是竞争关系:一个题几秒钟,不该去抢上面那张
|
||||
/// 今日任务卡的「今天最该做的一件事」。所以它单独占一行 —— 有题时是一张能
|
||||
/// 直接开始的卡,没题时缩成一行状态,但入口始终在。
|
||||
class WordTaskCard extends StatelessWidget {
|
||||
const WordTaskCard({
|
||||
super.key,
|
||||
required this.state,
|
||||
required this.onOpenWords,
|
||||
});
|
||||
|
||||
final AppState state;
|
||||
final VoidCallback onOpenWords;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final planned = state.wordSessionPreviewSize;
|
||||
if (planned == 0) return _restRow(context);
|
||||
return SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
onTap: onOpenWords,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Expanded(child: Eyebrow('认词')),
|
||||
Text(
|
||||
'${state.wordSessionMinutes(planned)} 分钟',
|
||||
style: TextStyle(color: AppColors.muted),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
wordTaskHeadline(state),
|
||||
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
'$planned 个题,听到、看到能认出来就行,不用会说。',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
SecondaryButton(label: '开始认词', onPressed: onOpenWords),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _restRow(BuildContext context) => SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
onTap: onOpenWords,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.abc_outlined, color: AppColors.muted, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
state.newWordQuota > 0
|
||||
? '今天的词都过完了'
|
||||
: '今天的新词学满 ${state.newWordsPerDay} 个了,明天继续',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'认识 ${state.recognizedWordCount} · 熟悉 ${state.familiarWordCount}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import '../../core/voice_service.dart';
|
||||
import '../../core/writing_feedback.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
import '../../widgets/lexicon_lookup.dart';
|
||||
import '../../widgets/usage_card.dart';
|
||||
import '../../widgets/voice_answer.dart';
|
||||
|
||||
part 'steps/independent_step.dart';
|
||||
@@ -148,6 +149,7 @@ class _LessonFlowState extends State<LessonFlow> {
|
||||
case LessonStep.speaking:
|
||||
content = _SpeakingStep(
|
||||
state: widget.state,
|
||||
segmentId: segment.id,
|
||||
text: activity.speaking,
|
||||
tip: activity.speakingTip,
|
||||
keepRecording: widget.state.keepRecordings,
|
||||
|
||||
@@ -47,6 +47,7 @@ class _PreviewStep extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
UsageCard(itemId: item.id, state: state),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [ActionChip(label: const Text('查词'), onPressed: onLookup)],
|
||||
|
||||
@@ -3,11 +3,13 @@ part of '../lesson_flow.dart';
|
||||
class _SpeakingStep extends StatefulWidget {
|
||||
const _SpeakingStep({
|
||||
required this.state,
|
||||
required this.segmentId,
|
||||
required this.text,
|
||||
this.tip = '',
|
||||
required this.keepRecording,
|
||||
required this.onContinue,
|
||||
});
|
||||
final String segmentId;
|
||||
final String text;
|
||||
final String tip;
|
||||
final AppState state;
|
||||
@@ -92,6 +94,7 @@ class _SpeakingStepState extends State<_SpeakingStep>
|
||||
style: TextStyle(color: AppColors.warmInk),
|
||||
),
|
||||
),
|
||||
SegmentUsage(segmentId: widget.segmentId, state: widget.state),
|
||||
SecondaryButton(
|
||||
label: transcribing
|
||||
? '正在 AI 识别发音…'
|
||||
|
||||
@@ -91,6 +91,7 @@ class _WritingStepState extends State<_WritingStep> {
|
||||
tint: AppColors.surfaceMuted,
|
||||
child: Text('小提示:${grammarNoteForSegment(widget.segmentId)}'),
|
||||
),
|
||||
SegmentUsage(segmentId: widget.segmentId, state: widget.state),
|
||||
if (widget.showHelp)
|
||||
SectionCard(
|
||||
tint: AppColors.softGreen,
|
||||
|
||||
@@ -11,6 +11,7 @@ import '../../core/courses/courses.dart';
|
||||
import '../../core/speech_compare.dart';
|
||||
import '../../core/voice_service.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
import '../../widgets/usage_card.dart';
|
||||
import '../../widgets/voice_answer.dart';
|
||||
|
||||
class ReviewPage extends StatefulWidget {
|
||||
@@ -50,6 +51,15 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
({String answer, String reference, String? audio, bool assisted})? lastResult;
|
||||
bool dictationPlayed = false;
|
||||
|
||||
/// Items whose recognition question has been answered in this session. The
|
||||
/// gate is asked once per item; the production task follows immediately.
|
||||
final Set<String> gateCleared = {};
|
||||
|
||||
/// The item whose contrast question is being shown after a miss, and the
|
||||
/// option picked, if any. Set only while the remediation is on screen.
|
||||
ReviewItem? contrastFor;
|
||||
String? contrastPicked;
|
||||
|
||||
@override
|
||||
AppState get voiceState => widget.state;
|
||||
|
||||
@@ -156,10 +166,204 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
});
|
||||
}
|
||||
|
||||
/// Options for the recognition question: the meaning plus distractors from
|
||||
/// the same unit. The answer's position is stable per item so it does not
|
||||
/// move while the learner reads, but it is not always first.
|
||||
List<String> _recognitionOptions(String id, String answer) {
|
||||
final options = [answer, ...recognitionDistractors(id)];
|
||||
final offset = id.hashCode.abs() % options.length;
|
||||
return [...options.skip(offset), ...options.take(offset)];
|
||||
}
|
||||
|
||||
void _answerGate(ReviewItem item, {required bool correct}) {
|
||||
widget.state.recordRecognitionGate(item, correct: correct);
|
||||
setState(() {
|
||||
gateCleared.add(item.id);
|
||||
// Being shown the meaning is help, so finishing the production step
|
||||
// afterwards is recorded as assisted.
|
||||
if (!correct) usedHelp = true;
|
||||
});
|
||||
if (!correct) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'它的意思是:${coreRecognitionQuestion(item.id).answer}。下一步试着写出英文。',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Step one of an item's first checkpoint: hear or read it and pick what it
|
||||
/// means. Recall of the English comes right after. Learning engine 3.1 puts
|
||||
/// 「认识」 before 「可回忆」, so the day after a lesson opens with a question
|
||||
/// the learner can answer instead of the hardest one.
|
||||
Widget _recognitionView(ReviewItem item) {
|
||||
final question = coreRecognitionQuestion(item.id);
|
||||
final answer = question.answer;
|
||||
final english = question.shown;
|
||||
final audio = english;
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Eyebrow('今天复习 · ${widget.state.reviewSession.length} 项待完成'),
|
||||
Text(
|
||||
'先认出来:它是什么意思?',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
Text(
|
||||
'第 1 步,共 2 步 · 认出来 → 说出来',
|
||||
style: TextStyle(color: AppColors.green, fontSize: 13),
|
||||
),
|
||||
SectionCard(
|
||||
tint: AppColors.softGreen,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Text(english, style: const TextStyle(fontSize: 22)),
|
||||
Row(
|
||||
children: [
|
||||
IconButton.filled(
|
||||
tooltip: '播放',
|
||||
onPressed: () => _play(audio),
|
||||
icon: const Icon(Icons.volume_up_outlined),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
TextButton(
|
||||
onPressed: () => _play(audio, slow: true),
|
||||
child: const Text('慢速'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (final option in _recognitionOptions(item.id, answer))
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: () => _answerGate(item, correct: option == answer),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Text(option, style: const TextStyle(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _answerGate(item, correct: false),
|
||||
child: const Text('不认识'),
|
||||
),
|
||||
Text(
|
||||
'选错或选「不认识」都不算答错,会直接告诉你意思,再一起练说出来。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _reportMiss(ReviewItem item) {
|
||||
widget.state.reportReviewFailure(item);
|
||||
// Being told the answer is the moment to say what the item is for. A
|
||||
// learner who could not produce `What's your name?` often knows the words
|
||||
// and not the situation.
|
||||
if (coreContrastQuestion(item.id, isTaught: widget.state.mastery.containsKey) != null) {
|
||||
setState(() {
|
||||
_resetAnswer();
|
||||
contrastFor = item;
|
||||
contrastPicked = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(_resetAnswer);
|
||||
}
|
||||
|
||||
void _answerContrast(ReviewItem item, String picked, String answer) {
|
||||
widget.state.recordContrastAnswer(item, correct: picked == answer);
|
||||
setState(() => contrastPicked = picked);
|
||||
}
|
||||
|
||||
/// The remediation after a miss: the usage note, then the situation with the
|
||||
/// taught sentences to choose between. It is teaching, not a checkpoint —
|
||||
/// the miss is already recorded and answering here cannot undo it.
|
||||
Widget _contrastView(ReviewItem item) {
|
||||
final question = coreContrastQuestion(
|
||||
item.id,
|
||||
isTaught: widget.state.mastery.containsKey,
|
||||
)!;
|
||||
final picked = contrastPicked;
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Eyebrow('先弄清楚什么时候用'),
|
||||
Text(
|
||||
'这句话用在这种情况:',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
UsageCard(itemId: item.id, state: widget.state),
|
||||
SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
child: Text(question.situation, style: const TextStyle(fontSize: 17)),
|
||||
),
|
||||
const Text('这时候该说哪一句?'),
|
||||
for (final option in question.options)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: picked == null
|
||||
? () => _answerContrast(item, option, question.answer)
|
||||
: null,
|
||||
style: picked == null
|
||||
? null
|
||||
: OutlinedButton.styleFrom(
|
||||
backgroundColor: option == question.answer
|
||||
? AppColors.softGreen
|
||||
: null,
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Text(option, style: const TextStyle(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (picked != null) ...[
|
||||
SectionCard(
|
||||
tint: picked == question.answer
|
||||
? AppColors.softGreen
|
||||
: AppColors.warm,
|
||||
child: Text(
|
||||
picked == question.answer
|
||||
? '对,就是这句。${question.note}'
|
||||
: '这里要说 ${question.answer}。${question.note}',
|
||||
style: TextStyle(color: AppColors.warmInk, height: 1.45),
|
||||
),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: '继续复习',
|
||||
onPressed: () => setState(() {
|
||||
contrastFor = null;
|
||||
contrastPicked = null;
|
||||
}),
|
||||
),
|
||||
],
|
||||
Text(
|
||||
'这一步不算检查点,也不会影响刚才那道题的结果。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _resultView(
|
||||
({String answer, String reference, String? audio, bool assisted}) result,
|
||||
) {
|
||||
final remaining = widget.state.dueReviews.length;
|
||||
final remaining = widget.state.reviewSession.length;
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
@@ -311,11 +515,12 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (contrastFor case final item?) return _contrastView(item);
|
||||
if (lastResult case final result?) return _resultView(result);
|
||||
final item = widget.state.dueReviews.isEmpty
|
||||
? null
|
||||
: widget.state.dueReviews.first;
|
||||
final session = widget.state.reviewSession;
|
||||
final item = session.isEmpty ? null : session.first;
|
||||
if (item == null) {
|
||||
final postponed = widget.state.postponedReviewCount;
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
@@ -324,12 +529,20 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
'到期项目已安排下次复练。',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const Text('记住不是一次答对就结束;系统会在不同间隔再次确认你仍能用出来。'),
|
||||
Text(
|
||||
postponed > 0
|
||||
? '今天的复习时间用完了,还有 $postponed 项到期,明天接着做,没有被丢掉。'
|
||||
: '记住不是一次答对就结束;系统会在不同间隔再次确认你仍能用出来。',
|
||||
),
|
||||
PrimaryButton(label: '回到首页', onPressed: widget.onFinished),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (widget.state.needsRecognitionGate(item) &&
|
||||
!gateCleared.contains(item.id)) {
|
||||
return _recognitionView(item);
|
||||
}
|
||||
final dictation = item.skill == dictationSkill
|
||||
? coreDictationSentences[item.id]
|
||||
: null;
|
||||
@@ -337,17 +550,33 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
final audioText = reviewAudioText(item.id, item.target);
|
||||
final checkpoint =
|
||||
widget.state.mastery[item.id]?.checkpoint ?? item.successfulReviews;
|
||||
final checkpointLabel = checkpoint >= 4
|
||||
final isRecap = item.kind == ReviewKind.recap;
|
||||
final checkpointLabel = isRecap
|
||||
? '不计检查点 · 只是趁热再想一遍'
|
||||
: checkpoint >= 4
|
||||
? '30 天抽查'
|
||||
: '第 ${checkpoint + 1} / 4 个间隔检查点';
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Eyebrow('今天复习 · ${widget.state.dueReviewCount} 项待完成'),
|
||||
Eyebrow(
|
||||
isRecap
|
||||
? '再想一遍 · 还有 ${widget.state.reviewSession.length} 项'
|
||||
: '今天复习 · ${widget.state.reviewSession.length} 项待完成',
|
||||
),
|
||||
Text(
|
||||
dictation != null ? '听一听,写下来。' : '不看答案,试着回答。',
|
||||
dictation != null
|
||||
? '听一听,写下来。'
|
||||
: isRecap
|
||||
? '刚学过的,现在还想得起来吗?'
|
||||
: '不看答案,试着回答。',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
if (isRecap)
|
||||
Text(
|
||||
'答不出也不算错,看一眼答案就好——这一次只是帮你记住,真正的检查在明天。',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.muted),
|
||||
),
|
||||
Text(
|
||||
'目标技能:${item.skill}',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
@@ -465,10 +694,7 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
),
|
||||
ActionChip(
|
||||
label: const Text('暂时想不起来'),
|
||||
onPressed: () {
|
||||
widget.state.reportReviewFailure(item);
|
||||
setState(_resetAnswer);
|
||||
},
|
||||
onPressed: () => _reportMiss(item),
|
||||
),
|
||||
ActionChip(
|
||||
label: Text(generatingVariant ? '正在生成…' : '生成变式'),
|
||||
@@ -509,6 +735,7 @@ class _ReviewPageState extends State<ReviewPage>
|
||||
icon: const Icon(Icons.volume_up_outlined),
|
||||
label: Text('听示范:$audioText'),
|
||||
),
|
||||
UsageCard(itemId: item.id, state: widget.state),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -10,6 +10,7 @@ import "../home/home_page.dart";
|
||||
import "../lesson/lesson_flow.dart";
|
||||
import "../profile/profile_page.dart";
|
||||
import "../review/review_page.dart";
|
||||
import "../words/words_page.dart";
|
||||
|
||||
class LearningShell extends StatefulWidget {
|
||||
const LearningShell({super.key, required this.state});
|
||||
@@ -197,6 +198,11 @@ class _LearningShellState extends State<LearningShell> {
|
||||
selectedIcon: Icon(Icons.refresh),
|
||||
label: "复习",
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.abc_outlined),
|
||||
selectedIcon: Icon(Icons.abc),
|
||||
label: "单词",
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.person_outline),
|
||||
selectedIcon: Icon(Icons.person),
|
||||
@@ -240,6 +246,7 @@ class _LearningShellState extends State<LearningShell> {
|
||||
.firstOrNull;
|
||||
if (pack != null) showAssessment(pack);
|
||||
},
|
||||
onOpenWords: () => showTab(AppTab.words),
|
||||
);
|
||||
case AppTab.dialogue:
|
||||
return DialogueScenePage(
|
||||
@@ -252,6 +259,8 @@ class _LearningShellState extends State<LearningShell> {
|
||||
onFinished: () => showTab(AppTab.learn),
|
||||
onOpenAdaptiveLesson: showAdaptiveLesson,
|
||||
);
|
||||
case AppTab.words:
|
||||
return WordsPage(state: widget.state);
|
||||
case AppTab.profile:
|
||||
return ProfilePage(
|
||||
state: widget.state,
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/app_state.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/courses/courses.dart';
|
||||
import '../../core/models.dart';
|
||||
import '../../core/voice_service.dart';
|
||||
import '../../widgets/app_widgets.dart';
|
||||
|
||||
/// What the round of recognition is about right now. The home card and this
|
||||
/// page share it so the two never describe today's words differently.
|
||||
String wordTaskHeadline(AppState state) {
|
||||
final due = state.dueWords.length;
|
||||
if (due > 0) return '今天有 $due 个词该过一遍';
|
||||
final unasked = state.unaskedWords.length;
|
||||
if (unasked > 0) return '有 $unasked 个新学的词还没问过';
|
||||
return '今天还能学 ${state.newWordQuota} 个新词';
|
||||
}
|
||||
|
||||
/// The word list the learner can open on its own (learning engine 3.5).
|
||||
///
|
||||
/// Words are never asked to be produced here: the only task is a 5–10 second
|
||||
/// recognition question, and the rest of the page is just what has been met.
|
||||
class WordsPage extends StatefulWidget {
|
||||
const WordsPage({super.key, required this.state});
|
||||
|
||||
final AppState state;
|
||||
|
||||
@override
|
||||
State<WordsPage> createState() => _WordsPageState();
|
||||
}
|
||||
|
||||
class _WordsPageState extends State<WordsPage> {
|
||||
List<WordQuestion>? quiz;
|
||||
int index = 0;
|
||||
String? picked;
|
||||
int right = 0;
|
||||
|
||||
void _startQuiz() {
|
||||
final plan = widget.state.wordSession();
|
||||
if (plan.isEmpty) return;
|
||||
setState(() {
|
||||
quiz = plan;
|
||||
index = 0;
|
||||
picked = null;
|
||||
right = 0;
|
||||
});
|
||||
_playIfHeard(plan.first);
|
||||
}
|
||||
|
||||
/// A listening question plays itself: the learner should not have to find a
|
||||
/// button before the question has even been asked.
|
||||
void _playIfHeard(WordQuestion question) {
|
||||
if (question.mode != WordAskMode.listen) return;
|
||||
VoiceService.instance.speak(wordEnglish(question.id));
|
||||
}
|
||||
|
||||
void _answer(WordQuestion question, String option) {
|
||||
final correct = option == question.answer;
|
||||
widget.state.recordWordRecognition(question.id, correct: correct);
|
||||
setState(() {
|
||||
picked = option;
|
||||
if (correct) right += 1;
|
||||
});
|
||||
}
|
||||
|
||||
void _next() {
|
||||
final last = index + 1 >= quiz!.length;
|
||||
setState(() {
|
||||
if (last) {
|
||||
quiz = null;
|
||||
} else {
|
||||
index += 1;
|
||||
}
|
||||
picked = null;
|
||||
});
|
||||
if (!last) _playIfHeard(quiz![index]);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (quiz case final questions?) return _quizView(questions[index]);
|
||||
return _listView();
|
||||
}
|
||||
|
||||
Widget _quizView(WordQuestion question) {
|
||||
final answered = picked != null;
|
||||
final english = wordEnglish(question.id);
|
||||
final ipa = wordIpa(question.id);
|
||||
// Before the answer each way round gives a different clue; after it all
|
||||
// three land on the spelling, which is what the question was for.
|
||||
// Withholding it earlier is the question: a heard word shown spelled is
|
||||
// a reading question, and a meaning shown next to its word is no question
|
||||
// at all.
|
||||
final prompt = switch (question.mode) {
|
||||
WordAskMode.read => english,
|
||||
WordAskMode.listen => answered ? english : '· · ·',
|
||||
WordAskMode.recall => answered ? english : question.shown,
|
||||
};
|
||||
// Speaking the word before a recall answer would just read it out.
|
||||
final canSpeak = answered || question.mode != WordAskMode.recall;
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Eyebrow(
|
||||
'${switch (question.mode) {
|
||||
WordAskMode.read => '认词',
|
||||
WordAskMode.listen => '听词',
|
||||
WordAskMode.recall => '想词',
|
||||
}} · ${index + 1} / ${quiz!.length}',
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
prompt,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
if (answered &&
|
||||
ipa.isNotEmpty &&
|
||||
question.mode != WordAskMode.read)
|
||||
Text(ipa, style: Theme.of(context).textTheme.bodyMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (canSpeak)
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
question.mode == WordAskMode.listen
|
||||
? Icons.replay_outlined
|
||||
: Icons.volume_up_outlined,
|
||||
),
|
||||
tooltip: question.mode == WordAskMode.listen ? '再听一遍' : null,
|
||||
onPressed: () => VoiceService.instance.speak(english),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(switch (question.mode) {
|
||||
WordAskMode.read => '它是什么意思?只要认出来就行,不用会说。',
|
||||
WordAskMode.listen => '听到的是哪个意思?听不清可以再听一遍。',
|
||||
WordAskMode.recall => '哪个词是这个意思?认出来就行,不用自己拼。',
|
||||
}),
|
||||
for (final option in question.options)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: answered ? null : () => _answer(question, option),
|
||||
style: !answered || option != question.answer
|
||||
? null
|
||||
: OutlinedButton.styleFrom(
|
||||
backgroundColor: AppColors.softGreen,
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Text(option, style: const TextStyle(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (answered) ...[
|
||||
SectionCard(
|
||||
tint: picked == question.answer
|
||||
? AppColors.softGreen
|
||||
: AppColors.warm,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Text(
|
||||
picked == question.answer
|
||||
? '对了。'
|
||||
: question.mode == WordAskMode.recall
|
||||
? '「${question.shown}」是 $english,明天再问你一次。'
|
||||
: '$english 是「${question.answer}」,明天再问你一次。',
|
||||
style: TextStyle(color: AppColors.warmInk, height: 1.45),
|
||||
),
|
||||
// The sentence is the point of the feedback: a gloss alone is
|
||||
// what did not stick the first time.
|
||||
if (WordBank.instance.exampleOf(question.id) case final sample?) ...[
|
||||
Text(
|
||||
sample.en,
|
||||
style: TextStyle(
|
||||
color: AppColors.warmInk,
|
||||
fontSize: 16,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
if (sample.zh.isNotEmpty)
|
||||
Text(
|
||||
sample.zh,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
if (WordBank.instance.moreSenses(question.id) case final more
|
||||
when more.isNotEmpty)
|
||||
Text(
|
||||
'也有「$more」的意思。',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PrimaryButton(
|
||||
label: index + 1 >= quiz!.length ? '做完了' : '下一个',
|
||||
onPressed: _next,
|
||||
),
|
||||
],
|
||||
TextButton(
|
||||
onPressed: () => setState(() {
|
||||
quiz = null;
|
||||
picked = null;
|
||||
}),
|
||||
child: const Text('先不做了'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _listView() {
|
||||
final state = widget.state;
|
||||
final shaky = state.shakyWords;
|
||||
final grouped = state.wordsByUnit;
|
||||
final waiting = state.unstartedBankWordCount;
|
||||
final quota = state.newWordQuota;
|
||||
final planned = state.wordSessionPreviewSize;
|
||||
return AppPage(
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
const Eyebrow('学过的词'),
|
||||
Text(
|
||||
grouped.isEmpty
|
||||
? '还没有学过的词'
|
||||
: '认识 ${state.recognizedWordCount} · 熟悉 ${state.familiarWordCount}',
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const Text('这些词只要听到、看到能认出来就够了,不用背着写出来。'),
|
||||
if (planned > 0)
|
||||
SectionCard(
|
||||
tint: AppColors.softGreen,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
Text(
|
||||
wordTaskHeadline(state),
|
||||
style: const TextStyle(fontSize: 17),
|
||||
),
|
||||
Text(
|
||||
'$planned 个题,一个题几秒钟。',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
PrimaryButton(label: '开始认词', onPressed: _startQuiz),
|
||||
],
|
||||
),
|
||||
)
|
||||
else if (grouped.isNotEmpty)
|
||||
SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
child: Text(
|
||||
quota > 0
|
||||
? '今天的词都过完了,剩下的等到期再问。'
|
||||
: '今天的新词学满 ${state.newWordsPerDay} 个了,到期的也都过完了。'
|
||||
'明天它们会回来找你。',
|
||||
),
|
||||
),
|
||||
if (waiting > 0)
|
||||
Text(
|
||||
quota > 0
|
||||
? '词库里还有 $waiting 个词没开始,今天还能学 $quota 个。'
|
||||
: '词库里还有 $waiting 个词没开始,明天继续。',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (shaky.isNotEmpty) ...[
|
||||
const Eyebrow('没记住的'),
|
||||
SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
for (final id in shaky.take(8)) _WordRow(id: id, state: state),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
for (final entry in grouped.entries) ...[
|
||||
Eyebrow(_unitTitle(entry.key)),
|
||||
SectionCard(
|
||||
tint: AppColors.surfaceMuted,
|
||||
child: SpacedColumn(
|
||||
children: [
|
||||
for (final id in entry.value.take(_groupLimit))
|
||||
_WordRow(id: id, state: state),
|
||||
if (entry.value.length > _groupLimit)
|
||||
Text(
|
||||
'还有 ${entry.value.length - _groupLimit} 个',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// A unit group is a reminder of what has been met, not a dictionary, so it
|
||||
/// stops before the bank turns it into an endless scroll.
|
||||
static const _groupLimit = 30;
|
||||
|
||||
String _unitTitle(String unit) {
|
||||
if (unit.isEmpty) return '查词收藏';
|
||||
if (WordBank.isBankUnit(unit)) return '词库 · ${WordBank.levelOfUnit(unit)}';
|
||||
final lesson = allLessons.where((item) => item.id == unit);
|
||||
return lesson.isEmpty ? unit : lesson.first.title;
|
||||
}
|
||||
}
|
||||
|
||||
class _WordRow extends StatelessWidget {
|
||||
const _WordRow({required this.id, required this.state});
|
||||
|
||||
final String id;
|
||||
final AppState state;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final status = state.wordStatus(id);
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
wordEnglish(id),
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
wordIpa(id).isEmpty
|
||||
? wordMeaning(id)
|
||||
: '${wordIpa(id)} ${wordMeaning(id)}',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
switch (status) {
|
||||
WordStatus.newWord => '新学',
|
||||
WordStatus.recognized => '认识',
|
||||
WordStatus.familiar => '熟悉',
|
||||
},
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.volume_up_outlined, size: 20),
|
||||
onPressed: () => VoiceService.instance.speak(wordEnglish(id)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import '../core/models.dart';
|
||||
import '../core/courses/courses.dart';
|
||||
import '../core/voice_service.dart';
|
||||
import 'app_widgets.dart';
|
||||
import 'usage_card.dart';
|
||||
|
||||
List<VocabularyItem>? _cachedEntries;
|
||||
Map<String, VocabularyItem>? _cachedExactMap;
|
||||
@@ -454,7 +455,7 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
try {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('已将短语 "${phrase.phrase}" 加入复习计划'),
|
||||
content: Text('已加入单词,之后在「单词」里认它'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -469,7 +470,7 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
try {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('已将 "${item.word}" 加入复习计划'),
|
||||
content: Text('已加入单词,之后在「单词」里认它'),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
@@ -663,7 +664,7 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
_addedToReview.contains(phrase.phrase.toLowerCase())
|
||||
? Chip(
|
||||
label: Text(
|
||||
'已在复习',
|
||||
'已在单词',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
avatar: Icon(
|
||||
@@ -688,7 +689,7 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
size: 14,
|
||||
),
|
||||
label: const Text(
|
||||
'加复习',
|
||||
'加到单词',
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
@@ -843,14 +844,15 @@ class _LexiconLookupSheetState extends State<_LexiconLookupSheet> {
|
||||
tint: AppColors.softGreen,
|
||||
child: Text('${entry!.example}\n${entry!.exampleMeaning}'),
|
||||
),
|
||||
UsageCard(itemId: entry!.id, state: widget.state),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: PrimaryButton(
|
||||
label:
|
||||
_addedToReview.contains(entry!.word.toLowerCase())
|
||||
? '已在复习中'
|
||||
: '加入复习',
|
||||
? '已在单词表'
|
||||
: '加到单词',
|
||||
onPressed: () {
|
||||
_addVocabItemToReview(entry!);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../core/app_state.dart';
|
||||
import '../core/app_theme.dart';
|
||||
import '../core/courses/courses.dart';
|
||||
import 'app_widgets.dart';
|
||||
|
||||
/// What a taught pattern is *for*, next to what it means. Knowing that
|
||||
/// `How are you?` means 你怎么样 does not tell a learner that it is not the
|
||||
/// way to ask someone's name, so the course states the situation, the reply
|
||||
/// it invites, and the items it is easiest to mix up with.
|
||||
///
|
||||
/// The card renders nothing when the pack explains nothing about the item,
|
||||
/// which is the normal case for a plain word.
|
||||
class UsageCard extends StatelessWidget {
|
||||
const UsageCard({
|
||||
super.key,
|
||||
required this.itemId,
|
||||
required this.state,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
final String itemId;
|
||||
final AppState state;
|
||||
|
||||
/// Inside a task step, only the situation and the difference are shown —
|
||||
/// the learner is mid-exercise, not reading a reference card.
|
||||
final bool compact;
|
||||
|
||||
/// A pattern counts as taught once it has a mastery row, which
|
||||
/// `AppState` creates when the lesson first presents it.
|
||||
bool _isTaught(String id) => state.mastery.containsKey(id);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final usage = coreUsage(itemId);
|
||||
if (usage == null) return const SizedBox.shrink();
|
||||
final lines = <({String label, String text})>[
|
||||
if (usage.when.isNotEmpty) (label: '什么时候用', text: usage.when),
|
||||
if (!compact && usage.reply.isNotEmpty) (label: '对方会怎么答', text: usage.reply),
|
||||
if (!compact && usage.swap.isNotEmpty) (label: '换个说法', text: usage.swap),
|
||||
// Mid-exercise only the nearest confusion is worth a line; the full
|
||||
// list belongs on the preview and lookup cards.
|
||||
for (final other in visibleConfusables(
|
||||
itemId,
|
||||
isTaught: _isTaught,
|
||||
).take(compact ? 1 : 3))
|
||||
if (other.note.isNotEmpty)
|
||||
(label: '别和 ${coreItemSpoken(other.id)} 弄混', text: other.note),
|
||||
];
|
||||
if (lines.isEmpty) return const SizedBox.shrink();
|
||||
return SectionCard(
|
||||
tint: AppColors.warm,
|
||||
child: SpacedColumn(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final line in lines)
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
style: DefaultTextStyle.of(context).style.copyWith(
|
||||
color: AppColors.warmInk,
|
||||
height: 1.45,
|
||||
),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '${line.label}:',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
TextSpan(text: line.text),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every usage note a teaching segment carries, in course order. Shown in the
|
||||
/// speaking and writing steps, where a learner is about to produce the pattern
|
||||
/// and the question is which one this situation calls for.
|
||||
class SegmentUsage extends StatelessWidget {
|
||||
const SegmentUsage({
|
||||
super.key,
|
||||
required this.segmentId,
|
||||
required this.state,
|
||||
this.compact = true,
|
||||
});
|
||||
|
||||
final String segmentId;
|
||||
final AppState state;
|
||||
final bool compact;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ids = findLessonSegment(segmentId)?.targetItemIds ?? const <String>[];
|
||||
final explained = [for (final id in ids) if (coreUsage(id) != null) id];
|
||||
if (explained.isEmpty) return const SizedBox.shrink();
|
||||
return SpacedColumn(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final id in explained)
|
||||
UsageCard(itemId: id, state: state, compact: compact),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user