From a69d221ab63d971ad76aed9e2f423907ed663689 Mon Sep 17 00:00:00 2001 From: shenlei Date: Wed, 16 Sep 2026 17:07:23 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=B0=86=20AppState=20=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=AD=98=E6=A1=A3=E5=BA=8F=E5=88=97=E5=8C=96=E6=8B=86?= =?UTF-8?q?=E5=88=86=E5=88=B0=20app=5Fstate=5Fsnapshot.dart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 纯代码搬移,不改行为:_restore、快照 JSON 构建及相关解析函数移入 part 文件。 已用覆盖全部存档字段的快照比对验证,重构前后写出的存档逐字节一致。 Co-Authored-By: Claude Opus 5 --- kouyu_english/lib/core/app_state.dart | 480 +----------------- .../lib/core/app_state_snapshot.dart | 475 +++++++++++++++++ 2 files changed, 483 insertions(+), 472 deletions(-) create mode 100644 kouyu_english/lib/core/app_state_snapshot.dart diff --git a/kouyu_english/lib/core/app_state.dart b/kouyu_english/lib/core/app_state.dart index b353d61..92b9a83 100644 --- a/kouyu_english/lib/core/app_state.dart +++ b/kouyu_english/lib/core/app_state.dart @@ -15,6 +15,8 @@ import 'ai_service.dart'; import 'sherpa_stt_service.dart'; import 'sync/sync_coordinator.dart'; +part 'app_state_snapshot.dart'; + class AppState extends ChangeNotifier { static const _storageKey = 'learning_state_v1'; bool isLoaded = false; @@ -353,484 +355,18 @@ class AppState extends ChangeNotifier { return true; } - void _restore(Map data) { - onboardingComplete = - data['onboardingComplete'] as bool? ?? onboardingComplete; - goal = _enumValue(LearningGoal.values, data['goal'] as String?, goal); - placement = _enumValue( - PlacementLevel.values, - data['placement'] as String?, - placement, - ); - dailyMinutes = data['dailyMinutes'] as int? ?? dailyMinutes; - showChineseHints = data['showChineseHints'] as bool? ?? showChineseHints; - keepRecordings = data['keepRecordings'] as bool? ?? keepRecordings; - aiEndpoint = data['aiEndpoint'] as String? ?? aiEndpoint; - aiModel = data['aiModel'] as String? ?? aiModel; - cachedAdaptiveLessonRaw = data['cachedAdaptiveLessonRaw'] as String?; - cachedAdaptiveLessonAuditedAt = DateTime.tryParse( - data['cachedAdaptiveLessonAuditedAt'] as String? ?? '', - ); - cachedAdaptiveLessonAuditor = - data['cachedAdaptiveLessonAuditor'] as String?; - adaptiveLessonDraftId = data['adaptiveLessonDraftId'] as String?; - adaptiveLessonDraftIndex = data['adaptiveLessonDraftIndex'] as int? ?? 0; - adaptiveLessonDraftAnswer = - data['adaptiveLessonDraftAnswer'] as String? ?? ''; - adaptiveLessonDraftReferenceShown = - data['adaptiveLessonDraftReferenceShown'] as bool? ?? false; - adaptiveLessonDraftUsedVoice = - data['adaptiveLessonDraftUsedVoice'] as bool? ?? false; - adaptiveLessonDraftTranscriptEdited = - data['adaptiveLessonDraftTranscriptEdited'] as bool? ?? false; - adaptiveLessonDraftTranscriptConfirmed = - data['adaptiveLessonDraftTranscriptConfirmed'] as bool? ?? false; - adaptiveLessonDraftOriginalTranscript = - data['adaptiveLessonDraftOriginalTranscript'] as String? ?? ''; - adaptiveLessonDraftRecordingPath = - data['adaptiveLessonDraftRecordingPath'] as String?; - reportedAdaptiveLessonIds - ..clear() - ..addAll( - (data['reportedAdaptiveLessonIds'] as List? ?? const []) - .whereType(), - ); - final savedTemporaryLexicon = data['temporaryLexicon'] as List?; - if (savedTemporaryLexicon != null) { - temporaryLexicon - ..clear() - ..addEntries( - savedTemporaryLexicon - .whereType>() - .map((item) { - final query = item['query'] as String? ?? ''; - return MapEntry( - _normalizeLexiconQuery(query), - TemporaryLexiconEntry( - query: query, - definition: item['definition'] as String? ?? '', - provider: item['provider'] as String? ?? 'unknown', - model: item['model'] as String? ?? '', - createdAt: - DateTime.tryParse(item['createdAt'] as String? ?? '') ?? - DateTime.now(), - ), - ); - }) - .where( - (entry) => - entry.key.isNotEmpty && entry.value.definition.isNotEmpty, - ), - ); - } - final savedSentenceAnalyses = data['sentenceAnalyses'] as List?; - if (savedSentenceAnalyses != null) { - sentenceAnalyses - ..clear() - ..addEntries( - savedSentenceAnalyses - .whereType>() - .map((item) { - final query = item['query'] as String? ?? ''; - final payload = item['payload'] as Map? ?? {}; - return MapEntry( - _normalizeLexiconQuery(query), - SentenceAnalysisResult.fromJson(payload), - ); - }) - .where((entry) => entry.key.isNotEmpty && entry.value.translation.isNotEmpty), - ); - } - aiProvider = _enumValue( - AiProviderType.values, - data['aiProvider'] as String?, - aiProvider, - ); - lessonStep = _enumValue( - LessonStep.values, - data['lessonStep'] as String?, - lessonStep, - ); - previewIndex = data['previewIndex'] as int? ?? previewIndex; - completedLessons = data['completedLessons'] as int? ?? completedLessons; - activeLessonId = data['activeLessonId'] as String? ?? activeLessonId; - completedLessonIds - ..clear() - ..addAll( - (data['completedLessonIds'] as List? ?? const []) - .whereType(), - ); - completedSegmentIds - ..clear() - ..addAll( - (data['completedSegmentIds'] as List? ?? const []) - .whereType(), - ); - reportedAiVariantKeys - ..clear() - ..addAll( - (data['reportedAiVariantKeys'] as List? ?? const []) - .whereType(), - ); - final savedSegmentIndexes = - data['activeSegmentIndexes'] as Map?; - if (savedSegmentIndexes != null) { - activeSegmentIndexes - ..clear() - ..addAll( - savedSegmentIndexes.map( - (key, value) => MapEntry(key, value as int? ?? 0), - ), - ); - } - lessonListeningComplete = data['lessonListeningComplete'] as bool? ?? false; - lessonSpeakingComplete = data['lessonSpeakingComplete'] as bool? ?? false; - lessonReadingComplete = data['lessonReadingComplete'] as bool? ?? false; - lessonWritingComplete = data['lessonWritingComplete'] as bool? ?? false; - lessonDialogueComplete = data['lessonDialogueComplete'] as bool? ?? false; - independentAttemptComplete = - data['independentAttemptComplete'] as bool? ?? false; - independentAttemptAssisted = - data['independentAttemptAssisted'] as bool? ?? false; - independentAttemptSpoken = - data['independentAttemptSpoken'] as bool? ?? false; - lessonWritingDraft = data['lessonWritingDraft'] as String? ?? ''; - independentAttemptDraft = data['independentAttemptDraft'] as String? ?? ''; - final reviews = data['reviews'] as List?; - if (reviews != null) { - reviewQueue - ..clear() - ..addAll( - reviews.whereType>().map(_reviewFromJson), - ); - } - final savedEvidence = data['attemptEvidence'] as List?; - if (savedEvidence != null) { - attemptEvidence - ..clear() - ..addAll( - savedEvidence.whereType>().map( - (item) => AttemptEvidence( - id: item['id'] as String? ?? '', - itemId: item['itemId'] as String? ?? '', - taskId: item['taskId'] as String? ?? '', - skill: item['skill'] as String? ?? '', - inputMode: item['inputMode'] as String? ?? 'text', - outcome: _enumValue( - EvidenceKind.values, - item['outcome'] as String?, - EvidenceKind.pending, - ), - createdAt: - DateTime.tryParse(item['createdAt'] as String? ?? '') ?? - DateTime.now(), - rawAnswer: item['rawAnswer'] as String?, - recordingPath: item['recordingPath'] as String?, - assisted: item['assisted'] as bool? ?? false, - variantIndex: item['variantIndex'] as int? ?? 0, - originalTranscript: item['originalTranscript'] as String?, - transcriptConfirmed: - item['transcriptConfirmed'] as bool? ?? false, - transcriptEdited: item['transcriptEdited'] as bool? ?? false, - ), - ), - ); - } - final savedMastery = data['mastery'] as List?; - if (savedMastery != null) { - mastery - ..clear() - ..addEntries( - savedMastery.whereType>().map((item) { - final id = item['id'] as String; - return MapEntry( - id, - MasteryItem( - id: id, - label: item['label'] as String? ?? id, - status: _enumValue( - MasteryStatus.values, - item['status'] as String?, - MasteryStatus.newItem, - ), - evidence: (item['evidence'] as List? ?? const []) - .whereType() - .map( - (name) => _enumValue( - EvidenceKind.values, - name, - EvidenceKind.pending, - ), - ) - .toList(), - needsReview: item['needsReview'] as bool? ?? false, - checkpoint: item['checkpoint'] as int? ?? 0, - firstTaughtAt: DateTime.tryParse( - item['firstTaughtAt'] as String? ?? '', - ), - ), - ); - }), - ); - } - final savedAssessments = data['assessments'] as List?; - if (savedAssessments != null) { - assessments - ..clear() - ..addAll( - savedAssessments.whereType>().map((item) { - final rawResults = - item['results'] as Map? ?? const {}; - return AssessmentRecord( - packId: item['packId'] as String, - completedAt: - DateTime.tryParse(item['completedAt'] as String? ?? '') ?? - DateTime.now(), - results: { - for (final skill in AssessmentSkill.values) - skill: rawResults[skill.name] == true, - }, - pendingSkills: - (item['pendingSkills'] as List? ?? const []) - .whereType() - .map( - (name) => _enumValue( - AssessmentSkill.values, - name, - AssessmentSkill.speaking, - ), - ) - .toSet(), - ); - }), - ); - } - final savedDraft = data['assessmentDraft'] as Map?; - if (savedDraft != null) { - assessmentDraft = AssessmentDraft( - packId: savedDraft['packId'] as String, - taskIndex: savedDraft['taskIndex'] as int? ?? 0, - results: (savedDraft['results'] as Map? ?? const {}) - .map((key, value) => MapEntry(key, value == true)), - ); - } - dialogueDraft = _dialogueDraftFromJson( - data['dialogueDraft'] as Map?, - ); - sceneDialogueDraft = _dialogueDraftFromJson( - data['sceneDialogueDraft'] as Map?, - ); - } - - DialogueDraft? _dialogueDraftFromJson(Map? saved) { - if (saved == null || saved['lessonId'] is! String) return null; - return DialogueDraft( - lessonId: saved['lessonId'] as String, - stage: saved['stage'] as int? ?? 0, - usedHelp: saved['usedHelp'] as bool? ?? false, - turns: (saved['turns'] as List? ?? const []) - .whereType>() - .map( - (turn) => DialogueTurn( - text: turn['text'] as String, - isLearner: turn['isLearner'] as bool? ?? false, - translation: turn['translation'] as String?, - ), - ) - .toList(), - ); - } - - Map? _dialogueDraftToJson(DialogueDraft? draft) => - draft == null - ? null - : { - 'lessonId': draft.lessonId, - 'stage': draft.stage, - 'usedHelp': draft.usedHelp, - 'turns': draft.turns - .map( - (turn) => { - 'text': turn.text, - 'isLearner': turn.isLearner, - 'translation': turn.translation, - }, - ) - .toList(), - }; - - T _enumValue(List values, String? name, T fallback) => - values.where((value) => value.name == name).firstOrNull ?? fallback; - - ReviewItem _reviewFromJson(Map data) => ReviewItem( - id: data['id'] as String, - target: data['target'] as String, - prompt: data['prompt'] as String, - hint: data['hint'] as String, - dueAt: DateTime.tryParse(data['dueAt'] as String? ?? '') ?? DateTime.now(), - skill: data['skill'] as String, - attempts: data['attempts'] as int? ?? 0, - successfulReviews: data['successfulReviews'] as int? ?? 0, - variantIndex: data['variantIndex'] as int? ?? 0, - lastProgressedAt: DateTime.tryParse( - data['lastProgressedAt'] as String? ?? '', - ), - isAiGenerated: data['isAiGenerated'] as bool? ?? false, - ); - void _persist() { if (!isLoaded) return; _dirty = true; if (_writing) return; _writing = true; _dirty = false; - _writeSnapshot( - jsonEncode({ - 'onboardingComplete': onboardingComplete, - 'goal': goal.name, - 'placement': placement.name, - 'dailyMinutes': dailyMinutes, - 'showChineseHints': showChineseHints, - 'keepRecordings': keepRecordings, - 'aiEndpoint': aiEndpoint, - 'aiModel': aiModel, - 'cachedAdaptiveLessonRaw': cachedAdaptiveLessonRaw, - 'cachedAdaptiveLessonAuditedAt': cachedAdaptiveLessonAuditedAt - ?.toIso8601String(), - 'cachedAdaptiveLessonAuditor': cachedAdaptiveLessonAuditor, - 'adaptiveLessonDraftId': adaptiveLessonDraftId, - 'adaptiveLessonDraftIndex': adaptiveLessonDraftIndex, - 'adaptiveLessonDraftAnswer': adaptiveLessonDraftAnswer, - 'adaptiveLessonDraftReferenceShown': adaptiveLessonDraftReferenceShown, - 'adaptiveLessonDraftUsedVoice': adaptiveLessonDraftUsedVoice, - 'adaptiveLessonDraftTranscriptEdited': - adaptiveLessonDraftTranscriptEdited, - 'adaptiveLessonDraftTranscriptConfirmed': - adaptiveLessonDraftTranscriptConfirmed, - 'adaptiveLessonDraftOriginalTranscript': - adaptiveLessonDraftOriginalTranscript, - 'adaptiveLessonDraftRecordingPath': adaptiveLessonDraftRecordingPath, - 'reportedAdaptiveLessonIds': reportedAdaptiveLessonIds.toList(), - 'temporaryLexicon': temporaryLexicon.values - .map( - (entry) => { - 'query': entry.query, - 'definition': entry.definition, - 'provider': entry.provider, - 'model': entry.model, - 'createdAt': entry.createdAt.toIso8601String(), - }, - ) - .toList(), - 'sentenceAnalyses': sentenceAnalyses.entries - .map( - (entry) => { - 'query': entry.key, - 'payload': entry.value.toJson(), - 'provider': entry.value.provider, - 'model': entry.value.model, - 'createdAt': entry.value.createdAt.toIso8601String(), - }, - ) - .toList(), - 'aiProvider': aiProvider.name, - 'lessonStep': lessonStep.name, - 'previewIndex': previewIndex, - 'completedLessons': completedLessons, - 'activeLessonId': activeLessonId, - 'completedLessonIds': completedLessonIds.toList(), - 'completedSegmentIds': completedSegmentIds.toList(), - 'reportedAiVariantKeys': reportedAiVariantKeys.toList(), - 'activeSegmentIndexes': activeSegmentIndexes, - 'lessonListeningComplete': lessonListeningComplete, - 'lessonSpeakingComplete': lessonSpeakingComplete, - 'lessonReadingComplete': lessonReadingComplete, - 'lessonWritingComplete': lessonWritingComplete, - 'lessonDialogueComplete': lessonDialogueComplete, - 'independentAttemptComplete': independentAttemptComplete, - 'independentAttemptAssisted': independentAttemptAssisted, - 'independentAttemptSpoken': independentAttemptSpoken, - 'lessonWritingDraft': lessonWritingDraft, - 'independentAttemptDraft': independentAttemptDraft, - 'reviews': reviewQueue - .map( - (item) => { - 'id': item.id, - 'target': item.target, - 'prompt': item.prompt, - 'hint': item.hint, - 'dueAt': item.dueAt.toIso8601String(), - 'skill': item.skill, - 'attempts': item.attempts, - 'successfulReviews': item.successfulReviews, - 'variantIndex': item.variantIndex, - 'lastProgressedAt': item.lastProgressedAt?.toIso8601String(), - 'isAiGenerated': item.isAiGenerated, - }, - ) - .toList(), - 'mastery': mastery.values - .map( - (item) => { - 'id': item.id, - 'label': item.label, - 'status': item.status.name, - 'evidence': item.evidence.map((value) => value.name).toList(), - 'needsReview': item.needsReview, - 'checkpoint': item.checkpoint, - 'firstTaughtAt': item.firstTaughtAt?.toIso8601String(), - }, - ) - .toList(), - 'attemptEvidence': attemptEvidence - .map( - (entry) => { - 'id': entry.id, - 'itemId': entry.itemId, - 'taskId': entry.taskId, - 'skill': entry.skill, - 'inputMode': entry.inputMode, - 'outcome': entry.outcome.name, - 'createdAt': entry.createdAt.toIso8601String(), - 'rawAnswer': entry.rawAnswer, - 'recordingPath': entry.recordingPath, - 'assisted': entry.assisted, - 'variantIndex': entry.variantIndex, - 'originalTranscript': entry.originalTranscript, - 'transcriptConfirmed': entry.transcriptConfirmed, - 'transcriptEdited': entry.transcriptEdited, - }, - ) - .toList(), - 'assessments': assessments - .map( - (record) => { - 'packId': record.packId, - 'completedAt': record.completedAt.toIso8601String(), - 'results': { - for (final entry in record.results.entries) - entry.key.name: entry.value, - }, - 'pendingSkills': record.pendingSkills - .map((skill) => skill.name) - .toList(), - }, - ) - .toList(), - 'assessmentDraft': assessmentDraft == null - ? null - : { - 'packId': assessmentDraft!.packId, - 'taskIndex': assessmentDraft!.taskIndex, - 'results': assessmentDraft!.results, - }, - 'dialogueDraft': _dialogueDraftToJson(dialogueDraft), - 'sceneDialogueDraft': _dialogueDraftToJson(sceneDialogueDraft), - }), - ).catchError((_) => false).whenComplete(() { - _writing = false; - if (_dirty) _persist(); - }); + _writeSnapshot(jsonEncode(_toSnapshotJson())) + .catchError((_) => false) + .whenComplete(() { + _writing = false; + if (_dirty) _persist(); + }); } Future _writeSnapshot(String payload) async { diff --git a/kouyu_english/lib/core/app_state_snapshot.dart b/kouyu_english/lib/core/app_state_snapshot.dart new file mode 100644 index 0000000..5395be7 --- /dev/null +++ b/kouyu_english/lib/core/app_state_snapshot.dart @@ -0,0 +1,475 @@ +part of 'app_state.dart'; + +/// Local snapshot format of [AppState]. Keys are persisted on device, so +/// renaming one silently drops that part of a learner's saved progress. +extension _AppStateSnapshot on AppState { + void _restore(Map data) { + onboardingComplete = + data['onboardingComplete'] as bool? ?? onboardingComplete; + goal = _enumValue(LearningGoal.values, data['goal'] as String?, goal); + placement = _enumValue( + PlacementLevel.values, + data['placement'] as String?, + placement, + ); + dailyMinutes = data['dailyMinutes'] as int? ?? dailyMinutes; + showChineseHints = data['showChineseHints'] as bool? ?? showChineseHints; + keepRecordings = data['keepRecordings'] as bool? ?? keepRecordings; + aiEndpoint = data['aiEndpoint'] as String? ?? aiEndpoint; + aiModel = data['aiModel'] as String? ?? aiModel; + cachedAdaptiveLessonRaw = data['cachedAdaptiveLessonRaw'] as String?; + cachedAdaptiveLessonAuditedAt = DateTime.tryParse( + data['cachedAdaptiveLessonAuditedAt'] as String? ?? '', + ); + cachedAdaptiveLessonAuditor = + data['cachedAdaptiveLessonAuditor'] as String?; + adaptiveLessonDraftId = data['adaptiveLessonDraftId'] as String?; + adaptiveLessonDraftIndex = data['adaptiveLessonDraftIndex'] as int? ?? 0; + adaptiveLessonDraftAnswer = + data['adaptiveLessonDraftAnswer'] as String? ?? ''; + adaptiveLessonDraftReferenceShown = + data['adaptiveLessonDraftReferenceShown'] as bool? ?? false; + adaptiveLessonDraftUsedVoice = + data['adaptiveLessonDraftUsedVoice'] as bool? ?? false; + adaptiveLessonDraftTranscriptEdited = + data['adaptiveLessonDraftTranscriptEdited'] as bool? ?? false; + adaptiveLessonDraftTranscriptConfirmed = + data['adaptiveLessonDraftTranscriptConfirmed'] as bool? ?? false; + adaptiveLessonDraftOriginalTranscript = + data['adaptiveLessonDraftOriginalTranscript'] as String? ?? ''; + adaptiveLessonDraftRecordingPath = + data['adaptiveLessonDraftRecordingPath'] as String?; + reportedAdaptiveLessonIds + ..clear() + ..addAll( + (data['reportedAdaptiveLessonIds'] as List? ?? const []) + .whereType(), + ); + final savedTemporaryLexicon = data['temporaryLexicon'] as List?; + if (savedTemporaryLexicon != null) { + temporaryLexicon + ..clear() + ..addEntries( + savedTemporaryLexicon + .whereType>() + .map((item) { + final query = item['query'] as String? ?? ''; + return MapEntry( + _normalizeLexiconQuery(query), + TemporaryLexiconEntry( + query: query, + definition: item['definition'] as String? ?? '', + provider: item['provider'] as String? ?? 'unknown', + model: item['model'] as String? ?? '', + createdAt: + DateTime.tryParse(item['createdAt'] as String? ?? '') ?? + DateTime.now(), + ), + ); + }) + .where( + (entry) => + entry.key.isNotEmpty && entry.value.definition.isNotEmpty, + ), + ); + } + final savedSentenceAnalyses = data['sentenceAnalyses'] as List?; + if (savedSentenceAnalyses != null) { + sentenceAnalyses + ..clear() + ..addEntries( + savedSentenceAnalyses + .whereType>() + .map((item) { + final query = item['query'] as String? ?? ''; + final payload = item['payload'] as Map? ?? {}; + return MapEntry( + _normalizeLexiconQuery(query), + SentenceAnalysisResult.fromJson(payload), + ); + }) + .where( + (entry) => + entry.key.isNotEmpty && entry.value.translation.isNotEmpty, + ), + ); + } + aiProvider = _enumValue( + AiProviderType.values, + data['aiProvider'] as String?, + aiProvider, + ); + lessonStep = _enumValue( + LessonStep.values, + data['lessonStep'] as String?, + lessonStep, + ); + previewIndex = data['previewIndex'] as int? ?? previewIndex; + completedLessons = data['completedLessons'] as int? ?? completedLessons; + activeLessonId = data['activeLessonId'] as String? ?? activeLessonId; + completedLessonIds + ..clear() + ..addAll( + (data['completedLessonIds'] as List? ?? const []) + .whereType(), + ); + completedSegmentIds + ..clear() + ..addAll( + (data['completedSegmentIds'] as List? ?? const []) + .whereType(), + ); + reportedAiVariantKeys + ..clear() + ..addAll( + (data['reportedAiVariantKeys'] as List? ?? const []) + .whereType(), + ); + final savedSegmentIndexes = + data['activeSegmentIndexes'] as Map?; + if (savedSegmentIndexes != null) { + activeSegmentIndexes + ..clear() + ..addAll( + savedSegmentIndexes.map( + (key, value) => MapEntry(key, value as int? ?? 0), + ), + ); + } + lessonListeningComplete = data['lessonListeningComplete'] as bool? ?? false; + lessonSpeakingComplete = data['lessonSpeakingComplete'] as bool? ?? false; + lessonReadingComplete = data['lessonReadingComplete'] as bool? ?? false; + lessonWritingComplete = data['lessonWritingComplete'] as bool? ?? false; + lessonDialogueComplete = data['lessonDialogueComplete'] as bool? ?? false; + independentAttemptComplete = + data['independentAttemptComplete'] as bool? ?? false; + independentAttemptAssisted = + data['independentAttemptAssisted'] as bool? ?? false; + independentAttemptSpoken = + data['independentAttemptSpoken'] as bool? ?? false; + lessonWritingDraft = data['lessonWritingDraft'] as String? ?? ''; + independentAttemptDraft = data['independentAttemptDraft'] as String? ?? ''; + final reviews = data['reviews'] as List?; + if (reviews != null) { + reviewQueue + ..clear() + ..addAll( + reviews.whereType>().map(_reviewFromJson), + ); + } + final savedEvidence = data['attemptEvidence'] as List?; + if (savedEvidence != null) { + attemptEvidence + ..clear() + ..addAll( + savedEvidence.whereType>().map( + (item) => AttemptEvidence( + id: item['id'] as String? ?? '', + itemId: item['itemId'] as String? ?? '', + taskId: item['taskId'] as String? ?? '', + skill: item['skill'] as String? ?? '', + inputMode: item['inputMode'] as String? ?? 'text', + outcome: _enumValue( + EvidenceKind.values, + item['outcome'] as String?, + EvidenceKind.pending, + ), + createdAt: + DateTime.tryParse(item['createdAt'] as String? ?? '') ?? + DateTime.now(), + rawAnswer: item['rawAnswer'] as String?, + recordingPath: item['recordingPath'] as String?, + assisted: item['assisted'] as bool? ?? false, + variantIndex: item['variantIndex'] as int? ?? 0, + originalTranscript: item['originalTranscript'] as String?, + transcriptConfirmed: + item['transcriptConfirmed'] as bool? ?? false, + transcriptEdited: item['transcriptEdited'] as bool? ?? false, + ), + ), + ); + } + final savedMastery = data['mastery'] as List?; + if (savedMastery != null) { + mastery + ..clear() + ..addEntries( + savedMastery.whereType>().map((item) { + final id = item['id'] as String; + return MapEntry( + id, + MasteryItem( + id: id, + label: item['label'] as String? ?? id, + status: _enumValue( + MasteryStatus.values, + item['status'] as String?, + MasteryStatus.newItem, + ), + evidence: (item['evidence'] as List? ?? const []) + .whereType() + .map( + (name) => _enumValue( + EvidenceKind.values, + name, + EvidenceKind.pending, + ), + ) + .toList(), + needsReview: item['needsReview'] as bool? ?? false, + checkpoint: item['checkpoint'] as int? ?? 0, + firstTaughtAt: DateTime.tryParse( + item['firstTaughtAt'] as String? ?? '', + ), + ), + ); + }), + ); + } + final savedAssessments = data['assessments'] as List?; + if (savedAssessments != null) { + assessments + ..clear() + ..addAll( + savedAssessments.whereType>().map((item) { + final rawResults = + item['results'] as Map? ?? const {}; + return AssessmentRecord( + packId: item['packId'] as String, + completedAt: + DateTime.tryParse(item['completedAt'] as String? ?? '') ?? + DateTime.now(), + results: { + for (final skill in AssessmentSkill.values) + skill: rawResults[skill.name] == true, + }, + pendingSkills: + (item['pendingSkills'] as List? ?? const []) + .whereType() + .map( + (name) => _enumValue( + AssessmentSkill.values, + name, + AssessmentSkill.speaking, + ), + ) + .toSet(), + ); + }), + ); + } + final savedDraft = data['assessmentDraft'] as Map?; + if (savedDraft != null) { + assessmentDraft = AssessmentDraft( + packId: savedDraft['packId'] as String, + taskIndex: savedDraft['taskIndex'] as int? ?? 0, + results: (savedDraft['results'] as Map? ?? const {}) + .map((key, value) => MapEntry(key, value == true)), + ); + } + dialogueDraft = _dialogueDraftFromJson( + data['dialogueDraft'] as Map?, + ); + sceneDialogueDraft = _dialogueDraftFromJson( + data['sceneDialogueDraft'] as Map?, + ); + } + + Map _toSnapshotJson() => { + 'onboardingComplete': onboardingComplete, + 'goal': goal.name, + 'placement': placement.name, + 'dailyMinutes': dailyMinutes, + 'showChineseHints': showChineseHints, + 'keepRecordings': keepRecordings, + 'aiEndpoint': aiEndpoint, + 'aiModel': aiModel, + 'cachedAdaptiveLessonRaw': cachedAdaptiveLessonRaw, + 'cachedAdaptiveLessonAuditedAt': cachedAdaptiveLessonAuditedAt + ?.toIso8601String(), + 'cachedAdaptiveLessonAuditor': cachedAdaptiveLessonAuditor, + 'adaptiveLessonDraftId': adaptiveLessonDraftId, + 'adaptiveLessonDraftIndex': adaptiveLessonDraftIndex, + 'adaptiveLessonDraftAnswer': adaptiveLessonDraftAnswer, + 'adaptiveLessonDraftReferenceShown': adaptiveLessonDraftReferenceShown, + 'adaptiveLessonDraftUsedVoice': adaptiveLessonDraftUsedVoice, + 'adaptiveLessonDraftTranscriptEdited': adaptiveLessonDraftTranscriptEdited, + 'adaptiveLessonDraftTranscriptConfirmed': + adaptiveLessonDraftTranscriptConfirmed, + 'adaptiveLessonDraftOriginalTranscript': + adaptiveLessonDraftOriginalTranscript, + 'adaptiveLessonDraftRecordingPath': adaptiveLessonDraftRecordingPath, + 'reportedAdaptiveLessonIds': reportedAdaptiveLessonIds.toList(), + 'temporaryLexicon': temporaryLexicon.values + .map( + (entry) => { + 'query': entry.query, + 'definition': entry.definition, + 'provider': entry.provider, + 'model': entry.model, + 'createdAt': entry.createdAt.toIso8601String(), + }, + ) + .toList(), + 'sentenceAnalyses': sentenceAnalyses.entries + .map( + (entry) => { + 'query': entry.key, + 'payload': entry.value.toJson(), + 'provider': entry.value.provider, + 'model': entry.value.model, + 'createdAt': entry.value.createdAt.toIso8601String(), + }, + ) + .toList(), + 'aiProvider': aiProvider.name, + 'lessonStep': lessonStep.name, + 'previewIndex': previewIndex, + 'completedLessons': completedLessons, + 'activeLessonId': activeLessonId, + 'completedLessonIds': completedLessonIds.toList(), + 'completedSegmentIds': completedSegmentIds.toList(), + 'reportedAiVariantKeys': reportedAiVariantKeys.toList(), + 'activeSegmentIndexes': activeSegmentIndexes, + 'lessonListeningComplete': lessonListeningComplete, + 'lessonSpeakingComplete': lessonSpeakingComplete, + 'lessonReadingComplete': lessonReadingComplete, + 'lessonWritingComplete': lessonWritingComplete, + 'lessonDialogueComplete': lessonDialogueComplete, + 'independentAttemptComplete': independentAttemptComplete, + 'independentAttemptAssisted': independentAttemptAssisted, + 'independentAttemptSpoken': independentAttemptSpoken, + 'lessonWritingDraft': lessonWritingDraft, + 'independentAttemptDraft': independentAttemptDraft, + 'reviews': reviewQueue + .map( + (item) => { + 'id': item.id, + 'target': item.target, + 'prompt': item.prompt, + 'hint': item.hint, + 'dueAt': item.dueAt.toIso8601String(), + 'skill': item.skill, + 'attempts': item.attempts, + 'successfulReviews': item.successfulReviews, + 'variantIndex': item.variantIndex, + 'lastProgressedAt': item.lastProgressedAt?.toIso8601String(), + 'isAiGenerated': item.isAiGenerated, + }, + ) + .toList(), + 'mastery': mastery.values + .map( + (item) => { + 'id': item.id, + 'label': item.label, + 'status': item.status.name, + 'evidence': item.evidence.map((value) => value.name).toList(), + 'needsReview': item.needsReview, + 'checkpoint': item.checkpoint, + 'firstTaughtAt': item.firstTaughtAt?.toIso8601String(), + }, + ) + .toList(), + 'attemptEvidence': attemptEvidence + .map( + (entry) => { + 'id': entry.id, + 'itemId': entry.itemId, + 'taskId': entry.taskId, + 'skill': entry.skill, + 'inputMode': entry.inputMode, + 'outcome': entry.outcome.name, + 'createdAt': entry.createdAt.toIso8601String(), + 'rawAnswer': entry.rawAnswer, + 'recordingPath': entry.recordingPath, + 'assisted': entry.assisted, + 'variantIndex': entry.variantIndex, + 'originalTranscript': entry.originalTranscript, + 'transcriptConfirmed': entry.transcriptConfirmed, + 'transcriptEdited': entry.transcriptEdited, + }, + ) + .toList(), + 'assessments': assessments + .map( + (record) => { + 'packId': record.packId, + 'completedAt': record.completedAt.toIso8601String(), + 'results': { + for (final entry in record.results.entries) + entry.key.name: entry.value, + }, + 'pendingSkills': record.pendingSkills + .map((skill) => skill.name) + .toList(), + }, + ) + .toList(), + 'assessmentDraft': assessmentDraft == null + ? null + : { + 'packId': assessmentDraft!.packId, + 'taskIndex': assessmentDraft!.taskIndex, + 'results': assessmentDraft!.results, + }, + 'dialogueDraft': _dialogueDraftToJson(dialogueDraft), + 'sceneDialogueDraft': _dialogueDraftToJson(sceneDialogueDraft), + }; +} + +DialogueDraft? _dialogueDraftFromJson(Map? saved) { + if (saved == null || saved['lessonId'] is! String) return null; + return DialogueDraft( + lessonId: saved['lessonId'] as String, + stage: saved['stage'] as int? ?? 0, + usedHelp: saved['usedHelp'] as bool? ?? false, + turns: (saved['turns'] as List? ?? const []) + .whereType>() + .map( + (turn) => DialogueTurn( + text: turn['text'] as String, + isLearner: turn['isLearner'] as bool? ?? false, + translation: turn['translation'] as String?, + ), + ) + .toList(), + ); +} + +Map? _dialogueDraftToJson(DialogueDraft? draft) => + draft == null + ? null + : { + 'lessonId': draft.lessonId, + 'stage': draft.stage, + 'usedHelp': draft.usedHelp, + 'turns': draft.turns + .map( + (turn) => { + 'text': turn.text, + 'isLearner': turn.isLearner, + 'translation': turn.translation, + }, + ) + .toList(), + }; + +T _enumValue(List values, String? name, T fallback) => + values.where((value) => value.name == name).firstOrNull ?? fallback; + +ReviewItem _reviewFromJson(Map data) => ReviewItem( + id: data['id'] as String, + target: data['target'] as String, + prompt: data['prompt'] as String, + hint: data['hint'] as String, + dueAt: DateTime.tryParse(data['dueAt'] as String? ?? '') ?? DateTime.now(), + skill: data['skill'] as String, + attempts: data['attempts'] as int? ?? 0, + successfulReviews: data['successfulReviews'] as int? ?? 0, + variantIndex: data['variantIndex'] as int? ?? 0, + lastProgressedAt: DateTime.tryParse( + data['lastProgressedAt'] as String? ?? '', + ), + isAiGenerated: data['isAiGenerated'] as bool? ?? false, +);