import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'models.dart'; import 'a0_core.dart'; import 'generated_content.dart'; import 'local_store.dart'; import 'seed_courses.dart'; import 'ai_config.dart'; import 'ai_service.dart'; import 'sherpa_stt_service.dart'; import 'sync/sync_coordinator.dart'; class AppState extends ChangeNotifier { static const _storageKey = 'learning_state_v1'; bool isLoaded = false; bool _writing = false; bool _dirty = false; // Widget/unit tests keep using the mock preference backend. Production uses // SQLite through Drift; this also prevents test cases sharing a real device // database between runs. bool get _usesLegacyTestStore => Platform.environment['FLUTTER_TEST'] == 'true'; AppState() { addListener(_persist); } LearningGoal goal = LearningGoal.dailyLife; int dailyMinutes = 20; PlacementLevel placement = PlacementLevel.beginner; bool onboardingComplete = false; bool showChineseHints = true; bool keepRecordings = false; int completedLessons = 0; String activeLessonId = 'a0-01'; final Set completedLessonIds = {}; final Set completedSegmentIds = {}; final Set reportedAiVariantKeys = {}; final Map activeSegmentIndexes = {}; final List assessments = []; AssessmentDraft? assessmentDraft; DialogueDraft? dialogueDraft; LessonStep lessonStep = LessonStep.preview; int previewIndex = 0; bool lessonListeningComplete = false; bool lessonSpeakingComplete = false; bool lessonReadingComplete = false; bool lessonWritingComplete = false; bool lessonDialogueComplete = false; bool independentAttemptComplete = false; bool independentAttemptAssisted = false; bool independentAttemptSpoken = false; String lessonWritingDraft = ''; String independentAttemptDraft = ''; AiProviderType aiProvider = AiProviderType.mock; String aiEndpoint = ''; String aiModel = ''; AiConfigFile get aiConfig => AiConfigFile( provider: aiProvider, endpoint: aiEndpoint, model: aiModel, ); String? cachedAdaptiveLessonRaw; DateTime? cachedAdaptiveLessonAuditedAt; String? cachedAdaptiveLessonAuditor; String? adaptiveLessonDraftId; int adaptiveLessonDraftIndex = 0; String adaptiveLessonDraftAnswer = ''; bool adaptiveLessonDraftReferenceShown = false; bool adaptiveLessonDraftUsedVoice = false; bool adaptiveLessonDraftTranscriptEdited = false; bool adaptiveLessonDraftTranscriptConfirmed = false; String adaptiveLessonDraftOriginalTranscript = ''; String? adaptiveLessonDraftRecordingPath; final Set reportedAdaptiveLessonIds = {}; final Map temporaryLexicon = {}; final List reviewQueue = []; final List attemptEvidence = []; final Map mastery = {}; List get dueReviews { final now = DateTime.now(); final due = reviewQueue.where((item) => !item.dueAt.isAfter(now)).toList() ..sort((a, b) => a.dueAt.compareTo(b.dueAt)); return due; } int get dueReviewCount => dueReviews.length; bool get reviewIsPrimary => dueReviewCount > 0; int get reviewBudgetSeconds => switch (dailyMinutes) { 10 => 3 * 60, 30 => 8 * 60, _ => 5 * 60, }; int get dueReviewEstimatedSeconds => dueReviewCount * 60; bool get reviewBacklog { final sevenDaysAgo = DateTime.now().subtract(const Duration(days: 7)); return dueReviewEstimatedSeconds > reviewBudgetSeconds * 2 || dueReviews.any((item) => item.dueAt.isBefore(sevenDaysAgo)); } bool get hasResumableLessonDialogue => dialogueDraft != null && dialogueDraft!.lessonId == activeLessonId && !lessonDialogueComplete; int activeSegmentIndexFor(String lessonId) => activeSegmentIndexes[lessonId] ?? 0; String get _activeSegmentId { final lesson = lessonById(activeLessonId); return lesson.segments[activeSegmentIndexFor(activeLessonId)].id; } bool isSegmentComplete(String segmentId) => completedSegmentIds.contains(segmentId); void completeSegment(String lessonId, int segmentIndex) { final segments = lessonById(lessonId).segments; if (segmentIndex < 0 || segmentIndex >= segments.length) return; completedSegmentIds.add(segments[segmentIndex].id); activeSegmentIndexes[lessonId] = (segmentIndex + 1).clamp( 0, segments.length - 1, ); notifyListeners(); } int get knownItemCount => mastery.length; int get coreUsableCount => mastery.entries .where( (entry) => a0CoreItems.containsKey(entry.key) && (entry.value.status == MasteryStatus.use || entry.value.status == MasteryStatus.master) && !entry.value.needsReview, ) .length; int get coreMasteredCount => mastery.entries .where( (entry) => a0CoreItems.containsKey(entry.key) && entry.value.status == MasteryStatus.master && !entry.value.needsReview, ) .length; bool get hasTwoValidAssessmentPasses { final passed = assessments.where((record) => record.passed).toList() ..sort((a, b) => b.completedAt.compareTo(a.completedAt)); if (passed.length < 2) { return false; } final latest = passed.first; if (DateTime.now().difference(latest.completedAt) > const Duration(days: 30)) { return false; } return passed .skip(1) .any( (record) => record.packId != latest.packId && latest.completedAt.difference(record.completedAt) >= const Duration(hours: 24), ); } bool get a0Passed => coreUsableCount >= 48 && coreMasteredCount >= 30 && hasTwoValidAssessmentPasses; bool canStartAssessmentPack(String packId) { if (packId != 'A0-E2') return true; final first = assessments .where((record) => record.packId == 'A0-E1' && record.passed) .firstOrNull; return first != null && DateTime.now().difference(first.completedAt) >= const Duration(hours: 24); } AssessmentRecord recordAssessment(AssessmentRecord record) { final canonicalPackId = record.packId.endsWith('R') ? record.packId.substring(0, record.packId.length - 1) : record.packId; final normalized = AssessmentRecord( packId: canonicalPackId, completedAt: record.completedAt, results: record.results, pendingSkills: record.pendingSkills, ); final previous = assessments .where((item) => item.packId == canonicalPackId) .firstOrNull; final isWithinWindow = previous != null && record.completedAt.difference(previous.completedAt) <= const Duration(days: 7); final merged = AssessmentRecord( packId: canonicalPackId, completedAt: normalized.completedAt, results: { for (final skill in AssessmentSkill.values) skill: normalized.results[skill] == true || (isWithinWindow && previous.results[skill] == true), }, pendingSkills: { for (final skill in AssessmentSkill.values) if (normalized.results[skill] != true && (normalized.pendingSkills.contains(skill) || (!normalized.results.containsKey(skill) && isWithinWindow && previous.pendingSkills.contains(skill)))) skill, }, ); assessments.removeWhere((item) => item.packId == canonicalPackId); assessments.add(merged); notifyListeners(); return merged; } void saveAssessmentDraft(AssessmentDraft draft) { assessmentDraft = draft; notifyListeners(); } void clearAssessmentDraft() { if (assessmentDraft == null) return; assessmentDraft = null; notifyListeners(); } void saveDialogueDraft(DialogueDraft draft) { dialogueDraft = draft; notifyListeners(); } void clearDialogueDraft() { if (dialogueDraft == null) return; dialogueDraft = null; notifyListeners(); } int get usableMasteryCount => mastery.values .where( (item) => item.status == MasteryStatus.use || item.status == MasteryStatus.master, ) .length; bool get lessonCanComplete => lessonListeningComplete && lessonSpeakingComplete && lessonReadingComplete && lessonWritingComplete && lessonDialogueComplete && independentAttemptComplete; Future load() async { try { unawaited(SherpaSttService.instance.initialize()); final config = await AiConfigFile.loadFromAsset(); if (config != null) { if (config.apiKey != null && config.apiKey!.trim().isNotEmpty) { AiService.instance.setFallbackApiKey(config.apiKey); } if (aiEndpoint.isEmpty && config.endpoint.isNotEmpty) { aiEndpoint = config.endpoint; } if (aiModel.isEmpty && config.model.isNotEmpty) { aiModel = config.model; } if (aiProvider == AiProviderType.mock && config.provider != AiProviderType.mock) { aiProvider = config.provider; } } String? raw; if (!_usesLegacyTestStore) { raw = await LocalSnapshotStore.instance.read(); } if (raw == null) { final preferences = await SharedPreferences.getInstance(); raw = preferences.getString(_storageKey); // One-time migration from builds that used SharedPreferences only. if (raw != null && !_usesLegacyTestStore) { await LocalSnapshotStore.instance.write(raw); } } if (raw != null) _restore(jsonDecode(raw) as Map); if (config != null) { if (aiEndpoint.isEmpty && config.endpoint.isNotEmpty) { aiEndpoint = config.endpoint; } if (aiModel.isEmpty && config.model.isNotEmpty) { aiModel = config.model; } if (aiProvider == AiProviderType.mock && config.provider != AiProviderType.mock && raw == null) { aiProvider = config.provider; } } } catch (_) { // A corrupt local cache must never prevent access to offline lessons. } finally { isLoaded = true; notifyListeners(); } } Future reloadAiConfigFromAsset() async { final config = await AiConfigFile.loadFromAsset(); if (config == null) return false; aiProvider = config.provider; aiEndpoint = config.endpoint; aiModel = config.model; if (config.apiKey != null && config.apiKey!.trim().isNotEmpty) { AiService.instance.setFallbackApiKey(config.apiKey); } notifyListeners(); return true; } void _restore(Map data) { onboardingComplete = data['onboardingComplete'] as bool? ?? onboardingComplete; goal = _enumValue(LearningGoal.values, data['goal'] as String?, goal); placement = _enumValue( PlacementLevel.values, data['placement'] as String?, placement, ); dailyMinutes = data['dailyMinutes'] as int? ?? dailyMinutes; showChineseHints = data['showChineseHints'] as bool? ?? showChineseHints; keepRecordings = data['keepRecordings'] as bool? ?? keepRecordings; aiEndpoint = data['aiEndpoint'] as String? ?? aiEndpoint; aiModel = data['aiModel'] as String? ?? aiModel; cachedAdaptiveLessonRaw = data['cachedAdaptiveLessonRaw'] as String?; cachedAdaptiveLessonAuditedAt = DateTime.tryParse( data['cachedAdaptiveLessonAuditedAt'] as String? ?? '', ); cachedAdaptiveLessonAuditor = data['cachedAdaptiveLessonAuditor'] as String?; adaptiveLessonDraftId = data['adaptiveLessonDraftId'] as String?; adaptiveLessonDraftIndex = data['adaptiveLessonDraftIndex'] as int? ?? 0; adaptiveLessonDraftAnswer = data['adaptiveLessonDraftAnswer'] as String? ?? ''; adaptiveLessonDraftReferenceShown = data['adaptiveLessonDraftReferenceShown'] as bool? ?? false; adaptiveLessonDraftUsedVoice = data['adaptiveLessonDraftUsedVoice'] as bool? ?? false; adaptiveLessonDraftTranscriptEdited = data['adaptiveLessonDraftTranscriptEdited'] as bool? ?? false; adaptiveLessonDraftTranscriptConfirmed = data['adaptiveLessonDraftTranscriptConfirmed'] as bool? ?? false; adaptiveLessonDraftOriginalTranscript = data['adaptiveLessonDraftOriginalTranscript'] as String? ?? ''; adaptiveLessonDraftRecordingPath = data['adaptiveLessonDraftRecordingPath'] as String?; reportedAdaptiveLessonIds ..clear() ..addAll( (data['reportedAdaptiveLessonIds'] as List? ?? const []) .whereType(), ); final savedTemporaryLexicon = data['temporaryLexicon'] as List?; if (savedTemporaryLexicon != null) { temporaryLexicon ..clear() ..addEntries( savedTemporaryLexicon .whereType>() .map((item) { final query = item['query'] as String? ?? ''; return MapEntry( _normalizeLexiconQuery(query), TemporaryLexiconEntry( query: query, definition: item['definition'] as String? ?? '', provider: item['provider'] as String? ?? 'unknown', model: item['model'] as String? ?? '', createdAt: DateTime.tryParse(item['createdAt'] as String? ?? '') ?? DateTime.now(), ), ); }) .where( (entry) => entry.key.isNotEmpty && entry.value.definition.isNotEmpty, ), ); } aiProvider = _enumValue( AiProviderType.values, data['aiProvider'] as String?, aiProvider, ); lessonStep = _enumValue( LessonStep.values, data['lessonStep'] as String?, lessonStep, ); previewIndex = data['previewIndex'] as int? ?? previewIndex; completedLessons = data['completedLessons'] as int? ?? completedLessons; activeLessonId = data['activeLessonId'] as String? ?? activeLessonId; completedLessonIds ..clear() ..addAll( (data['completedLessonIds'] as List? ?? const []) .whereType(), ); completedSegmentIds ..clear() ..addAll( (data['completedSegmentIds'] as List? ?? const []) .whereType(), ); reportedAiVariantKeys ..clear() ..addAll( (data['reportedAiVariantKeys'] as List? ?? const []) .whereType(), ); final savedSegmentIndexes = data['activeSegmentIndexes'] as Map?; if (savedSegmentIndexes != null) { activeSegmentIndexes ..clear() ..addAll( savedSegmentIndexes.map( (key, value) => MapEntry(key, value as int? ?? 0), ), ); } lessonListeningComplete = data['lessonListeningComplete'] as bool? ?? false; lessonSpeakingComplete = data['lessonSpeakingComplete'] as bool? ?? false; lessonReadingComplete = data['lessonReadingComplete'] as bool? ?? false; lessonWritingComplete = data['lessonWritingComplete'] as bool? ?? false; lessonDialogueComplete = data['lessonDialogueComplete'] as bool? ?? false; independentAttemptComplete = data['independentAttemptComplete'] as bool? ?? false; independentAttemptAssisted = data['independentAttemptAssisted'] as bool? ?? false; independentAttemptSpoken = data['independentAttemptSpoken'] as bool? ?? false; lessonWritingDraft = data['lessonWritingDraft'] as String? ?? ''; independentAttemptDraft = data['independentAttemptDraft'] as String? ?? ''; final reviews = data['reviews'] as List?; if (reviews != null) { reviewQueue ..clear() ..addAll( reviews.whereType>().map(_reviewFromJson), ); } final savedEvidence = data['attemptEvidence'] as List?; if (savedEvidence != null) { attemptEvidence ..clear() ..addAll( savedEvidence.whereType>().map( (item) => AttemptEvidence( id: item['id'] as String? ?? '', itemId: item['itemId'] as String? ?? '', taskId: item['taskId'] as String? ?? '', skill: item['skill'] as String? ?? '', inputMode: item['inputMode'] as String? ?? 'text', outcome: _enumValue( EvidenceKind.values, item['outcome'] as String?, EvidenceKind.pending, ), createdAt: DateTime.tryParse(item['createdAt'] as String? ?? '') ?? DateTime.now(), rawAnswer: item['rawAnswer'] as String?, recordingPath: item['recordingPath'] as String?, assisted: item['assisted'] as bool? ?? false, variantIndex: item['variantIndex'] as int? ?? 0, originalTranscript: item['originalTranscript'] as String?, transcriptConfirmed: item['transcriptConfirmed'] as bool? ?? false, transcriptEdited: item['transcriptEdited'] as bool? ?? false, ), ), ); } final savedMastery = data['mastery'] as List?; if (savedMastery != null) { mastery ..clear() ..addEntries( savedMastery.whereType>().map((item) { final id = item['id'] as String; return MapEntry( id, MasteryItem( id: id, label: item['label'] as String? ?? id, status: _enumValue( MasteryStatus.values, item['status'] as String?, MasteryStatus.newItem, ), evidence: (item['evidence'] as List? ?? const []) .whereType() .map( (name) => _enumValue( EvidenceKind.values, name, EvidenceKind.pending, ), ) .toList(), needsReview: item['needsReview'] as bool? ?? false, checkpoint: item['checkpoint'] as int? ?? 0, firstTaughtAt: DateTime.tryParse( item['firstTaughtAt'] as String? ?? '', ), ), ); }), ); } final savedAssessments = data['assessments'] as List?; if (savedAssessments != null) { assessments ..clear() ..addAll( savedAssessments.whereType>().map((item) { final rawResults = item['results'] as Map? ?? const {}; return AssessmentRecord( packId: item['packId'] as String, completedAt: DateTime.tryParse(item['completedAt'] as String? ?? '') ?? DateTime.now(), results: { for (final skill in AssessmentSkill.values) skill: rawResults[skill.name] == true, }, pendingSkills: (item['pendingSkills'] as List? ?? const []) .whereType() .map( (name) => _enumValue( AssessmentSkill.values, name, AssessmentSkill.speaking, ), ) .toSet(), ); }), ); } final savedDraft = data['assessmentDraft'] as Map?; if (savedDraft != null) { assessmentDraft = AssessmentDraft( packId: savedDraft['packId'] as String, taskIndex: savedDraft['taskIndex'] as int? ?? 0, results: (savedDraft['results'] as Map? ?? const {}) .map((key, value) => MapEntry(key, value == true)), ); } final savedDialogue = data['dialogueDraft'] as Map?; if (savedDialogue != null) { dialogueDraft = DialogueDraft( lessonId: savedDialogue['lessonId'] as String, stage: savedDialogue['stage'] as int? ?? 0, usedHelp: savedDialogue['usedHelp'] as bool? ?? false, turns: (savedDialogue['turns'] as List? ?? const []) .whereType>() .map( (turn) => DialogueTurn( text: turn['text'] as String, isLearner: turn['isLearner'] as bool? ?? false, translation: turn['translation'] as String?, ), ) .toList(), ); } } T _enumValue(List values, String? name, T fallback) => values.where((value) => value.name == name).firstOrNull ?? fallback; ReviewItem _reviewFromJson(Map data) => ReviewItem( id: data['id'] as String, target: data['target'] as String, prompt: data['prompt'] as String, hint: data['hint'] as String, dueAt: DateTime.tryParse(data['dueAt'] as String? ?? '') ?? DateTime.now(), skill: data['skill'] as String, attempts: data['attempts'] as int? ?? 0, successfulReviews: data['successfulReviews'] as int? ?? 0, variantIndex: data['variantIndex'] as int? ?? 0, lastProgressedAt: DateTime.tryParse( data['lastProgressedAt'] as String? ?? '', ), isAiGenerated: data['isAiGenerated'] as bool? ?? false, ); void _persist() { if (!isLoaded) return; _dirty = true; if (_writing) return; _writing = true; _dirty = false; _writeSnapshot( jsonEncode({ 'onboardingComplete': onboardingComplete, 'goal': goal.name, 'placement': placement.name, 'dailyMinutes': dailyMinutes, 'showChineseHints': showChineseHints, 'keepRecordings': keepRecordings, 'aiEndpoint': aiEndpoint, 'aiModel': aiModel, 'cachedAdaptiveLessonRaw': cachedAdaptiveLessonRaw, 'cachedAdaptiveLessonAuditedAt': cachedAdaptiveLessonAuditedAt ?.toIso8601String(), 'cachedAdaptiveLessonAuditor': cachedAdaptiveLessonAuditor, 'adaptiveLessonDraftId': adaptiveLessonDraftId, 'adaptiveLessonDraftIndex': adaptiveLessonDraftIndex, 'adaptiveLessonDraftAnswer': adaptiveLessonDraftAnswer, 'adaptiveLessonDraftReferenceShown': adaptiveLessonDraftReferenceShown, 'adaptiveLessonDraftUsedVoice': adaptiveLessonDraftUsedVoice, 'adaptiveLessonDraftTranscriptEdited': adaptiveLessonDraftTranscriptEdited, 'adaptiveLessonDraftTranscriptConfirmed': adaptiveLessonDraftTranscriptConfirmed, 'adaptiveLessonDraftOriginalTranscript': adaptiveLessonDraftOriginalTranscript, 'adaptiveLessonDraftRecordingPath': adaptiveLessonDraftRecordingPath, 'reportedAdaptiveLessonIds': reportedAdaptiveLessonIds.toList(), 'temporaryLexicon': temporaryLexicon.values .map( (entry) => { 'query': entry.query, 'definition': entry.definition, 'provider': entry.provider, 'model': entry.model, 'createdAt': entry.createdAt.toIso8601String(), }, ) .toList(), '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': dialogueDraft == null ? null : { 'lessonId': dialogueDraft!.lessonId, 'stage': dialogueDraft!.stage, 'usedHelp': dialogueDraft!.usedHelp, 'turns': dialogueDraft!.turns .map( (turn) => { 'text': turn.text, 'isLearner': turn.isLearner, 'translation': turn.translation, }, ) .toList(), }, }), ).catchError((_) => false).whenComplete(() { _writing = false; if (_dirty) _persist(); }); } Future _writeSnapshot(String payload) async { if (_usesLegacyTestStore) { final preferences = await SharedPreferences.getInstance(); await preferences.setString(_storageKey, payload); return; } await LocalSnapshotStore.instance.write(payload); } String _normalizeLexiconQuery(String value) => value.trim().toLowerCase().replaceAll(RegExp(r'\s+'), ' '); TemporaryLexiconEntry? temporaryDefinitionFor(String query) => temporaryLexicon[_normalizeLexiconQuery(query)]; void cacheTemporaryDefinition({ required String query, required String definition, }) { final key = _normalizeLexiconQuery(query); final normalizedDefinition = definition.trim(); if (key.isEmpty || normalizedDefinition.isEmpty) return; temporaryLexicon[key] = TemporaryLexiconEntry( query: query.trim(), definition: normalizedDefinition, provider: aiProvider.name, model: aiModel.trim(), createdAt: DateTime.now(), ); notifyListeners(); } void removeTemporaryDefinition(String query) { if (temporaryLexicon.remove(_normalizeLexiconQuery(query)) != null) { notifyListeners(); } } void finishOnboarding() { onboardingComplete = true; notifyListeners(); SyncCoordinator.instance.triggerBackgroundSync(this); } void setGoal(LearningGoal value) { goal = value; notifyListeners(); } void setDailyMinutes(int value) { dailyMinutes = value; notifyListeners(); } void setPlacement(PlacementLevel value) { placement = value; notifyListeners(); } void advanceLesson(LessonStep value) { lessonStep = value; notifyListeners(); } void setPreviewIndex(int value) { previewIndex = value < 0 ? 0 : value; notifyListeners(); } void setLessonWritingDraft(String value) { if (lessonWritingDraft == value) return; lessonWritingDraft = value; notifyListeners(); } void setIndependentAttemptDraft(String value) { if (independentAttemptDraft == value) return; independentAttemptDraft = value; notifyListeners(); } bool isLessonUnlocked(String id) { final index = a0SeedLessons.indexWhere((lesson) => lesson.id == id); return index == 0 || (index > 0 && completedLessonIds.contains(a0SeedLessons[index - 1].id)); } void openLesson(String id) { if (!isLessonUnlocked(id)) return; activeLessonId = id; lessonStep = LessonStep.preview; previewIndex = 0; lessonWritingDraft = ''; independentAttemptDraft = ''; notifyListeners(); } void completePreview() { previewIndex = 0; lessonStep = LessonStep.listening; notifyListeners(); } void completeListening() { _introduceLessonTargets(); lessonListeningComplete = true; lessonStep = LessonStep.speaking; notifyListeners(); } void completeSpeaking({bool assisted = false}) { lessonSpeakingComplete = true; lessonStep = LessonStep.reading; _recordLessonTaskEvidence( targetIds: [_primaryTargetId], taskSuffix: 'speaking', skill: '口语表达', outcome: assisted ? EvidenceKind.assisted : EvidenceKind.independentSuccess, assisted: assisted, ); notifyListeners(); } void completeReading() { lessonReadingComplete = true; lessonStep = LessonStep.writing; _recordLessonTaskEvidence( targetIds: [_primaryTargetId], taskSuffix: 'reading', skill: '阅读理解', outcome: EvidenceKind.exposure, ); notifyListeners(); } void completeWriting({bool assisted = false, String? rawAnswer}) { lessonWritingComplete = true; lessonWritingDraft = ''; lessonStep = LessonStep.dialogue; _recordLessonTaskEvidence( targetIds: [_primaryTargetId], taskSuffix: 'writing', skill: '写作表达', outcome: assisted ? EvidenceKind.assisted : EvidenceKind.independentSuccess, rawAnswer: rawAnswer, assisted: assisted, ); notifyListeners(); } void completeLessonDialogue() { lessonDialogueComplete = true; lessonStep = LessonStep.independent; _recordLessonTaskEvidence( targetIds: [_primaryTargetId], taskSuffix: 'dialogue', skill: '受控对话', outcome: EvidenceKind.assisted, assisted: true, ); notifyListeners(); } void completeIndependentAttempt({ required bool assisted, bool spoken = false, String? rawAnswer, String? recordingPath, }) { independentAttemptComplete = true; independentAttemptDraft = ''; independentAttemptAssisted = assisted; independentAttemptSpoken = spoken && !assisted; lessonStep = LessonStep.complete; _recordLessonTaskEvidence( targetIds: [_primaryTargetId], taskSuffix: 'independent', skill: spoken && !assisted ? '口语表达' : '写作表达', inputMode: spoken && !assisted ? 'speech-unedited-transcript' : 'text', outcome: assisted ? EvidenceKind.assisted : EvidenceKind.independentSuccess, rawAnswer: rawAnswer, recordingPath: recordingPath, assisted: assisted, ); notifyListeners(); } void finishLesson() { if (!lessonCanComplete) return; completedLessonIds.add(activeLessonId); completedLessons = completedLessonIds.length; final currentIndex = a0SeedLessons.indexWhere( (lesson) => lesson.id == activeLessonId, ); if (currentIndex >= 0 && currentIndex < a0SeedLessons.length - 1) { activeLessonId = a0SeedLessons[currentIndex + 1].id; } _resetLessonFlow(); notifyListeners(); SyncCoordinator.instance.triggerBackgroundSync(this); } void _resetLessonFlow() { lessonStep = LessonStep.preview; previewIndex = 0; lessonListeningComplete = false; lessonSpeakingComplete = false; lessonReadingComplete = false; lessonWritingComplete = false; lessonDialogueComplete = false; independentAttemptComplete = false; independentAttemptAssisted = false; independentAttemptSpoken = false; lessonWritingDraft = ''; independentAttemptDraft = ''; } void finishCurrentLessonSegment() { final lesson = lessonById(activeLessonId); final index = activeSegmentIndexFor(activeLessonId); if (index >= lesson.segments.length - 1) { completeSegment(activeLessonId, index); finishLesson(); return; } completeSegment(activeLessonId, index); _resetLessonFlow(); notifyListeners(); SyncCoordinator.instance.triggerBackgroundSync(this); } void completeReview( ReviewItem item, { required bool assisted, String? rawAnswer, }) { final index = reviewQueue.indexWhere( (candidate) => candidate.id == item.id, ); if (index < 0) return; final current = reviewQueue[index]; final now = DateTime.now(); // UI/network retries may still hold an old item instance. Only the // currently due queue entry is allowed to produce evidence. if (current.dueAt.isAfter(now)) return; // A review is never silently discarded. Success earns a wider interval; // an assisted answer gets another, different attempt tomorrow. final progressedToday = current.lastProgressedAt != null && current.lastProgressedAt!.year == now.year && current.lastProgressedAt!.month == now.month && current.lastProgressedAt!.day == now.day; final canProgress = !assisted && !progressedToday; final nextSuccesses = canProgress ? current.successfulReviews + 1 : current.successfulReviews; const intervals = [1, 2, 4]; final intervalIndex = (nextSuccesses - 1) .clamp(0, intervals.length - 1) .toInt(); final masteryItem = mastery[current.id]; final days = assisted || !canProgress ? 1 : masteryItem?.needsReview == true && masteryItem?.status == MasteryStatus.master ? 7 : nextSuccesses >= 4 ? 30 : intervals[intervalIndex]; reviewQueue[index] = current.copyWith( dueAt: DateTime.now().add(Duration(days: days)), attempts: current.attempts + 1, successfulReviews: nextSuccesses, lastProgressedAt: canProgress ? now : current.lastProgressedAt, ); if (assisted) { _recordEvidence(current.id, EvidenceKind.assisted); } else { _recordReviewSuccess(current.id, nextSuccesses); } _addAttemptEvidence( current, outcome: assisted ? EvidenceKind.assisted : EvidenceKind.independentSuccess, assisted: assisted, rawAnswer: rawAnswer, ); notifyListeners(); SyncCoordinator.instance.triggerBackgroundSync(this); } void reportReviewFailure(ReviewItem item) { final index = reviewQueue.indexWhere( (candidate) => candidate.id == item.id, ); if (index < 0) return; final existing = mastery[item.id] ?? MasteryItem( id: item.id, label: item.target, status: MasteryStatus.newItem, evidence: const [], ); final secondFailure = existing.needsReview; final nextCheckpoint = secondFailure ? (existing.checkpoint - 1).clamp(0, 4).toInt() : existing.checkpoint; mastery[item.id] = existing.copyWith( status: secondFailure ? _statusForCheckpoint(nextCheckpoint) : existing.status, checkpoint: nextCheckpoint, needsReview: true, evidence: [...existing.evidence, EvidenceKind.languageError], ); reviewQueue[index] = item.copyWith( dueAt: DateTime.now().add(const Duration(days: 1)), attempts: item.attempts + 1, successfulReviews: secondFailure ? nextCheckpoint : item.successfulReviews, ); _addAttemptEvidence(item, outcome: EvidenceKind.languageError); notifyListeners(); } void postponeReview(ReviewItem item) { final index = reviewQueue.indexWhere( (candidate) => candidate.id == item.id, ); if (index < 0) return; reviewQueue[index] = item.copyWith( dueAt: DateTime.now().add(const Duration(days: 1)), attempts: item.attempts + 1, ); _addAttemptEvidence(item, outcome: EvidenceKind.pending); notifyListeners(); } void _addAttemptEvidence( ReviewItem item, { required EvidenceKind outcome, bool assisted = false, String? rawAnswer, }) { final now = DateTime.now(); attemptEvidence.add( AttemptEvidence( id: 'review-${item.id}-${now.microsecondsSinceEpoch}', itemId: item.id, taskId: 'review-${item.id}', skill: item.skill, inputMode: 'text', outcome: outcome, createdAt: now, rawAnswer: rawAnswer, assisted: assisted, variantIndex: item.variantIndex, ), ); } void recordAssessmentAttempt({ required String taskId, required String skill, required bool correct, required String rawAnswer, required bool spoken, }) { final now = DateTime.now(); attemptEvidence.add( AttemptEvidence( id: 'assessment-$taskId-${now.microsecondsSinceEpoch}', itemId: taskId, taskId: taskId, skill: skill, inputMode: spoken ? 'speech-unedited-transcript' : 'text', outcome: correct ? EvidenceKind.independentSuccess : EvidenceKind.languageError, createdAt: now, rawAnswer: rawAnswer, ), ); notifyListeners(); } void recordAssessmentPending({ required String taskId, required String skill, required String reason, }) { final now = DateTime.now(); attemptEvidence.add( AttemptEvidence( id: 'assessment-pending-$taskId-${now.microsecondsSinceEpoch}', itemId: taskId, taskId: taskId, skill: skill, inputMode: 'unavailable', outcome: EvidenceKind.pending, createdAt: now, rawAnswer: reason, ), ); notifyListeners(); } void recordDialogueAttempt({ required String taskId, required String rawAnswer, required bool assisted, bool spoken = false, String? recordingPath, }) { final now = DateTime.now(); attemptEvidence.add( AttemptEvidence( id: 'dialogue-$taskId-${now.microsecondsSinceEpoch}', itemId: _primaryTargetId, taskId: taskId, skill: '受控对话', inputMode: spoken ? 'speech-unedited-transcript' : 'text', outcome: assisted ? EvidenceKind.assisted : EvidenceKind.pending, createdAt: now, rawAnswer: rawAnswer, recordingPath: recordingPath, assisted: assisted, ), ); notifyListeners(); } void addSavedWord(VocabularyItem item) { if (reviewQueue.any((review) => review.id == item.id)) return; reviewQueue.add( ReviewItem( id: item.id, target: item.word, prompt: item.example, hint: item.meaning, dueAt: DateTime.now().add(const Duration(days: 1)), skill: '认识与回忆', ), ); notifyListeners(); } /// Adds one low-priority, non-core recap based on a completed independent /// dialogue. It is deliberately separate from A0 denominator items. void addDialogueRecap(String sentence) { final now = DateTime.now(); final day = '${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}'; final id = 'dialogue-a0-meet-$day'; if (reviewQueue.any((item) => item.id == id)) return; reviewQueue.add( ReviewItem( id: id, target: sentence, prompt: '再用英语介绍一次自己。', hint: '试着不用提示,说出你刚才表达的内容。', dueAt: now.add(const Duration(days: 1)), skill: '情境复练', ), ); notifyListeners(); } /// AI adaptive lessons are bounded teaching material. Only a locally /// validated, unassisted answer can add limited teaching evidence; spaced /// review checkpoints and stage assessment remain the source of `master`. void recordAdaptiveLessonTask({ required GeneratedLesson lesson, required GeneratedLessonTask task, required String rawAnswer, required bool assisted, required bool correct, String inputMode = 'text', String? recordingPath, String? originalTranscript, bool transcriptConfirmed = false, bool transcriptEdited = false, }) { final stableTaskId = '${lesson.lessonId}-${task.taskId}'; // A bounded adaptive lesson presents each task once. Protect against a // double tap or a retried UI callback creating two independent successes // for the same material revision. if (attemptEvidence.any((entry) => entry.taskId == stableTaskId)) return; final now = DateTime.now(); // Treat the voice label as an evidence boundary, not caller-provided // metadata. An edited transcript is a useful written learning response, // but can never become speech evidence merely because a UI caller forgot // to clear its confirmation flag. final confirmedVoiceTranscript = inputMode == 'speechToText' && transcriptConfirmed && !transcriptEdited; attemptEvidence.add( AttemptEvidence( id: 'adaptive-${lesson.lessonId}-${task.taskId}-${now.microsecondsSinceEpoch}', itemId: task.targetItemIds.single, taskId: stableTaskId, skill: switch (task.skill) { 'listening' => '听力理解', 'speaking' => '口语表达', 'reading' => '阅读理解', _ => '写作表达', }, inputMode: confirmedVoiceTranscript ? 'speechToText' : 'text', outcome: assisted ? EvidenceKind.assisted : correct ? EvidenceKind.independentSuccess : EvidenceKind.pending, createdAt: now, rawAnswer: rawAnswer, recordingPath: recordingPath, assisted: assisted, originalTranscript: originalTranscript, transcriptConfirmed: confirmedVoiceTranscript, transcriptEdited: transcriptEdited, ), ); if (!assisted && correct) { _recordEvidence( task.targetItemIds.single, EvidenceKind.independentSuccess, ); } notifyListeners(); } void scheduleA0Reinforcement() { final candidates = a0CoreItems.keys.toList() ..sort((left, right) { final leftItem = mastery[left]; final rightItem = mastery[right]; final leftRank = leftItem?.needsReview == true ? -1 : leftItem?.checkpoint ?? 0; final rightRank = rightItem?.needsReview == true ? -1 : rightItem?.checkpoint ?? 0; return leftRank == rightRank ? left.compareTo(right) : leftRank.compareTo(rightRank); }); if (candidates.isEmpty) return; final id = candidates.first; final index = reviewQueue.indexWhere((item) => item.id == id); if (index >= 0) { final current = reviewQueue[index]; final template = coreReviewVariant(id, current.variantIndex + 1); reviewQueue[index] = current.copyWith( prompt: template.prompt, hint: template.hint, skill: template.skill, dueAt: DateTime.now(), variantIndex: current.variantIndex + 1, ); } else { final template = coreReviewVariant(id, 1); reviewQueue.add( ReviewItem( id: id, target: a0CoreItems[id]!, prompt: template.prompt, hint: template.hint, dueAt: DateTime.now(), skill: template.skill, variantIndex: 1, ), ); } notifyListeners(); } void applyGeneratedReviewVariant(GeneratedReviewVariant variant) { final index = reviewQueue.indexWhere( (item) => item.id == variant.targetItemId, ); if (index < 0) return; final current = reviewQueue[index]; reviewQueue[index] = current.copyWith( prompt: variant.prompt, hint: variant.expectedAnswer, variantIndex: current.variantIndex + 1, isAiGenerated: true, ); notifyListeners(); } /// Removes an AI-authored review wording from use. If it had already been /// answered, only evidence tied to that exact variant is invalidated and /// its checkpoint contribution is removed; the stable core item remains. void reportGeneratedReviewVariant(ReviewItem item) { if (!item.isAiGenerated) return; final index = reviewQueue.indexWhere( (candidate) => candidate.id == item.id, ); if (index < 0) return; final key = '${item.id}:${item.variantIndex}'; if (!reportedAiVariantKeys.add(key)) return; final invalidSuccesses = attemptEvidence .where( (evidence) => evidence.itemId == item.id && evidence.taskId == 'review-${item.id}' && evidence.variantIndex == item.variantIndex && evidence.outcome == EvidenceKind.independentSuccess, ) .length; attemptEvidence.removeWhere( (evidence) => evidence.itemId == item.id && evidence.taskId == 'review-${item.id}' && evidence.variantIndex == item.variantIndex, ); if (invalidSuccesses > 0) _rebuildMasteryItem(item.id, forceReview: true); final template = coreReviewVariant(item.id, item.variantIndex + 1); reviewQueue[index] = item.copyWith( prompt: template.prompt, hint: template.hint, skill: template.skill, variantIndex: item.variantIndex + 1, dueAt: DateTime.now(), isAiGenerated: false, ); notifyListeners(); } /// Rebuilds displayed mastery from immutable local attempt evidence. AI /// responses never supply this state. It is safe to call after isolating a /// bad generated variant because removed evidence can no longer contribute. void rebuildMasteryFromEvidence() { final ids = { ...mastery.keys, ...attemptEvidence.map((entry) => entry.itemId), }; for (final id in ids) { _rebuildMasteryItem(id); } for (var index = 0; index < reviewQueue.length; index++) { final item = reviewQueue[index]; final rebuilt = mastery[item.id]; if (rebuilt != null) { reviewQueue[index] = item.copyWith( successfulReviews: rebuilt.checkpoint, ); } } notifyListeners(); } void _rebuildMasteryItem(String id, {bool forceReview = false}) { final existing = mastery[id]; final events = attemptEvidence.where((entry) => entry.itemId == id).toList() ..sort((left, right) => left.createdAt.compareTo(right.createdAt)); var checkpoint = 0; var needsReview = forceReview; var nonReviewSuccesses = 0; DateTime? firstTaughtAt; final progressedDays = {}; for (final event in events) { final isReview = event.taskId == 'review-$id'; if (!isReview && event.outcome == EvidenceKind.exposure && (firstTaughtAt == null || event.createdAt.isBefore(firstTaughtAt))) { firstTaughtAt = event.createdAt; } if (!isReview && event.outcome == EvidenceKind.independentSuccess) { nonReviewSuccesses++; continue; } if (!isReview) continue; if (event.outcome == EvidenceKind.independentSuccess) { final day = '${event.createdAt.year}-${event.createdAt.month}-${event.createdAt.day}'; if (progressedDays.add(day)) checkpoint = (checkpoint + 1).clamp(0, 4); needsReview = false; } else if (event.outcome == EvidenceKind.languageError) { if (needsReview) checkpoint = (checkpoint - 1).clamp(0, 4); needsReview = true; } } final independentLevel = nonReviewSuccesses.clamp(0, 3); final level = checkpoint > independentLevel ? checkpoint : independentLevel; mastery[id] = MasteryItem( id: id, label: existing?.label ?? a0CoreItems[id] ?? id, status: _statusForCheckpoint(level), checkpoint: checkpoint, needsReview: needsReview, evidence: events.map((event) => event.outcome).toList(), firstTaughtAt: firstTaughtAt ?? existing?.firstTaughtAt, ); } void setAiProvider(AiProviderType value) { aiProvider = value; notifyListeners(); } void saveAiConfiguration({required String endpoint, required String model}) { aiEndpoint = endpoint.trim(); aiModel = model.trim(); notifyListeners(); } GeneratedLesson? get cachedAdaptiveLesson { final raw = cachedAdaptiveLessonRaw; if (raw == null) return null; try { final data = jsonDecode(raw) as Map; final targets = data['targetItemIds'] as List?; final target = targets?.singleOrNull; final lesson = target is String ? decodeGeneratedLesson(raw, expectedTargetItemId: target) : null; return lesson != null && !reportedAdaptiveLessonIds.contains(lesson.lessonId) ? lesson : null; } catch (_) { return null; } } /// Call only after an independent AI audit approves the lesson. The audit /// metadata stays beside the immutable lesson revision for later tracing. void cacheApprovedAdaptiveLesson( GeneratedLesson lesson, { DateTime? auditedAt, String? auditor, }) { cachedAdaptiveLessonRaw = encodeGeneratedLesson(lesson); cachedAdaptiveLessonAuditedAt = auditedAt ?? DateTime.now(); cachedAdaptiveLessonAuditor = auditor; adaptiveLessonDraftId = lesson.lessonId; adaptiveLessonDraftIndex = 0; adaptiveLessonDraftAnswer = ''; adaptiveLessonDraftReferenceShown = false; adaptiveLessonDraftUsedVoice = false; adaptiveLessonDraftTranscriptEdited = false; adaptiveLessonDraftTranscriptConfirmed = false; adaptiveLessonDraftOriginalTranscript = ''; adaptiveLessonDraftRecordingPath = null; notifyListeners(); } /// Stores progress after every input change so an interrupted AI teaching /// activity can resume at exactly the same task. This is not mastery data. void saveAdaptiveLessonDraft({ required GeneratedLesson lesson, required int taskIndex, required String answer, required bool referenceShown, bool usedVoice = false, bool transcriptEdited = false, bool transcriptConfirmed = false, String originalTranscript = '', String? recordingPath, }) { final confirmedVoiceTranscript = usedVoice && transcriptConfirmed && !transcriptEdited; adaptiveLessonDraftId = lesson.lessonId; adaptiveLessonDraftIndex = taskIndex; adaptiveLessonDraftAnswer = answer; adaptiveLessonDraftReferenceShown = referenceShown; adaptiveLessonDraftUsedVoice = usedVoice; adaptiveLessonDraftTranscriptEdited = transcriptEdited; adaptiveLessonDraftTranscriptConfirmed = confirmedVoiceTranscript; adaptiveLessonDraftOriginalTranscript = originalTranscript; adaptiveLessonDraftRecordingPath = recordingPath; notifyListeners(); } void clearAdaptiveLessonDraft() { adaptiveLessonDraftId = null; adaptiveLessonDraftIndex = 0; adaptiveLessonDraftAnswer = ''; adaptiveLessonDraftReferenceShown = false; adaptiveLessonDraftUsedVoice = false; adaptiveLessonDraftTranscriptEdited = false; adaptiveLessonDraftTranscriptConfirmed = false; adaptiveLessonDraftOriginalTranscript = ''; adaptiveLessonDraftRecordingPath = null; notifyListeners(); } /// A reported AI lesson must never be shown again from this local cache. /// Existing submissions stay as pending/assisted learning history; they /// never contributed to mastery and therefore need no mastery rollback. void reportAdaptiveLesson(GeneratedLesson lesson) { reportedAdaptiveLessonIds.add(lesson.lessonId); if (cachedAdaptiveLesson?.lessonId == lesson.lessonId) { cachedAdaptiveLessonRaw = null; cachedAdaptiveLessonAuditedAt = null; cachedAdaptiveLessonAuditor = null; } adaptiveLessonDraftId = null; adaptiveLessonDraftIndex = 0; adaptiveLessonDraftAnswer = ''; adaptiveLessonDraftReferenceShown = false; adaptiveLessonDraftUsedVoice = false; adaptiveLessonDraftTranscriptEdited = false; adaptiveLessonDraftTranscriptConfirmed = false; adaptiveLessonDraftOriginalTranscript = ''; adaptiveLessonDraftRecordingPath = null; notifyListeners(); } void toggleChineseHints(bool value) { showChineseHints = value; notifyListeners(); } void toggleKeepRecordings(bool value) { keepRecordings = value; notifyListeners(); } void clearProgress() { completedLessons = 0; activeLessonId = 'a0-01'; completedLessonIds.clear(); completedSegmentIds.clear(); reportedAiVariantKeys.clear(); activeSegmentIndexes.clear(); reviewQueue.clear(); attemptEvidence.clear(); cachedAdaptiveLessonRaw = null; cachedAdaptiveLessonAuditedAt = null; cachedAdaptiveLessonAuditor = null; adaptiveLessonDraftId = null; adaptiveLessonDraftIndex = 0; adaptiveLessonDraftAnswer = ''; adaptiveLessonDraftReferenceShown = false; adaptiveLessonDraftUsedVoice = false; adaptiveLessonDraftTranscriptEdited = false; adaptiveLessonDraftTranscriptConfirmed = false; adaptiveLessonDraftOriginalTranscript = ''; adaptiveLessonDraftRecordingPath = null; reportedAdaptiveLessonIds.clear(); assessments.clear(); assessmentDraft = null; dialogueDraft = null; lessonStep = LessonStep.preview; previewIndex = 0; lessonListeningComplete = false; lessonSpeakingComplete = false; lessonReadingComplete = false; lessonWritingComplete = false; lessonDialogueComplete = false; independentAttemptComplete = false; independentAttemptAssisted = false; independentAttemptSpoken = false; mastery.clear(); notifyListeners(); } void _recordEvidence(String id, EvidenceKind evidence) { final existing = mastery[id] ?? MasteryItem( id: id, label: id, status: MasteryStatus.newItem, evidence: const [], ); final allEvidence = [...existing.evidence, evidence]; MasteryStatus next = existing.status; if (evidence == EvidenceKind.independentSuccess) { next = switch (existing.status) { MasteryStatus.newItem => MasteryStatus.recognize, MasteryStatus.recognize => MasteryStatus.recall, MasteryStatus.recall => MasteryStatus.use, MasteryStatus.use || MasteryStatus.master => existing.status, MasteryStatus.needsReview => MasteryStatus.recall, }; } mastery[id] = existing.copyWith(status: next, evidence: allEvidence); } void _recordReviewSuccess(String id, int successes) { final existing = mastery[id] ?? MasteryItem( id: id, label: a0CoreItems[id] ?? id, status: MasteryStatus.newItem, evidence: const [], ); final checkpoint = successes.clamp(0, 4).toInt(); mastery[id] = existing.copyWith( status: _statusForCheckpoint(checkpoint), checkpoint: checkpoint, needsReview: false, evidence: [...existing.evidence, EvidenceKind.independentSuccess], ); } MasteryStatus _statusForCheckpoint(int checkpoint) => switch (checkpoint) { 0 => MasteryStatus.newItem, 1 => MasteryStatus.recognize, 2 => MasteryStatus.recall, 3 => MasteryStatus.use, _ => MasteryStatus.master, }; List get _activeTargetItemIds { final lesson = lessonById(activeLessonId); return lesson.segments[activeSegmentIndexFor(activeLessonId)].targetItemIds; } String get _primaryTargetId => _activeTargetItemIds.lastOrNull ?? 'A0-P02'; /// Records one evidence row for every target actually attached to a local /// task. A task can be displayed once but must never silently award its /// result to unrelated core items. void _recordLessonTaskEvidence({ required List targetIds, required String taskSuffix, required String skill, required EvidenceKind outcome, String? rawAnswer, String inputMode = 'text', String? recordingPath, bool assisted = false, }) { final now = DateTime.now(); for (var index = 0; index < targetIds.length; index++) { final id = targetIds[index]; _recordEvidence(id, outcome); attemptEvidence.add( AttemptEvidence( id: 'lesson-$_activeSegmentId-$taskSuffix-$id-${now.microsecondsSinceEpoch}-$index', itemId: id, taskId: 'lesson-$_activeSegmentId-$taskSuffix', skill: skill, inputMode: inputMode, outcome: outcome, createdAt: now, rawAnswer: rawAnswer, recordingPath: recordingPath, assisted: assisted, ), ); } } void _introduceLessonTargets() { _recordLessonTaskEvidence( targetIds: _activeTargetItemIds, taskSuffix: 'listening', skill: '听力输入', outcome: EvidenceKind.exposure, ); for (final id in _activeTargetItemIds) { final introduced = mastery[id]!; if (introduced.firstTaughtAt == null) { mastery[id] = introduced.copyWith(firstTaughtAt: DateTime.now()); } if (reviewQueue.any((item) => item.id == id)) continue; final template = coreReviewTemplate(id); reviewQueue.add( ReviewItem( id: id, target: a0CoreItems[id] ?? id, prompt: template.prompt, hint: template.hint, dueAt: DateTime.now().add(const Duration(days: 1)), skill: template.skill, ), ); } } }