From 9b0f9d64cb5fddd0852f21542819da5a858c589a Mon Sep 17 00:00:00 2001 From: shenlei Date: Wed, 16 Sep 2026 17:22:19 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=8C=89=E9=A2=86=E5=9F=9F?= =?UTF-8?q?=E5=B0=86=20AppState=20=E6=8B=86=E5=88=86=E4=B8=BA=20mixin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app_state.dart 只保留持久化字段(_AppStateData)、加载/存档、设置项和清空进度 - app_state_review.dart:复习队列与掌握度 - app_state_lesson.dart:课程解锁、分段与课内步骤流程 - app_state_assessment.dart:阶段测评 - app_state_ai_content.dart:临时释义、句子分析缓存与补练课 - 后台同步调用统一为 _syncInBackground() 纯搬移,方法体未改,无用户可见变化。 Co-Authored-By: Claude Opus 5 --- kouyu_english/lib/core/app_state.dart | 1258 +---------------- .../lib/core/app_state_ai_content.dart | 215 +++ .../lib/core/app_state_assessment.dart | 139 ++ kouyu_english/lib/core/app_state_lesson.dart | 400 ++++++ kouyu_english/lib/core/app_state_review.dart | 463 ++++++ 5 files changed, 1254 insertions(+), 1221 deletions(-) create mode 100644 kouyu_english/lib/core/app_state_ai_content.dart create mode 100644 kouyu_english/lib/core/app_state_assessment.dart create mode 100644 kouyu_english/lib/core/app_state_lesson.dart create mode 100644 kouyu_english/lib/core/app_state_review.dart diff --git a/kouyu_english/lib/core/app_state.dart b/kouyu_english/lib/core/app_state.dart index 92b9a83..5fcc328 100644 --- a/kouyu_english/lib/core/app_state.dart +++ b/kouyu_english/lib/core/app_state.dart @@ -15,23 +15,15 @@ import 'ai_service.dart'; import 'sherpa_stt_service.dart'; import 'sync/sync_coordinator.dart'; +part 'app_state_ai_content.dart'; +part 'app_state_assessment.dart'; +part 'app_state_lesson.dart'; +part 'app_state_review.dart'; part 'app_state_snapshot.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); - } +/// Everything the app persists. Behaviour lives in the domain mixins that +/// [AppState] composes. +abstract class _AppStateData extends ChangeNotifier { LearningGoal goal = LearningGoal.dailyLife; int dailyMinutes = 20; PlacementLevel placement = PlacementLevel.beginner; @@ -67,11 +59,8 @@ class AppState extends ChangeNotifier { String aiEndpoint = ''; String aiModel = ''; - AiConfigFile get aiConfig => AiConfigFile( - provider: aiProvider, - endpoint: aiEndpoint, - model: aiModel, - ); + AiConfigFile get aiConfig => + AiConfigFile(provider: aiProvider, endpoint: aiEndpoint, model: aiModel); String? cachedAdaptiveLessonRaw; DateTime? cachedAdaptiveLessonAuditedAt; String? cachedAdaptiveLessonAuditor; @@ -93,202 +82,26 @@ class AppState extends ChangeNotifier { 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; + void _syncInBackground(); +} + +class AppState extends _AppStateData + with _ReviewAndMastery, _LessonProgress, _AssessmentProgress, _AiContent { + 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); } - 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(); - } - - void saveSceneDialogueDraft(DialogueDraft draft) { - sceneDialogueDraft = draft; - notifyListeners(); - } - - void clearSceneDialogueDraft() { - if (sceneDialogueDraft == null) return; - sceneDialogueDraft = 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()); @@ -361,12 +174,12 @@ class AppState extends ChangeNotifier { if (_writing) return; _writing = true; _dirty = false; - _writeSnapshot(jsonEncode(_toSnapshotJson())) - .catchError((_) => false) - .whenComplete(() { - _writing = false; - if (_dirty) _persist(); - }); + _writeSnapshot( + jsonEncode(_toSnapshotJson()), + ).catchError((_) => false).whenComplete(() { + _writing = false; + if (_dirty) _persist(); + }); } Future _writeSnapshot(String payload) async { @@ -378,79 +191,10 @@ class AppState extends ChangeNotifier { 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(); - } - } - - SentenceAnalysisResult? sentenceAnalysisFor(String query) => - sentenceAnalyses[_normalizeLexiconQuery(query)]; - - void cacheSentenceAnalysis(SentenceAnalysisResult result) { - final key = _normalizeLexiconQuery(result.originalText); - if (key.isEmpty || result.translation.trim().isEmpty) return; - sentenceAnalyses[key] = result; - notifyListeners(); - } - - void removeSentenceAnalysis(String query) { - if (sentenceAnalyses.remove(_normalizeLexiconQuery(query)) != null) { - notifyListeners(); - } - } - - void addPhraseToReview({ - required String phrase, - required String meaning, - String? ipa, - String? usageNote, - required String contextSentence, - }) { - final cleanPhrase = phrase.trim(); - if (cleanPhrase.isEmpty) return; - final id = 'phrase_${cleanPhrase.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_')}'; - final fullMeaning = usageNote != null && usageNote.trim().isNotEmpty - ? '$meaning ($usageNote)' - : meaning; - final item = VocabularyItem( - id: id, - word: cleanPhrase, - meaning: fullMeaning, - example: contextSentence.trim(), - exampleMeaning: meaning, - ipa: ipa, - ); - addSavedWord(item); - } - void finishOnboarding() { onboardingComplete = true; notifyListeners(); - SyncCoordinator.instance.triggerBackgroundSync(this); + _syncInBackground(); } void setGoal(LearningGoal value) { @@ -468,719 +212,6 @@ class AppState extends ChangeNotifier { 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); - } - - /// Merges lesson progress pulled from the sync server. - /// - /// Completed lessons/segments are unioned, and the learner's position only - /// ever moves forward: a device that has not caught up yet must never drag - /// another device back to an earlier lesson or segment. - bool mergeSyncedLessonProgress({ - required Iterable completedLessons, - required Iterable completedSegments, - required String remoteActiveLessonId, - }) { - var changed = false; - final completedBefore = completedLessonIds.toSet(); - for (final id in completedLessons) { - if (completedLessonIds.add(id)) changed = true; - } - for (final id in completedSegments) { - if (completedSegmentIds.add(id)) changed = true; - } - if (this.completedLessons != completedLessonIds.length) { - this.completedLessons = completedLessonIds.length; - changed = true; - } - - int lessonIndex(String id) => - a0SeedLessons.indexWhere((lesson) => lesson.id == id); - - var targetIndex = lessonIndex(activeLessonId); - final remoteIndex = lessonIndex(remoteActiveLessonId); - if (remoteIndex > targetIndex && isLessonUnlocked(remoteActiveLessonId)) { - targetIndex = remoteIndex; - } - // The server's active lesson can itself be stale. Skip lessons that only - // became complete through this pull, but leave a lesson alone when the - // learner deliberately reopened it locally after finishing it. - while (targetIndex >= 0 && - targetIndex < a0SeedLessons.length - 1 && - !completedBefore.contains(a0SeedLessons[targetIndex].id) && - completedLessonIds.contains(a0SeedLessons[targetIndex].id)) { - targetIndex++; - } - - var positionChanged = false; - if (targetIndex >= 0 && a0SeedLessons[targetIndex].id != activeLessonId) { - activeLessonId = a0SeedLessons[targetIndex].id; - positionChanged = true; - } - - for (final lesson in a0SeedLessons) { - final segments = lesson.segments; - var firstOpen = segments.indexWhere( - (segment) => !completedSegmentIds.contains(segment.id), - ); - if (firstOpen < 0) firstOpen = segments.length - 1; - if (firstOpen > activeSegmentIndexFor(lesson.id)) { - activeSegmentIndexes[lesson.id] = firstOpen; - if (lesson.id == activeLessonId) positionChanged = true; - changed = true; - } - } - - if (positionChanged) { - _resetLessonFlow(); - changed = true; - } - return changed; - } - - 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(); @@ -1192,109 +223,6 @@ class AppState extends ChangeNotifier { 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(); @@ -1345,120 +273,8 @@ class AppState extends ChangeNotifier { 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, - ), - ); - } + @override + void _syncInBackground() { + SyncCoordinator.instance.triggerBackgroundSync(this); } } diff --git a/kouyu_english/lib/core/app_state_ai_content.dart b/kouyu_english/lib/core/app_state_ai_content.dart new file mode 100644 index 0000000..cf19e5c --- /dev/null +++ b/kouyu_english/lib/core/app_state_ai_content.dart @@ -0,0 +1,215 @@ +part of 'app_state.dart'; + +/// Locally cached AI output: temporary glosses, sentence analyses and the +/// audited adaptive lesson with its resumable draft. +mixin _AiContent on _AppStateData, _ReviewAndMastery { + 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(); + } + } + + SentenceAnalysisResult? sentenceAnalysisFor(String query) => + sentenceAnalyses[_normalizeLexiconQuery(query)]; + + void cacheSentenceAnalysis(SentenceAnalysisResult result) { + final key = _normalizeLexiconQuery(result.originalText); + if (key.isEmpty || result.translation.trim().isEmpty) return; + sentenceAnalyses[key] = result; + notifyListeners(); + } + + void removeSentenceAnalysis(String query) { + if (sentenceAnalyses.remove(_normalizeLexiconQuery(query)) != null) { + 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(); + } + + 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(); + } +} diff --git a/kouyu_english/lib/core/app_state_assessment.dart b/kouyu_english/lib/core/app_state_assessment.dart new file mode 100644 index 0000000..0c95012 --- /dev/null +++ b/kouyu_english/lib/core/app_state_assessment.dart @@ -0,0 +1,139 @@ +part of 'app_state.dart'; + +/// Stage assessments and the A0 pass criteria. +mixin _AssessmentProgress on _AppStateData, _ReviewAndMastery { + 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 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(); + } +} diff --git a/kouyu_english/lib/core/app_state_lesson.dart b/kouyu_english/lib/core/app_state_lesson.dart new file mode 100644 index 0000000..755dba5 --- /dev/null +++ b/kouyu_english/lib/core/app_state_lesson.dart @@ -0,0 +1,400 @@ +part of 'app_state.dart'; + +/// Position in the seed course, the in-lesson step flow, and the evidence +/// lesson tasks produce. +mixin _LessonProgress on _AppStateData, _ReviewAndMastery { + 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(); + } + + void saveDialogueDraft(DialogueDraft draft) { + dialogueDraft = draft; + notifyListeners(); + } + + void clearDialogueDraft() { + if (dialogueDraft == null) return; + dialogueDraft = null; + notifyListeners(); + } + + void saveSceneDialogueDraft(DialogueDraft draft) { + sceneDialogueDraft = draft; + notifyListeners(); + } + + void clearSceneDialogueDraft() { + if (sceneDialogueDraft == null) return; + sceneDialogueDraft = null; + notifyListeners(); + } + + bool get lessonCanComplete => + lessonListeningComplete && + lessonSpeakingComplete && + lessonReadingComplete && + lessonWritingComplete && + lessonDialogueComplete && + independentAttemptComplete; + + 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(); + _syncInBackground(); + } + + /// Merges lesson progress pulled from the sync server. + /// + /// Completed lessons/segments are unioned, and the learner's position only + /// ever moves forward: a device that has not caught up yet must never drag + /// another device back to an earlier lesson or segment. + bool mergeSyncedLessonProgress({ + required Iterable completedLessons, + required Iterable completedSegments, + required String remoteActiveLessonId, + }) { + var changed = false; + final completedBefore = completedLessonIds.toSet(); + for (final id in completedLessons) { + if (completedLessonIds.add(id)) changed = true; + } + for (final id in completedSegments) { + if (completedSegmentIds.add(id)) changed = true; + } + if (this.completedLessons != completedLessonIds.length) { + this.completedLessons = completedLessonIds.length; + changed = true; + } + + int lessonIndex(String id) => + a0SeedLessons.indexWhere((lesson) => lesson.id == id); + + var targetIndex = lessonIndex(activeLessonId); + final remoteIndex = lessonIndex(remoteActiveLessonId); + if (remoteIndex > targetIndex && isLessonUnlocked(remoteActiveLessonId)) { + targetIndex = remoteIndex; + } + // The server's active lesson can itself be stale. Skip lessons that only + // became complete through this pull, but leave a lesson alone when the + // learner deliberately reopened it locally after finishing it. + while (targetIndex >= 0 && + targetIndex < a0SeedLessons.length - 1 && + !completedBefore.contains(a0SeedLessons[targetIndex].id) && + completedLessonIds.contains(a0SeedLessons[targetIndex].id)) { + targetIndex++; + } + + var positionChanged = false; + if (targetIndex >= 0 && a0SeedLessons[targetIndex].id != activeLessonId) { + activeLessonId = a0SeedLessons[targetIndex].id; + positionChanged = true; + } + + for (final lesson in a0SeedLessons) { + final segments = lesson.segments; + var firstOpen = segments.indexWhere( + (segment) => !completedSegmentIds.contains(segment.id), + ); + if (firstOpen < 0) firstOpen = segments.length - 1; + if (firstOpen > activeSegmentIndexFor(lesson.id)) { + activeSegmentIndexes[lesson.id] = firstOpen; + if (lesson.id == activeLessonId) positionChanged = true; + changed = true; + } + } + + if (positionChanged) { + _resetLessonFlow(); + changed = true; + } + return changed; + } + + 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(); + _syncInBackground(); + } + + 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(); + } + + 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, + ), + ); + } + } +} diff --git a/kouyu_english/lib/core/app_state_review.dart b/kouyu_english/lib/core/app_state_review.dart new file mode 100644 index 0000000..540d3dc --- /dev/null +++ b/kouyu_english/lib/core/app_state_review.dart @@ -0,0 +1,463 @@ +part of 'app_state.dart'; + +/// Review queue scheduling and the mastery derived from attempt evidence. +mixin _ReviewAndMastery on _AppStateData { + 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)); + } + + 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; + + int get usableMasteryCount => mastery.values + .where( + (item) => + item.status == MasteryStatus.use || + item.status == MasteryStatus.master, + ) + .length; + + void addPhraseToReview({ + required String phrase, + required String meaning, + String? ipa, + String? usageNote, + required String contextSentence, + }) { + final cleanPhrase = phrase.trim(); + if (cleanPhrase.isEmpty) return; + final id = + 'phrase_${cleanPhrase.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_')}'; + final fullMeaning = usageNote != null && usageNote.trim().isNotEmpty + ? '$meaning ($usageNote)' + : meaning; + final item = VocabularyItem( + id: id, + word: cleanPhrase, + meaning: fullMeaning, + example: contextSentence.trim(), + exampleMeaning: meaning, + ipa: ipa, + ); + addSavedWord(item); + } + + 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(); + _syncInBackground(); + } + + 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 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(); + } + + 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 _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, + }; +}