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