Files
English/kouyu_english/lib/core/app_state.dart
T
shenleiandClaude Opus 5 9b0f9d64cb refactor: 按领域将 AppState 拆分为 mixin
- app_state.dart 只保留持久化字段(_AppStateData)、加载/存档、设置项和清空进度
- app_state_review.dart:复习队列与掌握度
- app_state_lesson.dart:课程解锁、分段与课内步骤流程
- app_state_assessment.dart:阶段测评
- app_state_ai_content.dart:临时释义、句子分析缓存与补练课
- 后台同步调用统一为 _syncInBackground()

纯搬移,方法体未改,无用户可见变化。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:22:19 +09:00

281 lines
8.5 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 'generated_content.dart';
import 'local_store.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;
int completedLessons = 0;
String activeLessonId = '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();
}
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 clearProgress() {
completedLessons = 0;
activeLessonId = '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);
}
}