Implement verified curriculum, video review, watch sessions, ledger safeguards, and remove demo data

This commit is contained in:
Hamza-Ayed
2026-09-09 17:04:08 +03:00
parent 0dc5df7b5e
commit 5af684a892
49 changed files with 1809 additions and 3005 deletions
@@ -71,6 +71,9 @@ class VideoPlaybackError extends VideoPlaybackState {
class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
final CurriculumRepository _repo;
int _lastObservedPosition = -1;
String? _watchSessionId;
int _watchSequence = 1;
int _lastWatchEventPosition = 0;
VideoPlaybackCubit({CurriculumRepository? repo})
: _repo = repo ?? CurriculumRepository(),
@@ -86,19 +89,30 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
return fallbackId ?? 'lesson_default';
}
Future<void> loadLesson(CurriculumLessonItemModel lesson, {SubjectModel? subject}) async {
Future<void> loadLesson(CurriculumLessonItemModel lesson, {SubjectModel? subject, String? selectedVideoVersionId}) async {
AppLogger.log('Loading Socratic playback for lesson: ${lesson.title}', tag: 'VIDEO_CUBIT');
_lastObservedPosition = -1;
emit(VideoPlaybackLoading());
final storageKey = _getLessonStorageKey(lesson);
try {
final cleanKey = (lesson.markdownFilePath ?? lesson.id).replaceAll(RegExp(r'\.md$'), '');
var playback = await _repo.getLessonPlayback(cleanKey, subjectId: subject?.id, title: lesson.title);
final versionId = lesson.curriculumLessonId;
if (versionId == null || versionId.isEmpty) {
throw StateError('هذا الدرس غير منشور بعد ضمن حزمة المحتوى المعتمدة.');
}
final published = await _repo.getPublishedLessonVideos(versionId);
if (published.isEmpty) throw StateError('لا توجد حصة منشورة ومصرح بها لهذا الدرس بعد.');
// Selection is performed in the lesson screen when several teachers exist.
var playback = await _repo.getVideoVersionPlayback(selectedVideoVersionId ?? published.first.videoVersionId);
if (playback.videoUrl.isEmpty) {
throw StateError('لم يربط الخادم فيديو R2 بهذا الدرس بعد.');
}
_watchSessionId = null; _watchSequence = 1; _lastWatchEventPosition = 0;
if (playback.videoVersionId != null && playback.videoVersionId!.isNotEmpty) {
try { _watchSessionId = await _repo.startWatchSession(playback.videoVersionId!); } catch (_) {}
}
// Load saved resume position strictly per video
int resumePos = playback.lastPositionSeconds ?? 0;
Set<int> passedIds = {};
@@ -151,6 +165,13 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
}
}
Future<void> endWatchSession() async {
final session = _watchSessionId;
if (session == null) return;
_watchSessionId = null;
try { await _repo.recordWatchEvent(session, ++_watchSequence, 'end', _lastObservedPosition.clamp(0, 1 << 31) * 1000); } catch (_) {}
}
void togglePlayPause() {
final currentState = state;
if (currentState is VideoPlaybackReady && currentState.activeCheckpoint == null) {
@@ -197,11 +218,11 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
);
}
if (currentState.playbackData.lessonId > 0) {
await _repo.saveProgress(
lessonId: currentState.playbackData.lessonId,
positionSeconds: positionSeconds,
watchedSeconds: watchedSeconds,
);
await _repo.saveProgress(lessonId: currentState.playbackData.lessonId, positionSeconds: positionSeconds, watchedSeconds: watchedSeconds);
}
if (_watchSessionId != null && positionSeconds - _lastWatchEventPosition >= 30) {
_watchSequence++; _lastWatchEventPosition = positionSeconds;
await _repo.recordWatchEvent(_watchSessionId!, _watchSequence, 'heartbeat', positionSeconds * 1000);
}
} catch (e) {
AppLogger.log('Progress sync deferred: $e', tag: 'VIDEO_CUBIT');