Files
English/kouyu_english/lib/core/app_state.dart
T

1749 lines
58 KiB
Dart

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