refactor: 按领域将 AppState 拆分为 mixin

- 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 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-16 17:22:19 +09:00
co-authored by Claude Opus 5
parent 6c0aeec628
commit 9b0f9d64cb
5 changed files with 1254 additions and 1221 deletions
File diff suppressed because it is too large Load Diff
@@ -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<String, dynamic>;
final targets = data['targetItemIds'] as List<dynamic>?;
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();
}
}
@@ -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();
}
}
@@ -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<String> completedLessons,
required Iterable<String> 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<String> 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<String> 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,
),
);
}
}
}
@@ -0,0 +1,463 @@
part of 'app_state.dart';
/// Review queue scheduling and the mastery derived from attempt evidence.
mixin _ReviewAndMastery on _AppStateData {
List<ReviewItem> 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 = <String>{};
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,
};
}