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 'sherpa_stt_service.dart';
|
||||||
import 'sync/sync_coordinator.dart';
|
import 'sync/sync_coordinator.dart';
|
||||||
|
|
||||||
|
part 'app_state_snapshot.dart';
|
||||||
|
|
||||||
class AppState extends ChangeNotifier {
|
class AppState extends ChangeNotifier {
|
||||||
static const _storageKey = 'learning_state_v1';
|
static const _storageKey = 'learning_state_v1';
|
||||||
bool isLoaded = false;
|
bool isLoaded = false;
|
||||||
@@ -353,484 +355,18 @@ class AppState extends ChangeNotifier {
|
|||||||
return true;
|
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() {
|
void _persist() {
|
||||||
if (!isLoaded) return;
|
if (!isLoaded) return;
|
||||||
_dirty = true;
|
_dirty = true;
|
||||||
if (_writing) return;
|
if (_writing) return;
|
||||||
_writing = true;
|
_writing = true;
|
||||||
_dirty = false;
|
_dirty = false;
|
||||||
_writeSnapshot(
|
_writeSnapshot(jsonEncode(_toSnapshotJson()))
|
||||||
jsonEncode({
|
.catchError((_) => false)
|
||||||
'onboardingComplete': onboardingComplete,
|
.whenComplete(() {
|
||||||
'goal': goal.name,
|
_writing = false;
|
||||||
'placement': placement.name,
|
if (_dirty) _persist();
|
||||||
'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();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _writeSnapshot(String payload) async {
|
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,
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user