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

## 独立词库

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

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

## 背单词机制

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

## 用法对照

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

## 复习流

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

测试 296 通过,flutter analyze 干净。

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