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 completedLessonIds = {}; final Set completedSegmentIds = {}; final Set reportedAiVariantKeys = {}; final Map activeSegmentIndexes = {}; final List 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 reportedAdaptiveLessonIds = {}; final Map temporaryLexicon = {}; final Map sentenceAnalyses = {}; final List reviewQueue = []; final List attemptEvidence = []; final Map 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 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); 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 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 _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); } }