Files
English/kouyu_english/lib/core/app_state.dart
T

350 lines
11 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 listeningAudioPlayed = false;
bool listeningAudioUnavailable = false;
ListeningSupport listeningSupport = ListeningSupport.none;
int listeningAttempts = 0;
bool sentenceBuildUsed = false;
int sentenceBuildIndex = 0;
bool lessonSpeakingComplete = false;
bool lessonReadingComplete = false;
bool lessonWritingComplete = false;
bool lessonDialogueComplete = false;
bool independentAttemptComplete = false;
bool independentAttemptAssisted = false;
bool independentAttemptPending = 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();
}
/// Placement never marks an earlier lesson as passed or opens draft content.
void setPlacementStartLesson(String lessonId) {
if (!allLessons.any(
(lesson) =>
lesson.id == lessonId && lesson.status == ContentStatus.approved,
)) {
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;
listeningAudioPlayed = false;
listeningAudioUnavailable = false;
listeningSupport = ListeningSupport.none;
listeningAttempts = 0;
sentenceBuildUsed = false;
sentenceBuildIndex = 0;
lessonSpeakingComplete = false;
lessonReadingComplete = false;
lessonWritingComplete = false;
lessonDialogueComplete = false;
independentAttemptComplete = false;
independentAttemptAssisted = false;
independentAttemptPending = false;
independentAttemptSpoken = false;
mastery.clear();
notifyListeners();
}
@override
void _syncInBackground() {
SyncCoordinator.instance.triggerBackgroundSync(this);
}
}