Files
English/kouyu_english/lib/core/app_state.dart
T
shenleiandClaude Opus 5 6b42b7abc3 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>
2026-09-20 23:54:17 +09:00

331 lines
10 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'models.dart';
import 'courses/courses.dart';
import 'app_theme.dart';
import 'generated_content.dart';
import 'local_store.dart';
import 'courses/course_repository.dart';
import 'ai_config.dart';
import 'ai_service.dart';
import 'sherpa_stt_service.dart';
import 'sync/sync_coordinator.dart';
part 'app_state_ai_content.dart';
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.
abstract class _AppStateData extends ChangeNotifier {
LearningGoal goal = LearningGoal.dailyLife;
int dailyMinutes = 20;
PlacementLevel placement = PlacementLevel.beginner;
bool onboardingComplete = false;
bool showChineseHints = true;
bool keepRecordings = false;
AppThemeMode themeMode = AppThemeMode.system;
int completedLessons = 0;
String activeLessonId = 'a0-01';
/// Lesson recommended by the placement check; it and earlier lessons are
/// open without finishing the lesson before them.
String placementStartLessonId = 'a0-01';
final Set<String> completedLessonIds = {};
final Set<String> completedSegmentIds = {};
final Set<String> reportedAiVariantKeys = {};
final Map<String, int> activeSegmentIndexes = {};
final List<AssessmentRecord> assessments = [];
AssessmentDraft? assessmentDraft;
DialogueDraft? dialogueDraft;
/// The free "初次见面" scene keeps its own slot, so resuming a scene never
/// overwrites an unfinished lesson dialogue.
DialogueDraft? sceneDialogueDraft;
LessonStep lessonStep = LessonStep.preview;
int previewIndex = 0;
bool lessonListeningComplete = false;
bool lessonSpeakingComplete = false;
bool lessonReadingComplete = false;
bool lessonWritingComplete = false;
bool lessonDialogueComplete = false;
bool independentAttemptComplete = false;
bool independentAttemptAssisted = false;
bool independentAttemptSpoken = false;
String lessonWritingDraft = '';
String independentAttemptDraft = '';
AiProviderType aiProvider = AiProviderType.mock;
String aiEndpoint = '';
String aiModel = '';
AiConfigFile get aiConfig =>
AiConfigFile(provider: aiProvider, endpoint: aiEndpoint, model: aiModel);
String? cachedAdaptiveLessonRaw;
DateTime? cachedAdaptiveLessonAuditedAt;
String? cachedAdaptiveLessonAuditor;
String? adaptiveLessonDraftId;
int adaptiveLessonDraftIndex = 0;
String adaptiveLessonDraftAnswer = '';
bool adaptiveLessonDraftReferenceShown = false;
bool adaptiveLessonDraftUsedVoice = false;
bool adaptiveLessonDraftTranscriptEdited = false;
bool adaptiveLessonDraftTranscriptConfirmed = false;
String adaptiveLessonDraftOriginalTranscript = '';
String? adaptiveLessonDraftRecordingPath;
final Set<String> reportedAdaptiveLessonIds = {};
final Map<String, TemporaryLexiconEntry> temporaryLexicon = {};
final Map<String, SentenceAnalysisResult> sentenceAnalyses = {};
final List<ReviewItem> reviewQueue = [];
final List<AttemptEvidence> attemptEvidence = [];
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,
_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;
bool _dirty = false;
// Widget/unit tests keep using the mock preference backend. Production uses
// SQLite through Drift; this also prevents test cases sharing a real device
// database between runs.
bool get _usesLegacyTestStore =>
Platform.environment['FLUTTER_TEST'] == 'true';
AppState() {
addListener(_persist);
}
Future<void> load() async {
try {
unawaited(SherpaSttService.instance.initialize());
// 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) {
AiService.instance.setFallbackApiKey(config.apiKey);
}
if (aiEndpoint.isEmpty && config.endpoint.isNotEmpty) {
aiEndpoint = config.endpoint;
}
if (aiModel.isEmpty && config.model.isNotEmpty) {
aiModel = config.model;
}
if (aiProvider == AiProviderType.mock &&
config.provider != AiProviderType.mock) {
aiProvider = config.provider;
}
}
String? raw;
if (!_usesLegacyTestStore) {
raw = await LocalSnapshotStore.instance.read();
}
if (raw == null) {
final preferences = await SharedPreferences.getInstance();
raw = preferences.getString(_storageKey);
// One-time migration from builds that used SharedPreferences only.
if (raw != null && !_usesLegacyTestStore) {
await LocalSnapshotStore.instance.write(raw);
}
}
if (raw != null) _restore(jsonDecode(raw) as Map<String, dynamic>);
if (config != null) {
if (aiEndpoint.isEmpty && config.endpoint.isNotEmpty) {
aiEndpoint = config.endpoint;
}
if (aiModel.isEmpty && config.model.isNotEmpty) {
aiModel = config.model;
}
if (aiProvider == AiProviderType.mock &&
config.provider != AiProviderType.mock &&
raw == null) {
aiProvider = config.provider;
}
}
} catch (_) {
// A corrupt local cache must never prevent access to offline lessons.
} finally {
isLoaded = true;
notifyListeners();
}
}
Future<bool> reloadAiConfigFromAsset() async {
final config = await AiConfigFile.loadFromAsset();
if (config == null) return false;
aiProvider = config.provider;
aiEndpoint = config.endpoint;
aiModel = config.model;
if (config.apiKey != null && config.apiKey!.trim().isNotEmpty) {
AiService.instance.setFallbackApiKey(config.apiKey);
}
notifyListeners();
return true;
}
void _persist() {
if (!isLoaded) return;
_dirty = true;
if (_writing) return;
_writing = true;
_dirty = false;
_writeSnapshot(
jsonEncode(_toSnapshotJson()),
).catchError((_) => false).whenComplete(() {
_writing = false;
if (_dirty) _persist();
});
}
Future<void> _writeSnapshot(String payload) async {
if (_usesLegacyTestStore) {
final preferences = await SharedPreferences.getInstance();
await preferences.setString(_storageKey, payload);
return;
}
await LocalSnapshotStore.instance.write(payload);
}
void finishOnboarding() {
onboardingComplete = true;
notifyListeners();
_syncInBackground();
}
void setGoal(LearningGoal value) {
goal = value;
notifyListeners();
}
void setDailyMinutes(int value) {
dailyMinutes = value;
notifyListeners();
}
void setPlacement(PlacementLevel value) {
placement = value;
notifyListeners();
}
/// Applies the placement result. The start may land on any loaded lesson
/// across A0–B1, and placement never counts as passing a lesson.
void setPlacementStartLesson(String lessonId) {
if (!allLessons.any((lesson) => lesson.id == lessonId)) return;
placementStartLessonId = lessonId;
activeLessonId = lessonId;
_resetLessonFlow();
notifyListeners();
}
void setAiProvider(AiProviderType value) {
aiProvider = value;
notifyListeners();
}
void saveAiConfiguration({required String endpoint, required String model}) {
aiEndpoint = endpoint.trim();
aiModel = model.trim();
notifyListeners();
}
void toggleChineseHints(bool value) {
showChineseHints = value;
notifyListeners();
}
void toggleKeepRecordings(bool value) {
keepRecordings = value;
notifyListeners();
}
void setThemeMode(AppThemeMode value) {
if (themeMode == value) return;
themeMode = value;
notifyListeners();
}
void clearProgress() {
completedLessons = 0;
activeLessonId = 'a0-01';
placementStartLessonId = 'a0-01';
completedLessonIds.clear();
completedSegmentIds.clear();
reportedAiVariantKeys.clear();
activeSegmentIndexes.clear();
reviewQueue.clear();
attemptEvidence.clear();
cachedAdaptiveLessonRaw = null;
cachedAdaptiveLessonAuditedAt = null;
cachedAdaptiveLessonAuditor = null;
adaptiveLessonDraftId = null;
adaptiveLessonDraftIndex = 0;
adaptiveLessonDraftAnswer = '';
adaptiveLessonDraftReferenceShown = false;
adaptiveLessonDraftUsedVoice = false;
adaptiveLessonDraftTranscriptEdited = false;
adaptiveLessonDraftTranscriptConfirmed = false;
adaptiveLessonDraftOriginalTranscript = '';
adaptiveLessonDraftRecordingPath = null;
reportedAdaptiveLessonIds.clear();
assessments.clear();
assessmentDraft = null;
dialogueDraft = null;
sceneDialogueDraft = null;
lessonStep = LessonStep.preview;
previewIndex = 0;
lessonListeningComplete = false;
lessonSpeakingComplete = false;
lessonReadingComplete = false;
lessonWritingComplete = false;
lessonDialogueComplete = false;
independentAttemptComplete = false;
independentAttemptAssisted = false;
independentAttemptSpoken = false;
mastery.clear();
notifyListeners();
}
@override
void _syncInBackground() {
SyncCoordinator.instance.triggerBackgroundSync(this);
}
}