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:
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user