Files
English/kouyu_english/lib/core/app_state.dart
T
shenleiandClaude Opus 4.8 febfd30f49 feat: A1 短材料复现本单元理解词,并加入黑夜模式
课程内容
- 重写全部 21 个 A1 单元的听读材料,把本单元理解词织进听/读文本,
  单元内理解词复现率从约 20% 提升到约 77%(各单元 56–96%)。
- 修正 U01 房间号与机场大巴同为 thirty 的撞车(改为 17/30/40)。
- 校验器容差按级别读取(A1 为 8%),materials 覆盖率、字数、
  选项子串等校验全部通过;course_content_test 通过。

黑夜模式
- app_theme 拆分明/暗两套调色板,AppColors 随亮度切换;
  main 用 theme/darkTheme/themeMode + builder 镜像已解析亮度;
  主题偏好持久化到快照;进度页新增“外观主题”切换。

文档
- COURSE-PACK-JSON.md 更新 A1 词池覆盖(840/933)与材料复现约定。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-18 11:16:09 +09:00

305 lines
9.4 KiB
Dart

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 'a0_core.dart';
import 'app_theme.dart';
import 'generated_content.dart';
import 'local_store.dart';
import 'review_feedback.dart';
import 'seed_courses.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';
/// 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 = {};
void _syncInBackground();
}
class AppState extends _AppStateData
with _ReviewAndMastery, _LessonProgress, _AssessmentProgress, _AiContent {
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());
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. Only A0 starting points exist until A1
/// content is frozen, and placement never counts as passing a lesson.
void setPlacementStartLesson(String lessonId) {
if (!a0SeedLessons.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);
}
}