refactor: 将 AppState 本地存档序列化拆分到 app_state_snapshot.dart
纯代码搬移,不改行为:_restore、快照 JSON 构建及相关解析函数移入 part 文件。 已用覆盖全部存档字段的快照比对验证,重构前后写出的存档逐字节一致。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,481 +355,15 @@ 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(() {
|
||||
_writeSnapshot(jsonEncode(_toSnapshotJson()))
|
||||
.catchError((_) => false)
|
||||
.whenComplete(() {
|
||||
_writing = false;
|
||||
if (_dirty) _persist();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
Reference in New Issue
Block a user