refactor: 将 AppState 本地存档序列化拆分到 app_state_snapshot.dart

纯代码搬移,不改行为:_restore、快照 JSON 构建及相关解析函数移入 part 文件。
已用覆盖全部存档字段的快照比对验证,重构前后写出的存档逐字节一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-16 17:07:23 +09:00
co-authored by Claude Opus 5
parent 8ed52312fb
commit 5faeda5e2d
2 changed files with 483 additions and 472 deletions
+8 -472
View File
@@ -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<String, dynamic> 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<dynamic>? ?? const [])
.whereType<String>(),
);
final savedTemporaryLexicon = data['temporaryLexicon'] as List<dynamic>?;
if (savedTemporaryLexicon != null) {
temporaryLexicon
..clear()
..addEntries(
savedTemporaryLexicon
.whereType<Map<String, dynamic>>()
.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<dynamic>?;
if (savedSentenceAnalyses != null) {
sentenceAnalyses
..clear()
..addEntries(
savedSentenceAnalyses
.whereType<Map<String, dynamic>>()
.map((item) {
final query = item['query'] as String? ?? '';
final payload = item['payload'] as Map<String, dynamic>? ?? {};
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<dynamic>? ?? const [])
.whereType<String>(),
);
completedSegmentIds
..clear()
..addAll(
(data['completedSegmentIds'] as List<dynamic>? ?? const [])
.whereType<String>(),
);
reportedAiVariantKeys
..clear()
..addAll(
(data['reportedAiVariantKeys'] as List<dynamic>? ?? const [])
.whereType<String>(),
);
final savedSegmentIndexes =
data['activeSegmentIndexes'] as Map<String, dynamic>?;
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<dynamic>?;
if (reviews != null) {
reviewQueue
..clear()
..addAll(
reviews.whereType<Map<String, dynamic>>().map(_reviewFromJson),
);
}
final savedEvidence = data['attemptEvidence'] as List<dynamic>?;
if (savedEvidence != null) {
attemptEvidence
..clear()
..addAll(
savedEvidence.whereType<Map<String, dynamic>>().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<dynamic>?;
if (savedMastery != null) {
mastery
..clear()
..addEntries(
savedMastery.whereType<Map<String, dynamic>>().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<dynamic>? ?? const [])
.whereType<String>()
.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<dynamic>?;
if (savedAssessments != null) {
assessments
..clear()
..addAll(
savedAssessments.whereType<Map<String, dynamic>>().map((item) {
final rawResults =
item['results'] as Map<String, dynamic>? ?? 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<dynamic>? ?? const [])
.whereType<String>()
.map(
(name) => _enumValue(
AssessmentSkill.values,
name,
AssessmentSkill.speaking,
),
)
.toSet(),
);
}),
);
}
final savedDraft = data['assessmentDraft'] as Map<String, dynamic>?;
if (savedDraft != null) {
assessmentDraft = AssessmentDraft(
packId: savedDraft['packId'] as String,
taskIndex: savedDraft['taskIndex'] as int? ?? 0,
results: (savedDraft['results'] as Map<String, dynamic>? ?? const {})
.map((key, value) => MapEntry(key, value == true)),
);
}
dialogueDraft = _dialogueDraftFromJson(
data['dialogueDraft'] as Map<String, dynamic>?,
);
sceneDialogueDraft = _dialogueDraftFromJson(
data['sceneDialogueDraft'] as Map<String, dynamic>?,
);
}
DialogueDraft? _dialogueDraftFromJson(Map<String, dynamic>? 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<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(
(turn) => DialogueTurn(
text: turn['text'] as String,
isLearner: turn['isLearner'] as bool? ?? false,
translation: turn['translation'] as String?,
),
)
.toList(),
);
}
Map<String, dynamic>? _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<T extends Enum>(List<T> values, String? name, T fallback) =>
values.where((value) => value.name == name).firstOrNull ?? fallback;
ReviewItem _reviewFromJson(Map<String, dynamic> 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<void> _writeSnapshot(String payload) async {
@@ -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<String, dynamic> 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<dynamic>? ?? const [])
.whereType<String>(),
);
final savedTemporaryLexicon = data['temporaryLexicon'] as List<dynamic>?;
if (savedTemporaryLexicon != null) {
temporaryLexicon
..clear()
..addEntries(
savedTemporaryLexicon
.whereType<Map<String, dynamic>>()
.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<dynamic>?;
if (savedSentenceAnalyses != null) {
sentenceAnalyses
..clear()
..addEntries(
savedSentenceAnalyses
.whereType<Map<String, dynamic>>()
.map((item) {
final query = item['query'] as String? ?? '';
final payload = item['payload'] as Map<String, dynamic>? ?? {};
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<dynamic>? ?? const [])
.whereType<String>(),
);
completedSegmentIds
..clear()
..addAll(
(data['completedSegmentIds'] as List<dynamic>? ?? const [])
.whereType<String>(),
);
reportedAiVariantKeys
..clear()
..addAll(
(data['reportedAiVariantKeys'] as List<dynamic>? ?? const [])
.whereType<String>(),
);
final savedSegmentIndexes =
data['activeSegmentIndexes'] as Map<String, dynamic>?;
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<dynamic>?;
if (reviews != null) {
reviewQueue
..clear()
..addAll(
reviews.whereType<Map<String, dynamic>>().map(_reviewFromJson),
);
}
final savedEvidence = data['attemptEvidence'] as List<dynamic>?;
if (savedEvidence != null) {
attemptEvidence
..clear()
..addAll(
savedEvidence.whereType<Map<String, dynamic>>().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<dynamic>?;
if (savedMastery != null) {
mastery
..clear()
..addEntries(
savedMastery.whereType<Map<String, dynamic>>().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<dynamic>? ?? const [])
.whereType<String>()
.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<dynamic>?;
if (savedAssessments != null) {
assessments
..clear()
..addAll(
savedAssessments.whereType<Map<String, dynamic>>().map((item) {
final rawResults =
item['results'] as Map<String, dynamic>? ?? 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<dynamic>? ?? const [])
.whereType<String>()
.map(
(name) => _enumValue(
AssessmentSkill.values,
name,
AssessmentSkill.speaking,
),
)
.toSet(),
);
}),
);
}
final savedDraft = data['assessmentDraft'] as Map<String, dynamic>?;
if (savedDraft != null) {
assessmentDraft = AssessmentDraft(
packId: savedDraft['packId'] as String,
taskIndex: savedDraft['taskIndex'] as int? ?? 0,
results: (savedDraft['results'] as Map<String, dynamic>? ?? const {})
.map((key, value) => MapEntry(key, value == true)),
);
}
dialogueDraft = _dialogueDraftFromJson(
data['dialogueDraft'] as Map<String, dynamic>?,
);
sceneDialogueDraft = _dialogueDraftFromJson(
data['sceneDialogueDraft'] as Map<String, dynamic>?,
);
}
Map<String, dynamic> _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<String, dynamic>? 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<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(
(turn) => DialogueTurn(
text: turn['text'] as String,
isLearner: turn['isLearner'] as bool? ?? false,
translation: turn['translation'] as String?,
),
)
.toList(),
);
}
Map<String, dynamic>? _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<T extends Enum>(List<T> values, String? name, T fallback) =>
values.where((value) => value.name == name).firstOrNull ?? fallback;
ReviewItem _reviewFromJson(Map<String, dynamic> 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,
);