diff --git a/apps/student_app/lib/core/utils/app_logger.dart b/apps/student_app/lib/core/utils/app_logger.dart index b548e1e..5e8e02a 100644 --- a/apps/student_app/lib/core/utils/app_logger.dart +++ b/apps/student_app/lib/core/utils/app_logger.dart @@ -6,13 +6,57 @@ import 'package:flutter/foundation.dart'; class AppLogger { AppLogger._(); + static const _sensitiveKeyParts = [ + 'authorization', + 'token', + 'secret', + 'password', + 'phone', + 'national', + 'identity', + 'otp', + 'ticket', + 'email', + 'fingerprint', + 'pin', + 'address', + ]; + + static bool _isSensitiveKey(String key) { + final normalized = key.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), ''); + return _sensitiveKeyParts.any(normalized.contains); + } + + static dynamic _safeLogValue(dynamic value, {String key = ''}) { + if (_isSensitiveKey(key)) return '[REDACTED]'; + if (value is Map) { + return value.map((k, v) => MapEntry( + k.toString(), + _safeLogValue(v, key: k.toString()), + )); + } + if (value is List) { + return value.map((v) => _safeLogValue(v)).toList(); + } + if (value is String && key.isEmpty) return '[TEXT OMITTED]'; + return value; + } + + static Uri _safeUri(Uri uri) { + final safeQuery = Map.fromEntries( + uri.queryParameters.entries.where((entry) => !_isSensitiveKey(entry.key)), + ); + return uri.replace(queryParameters: safeQuery.isEmpty ? null : safeQuery); + } + static void log(String message, {String tag = 'SAQEL'}) { if (kDebugMode) { debugPrint('[$tag] $message'); } } - static void event(String name, {Map? details, String tag = 'EVENT'}) { + static void event(String name, + {Map? details, String tag = 'EVENT'}) { if (!kDebugMode) return; final buffer = StringBuffer(); buffer.write('⚡ [$tag] $name'); @@ -35,21 +79,23 @@ class AppLogger { if (!kDebugMode) return; final buffer = StringBuffer(); - buffer.writeln('\n🌐 ─── [HTTP REQUEST] ──────────────────────────────────────────'); + buffer.writeln( + '\n🌐 ─── [HTTP REQUEST] ──────────────────────────────────────────'); buffer.writeln('➡️ Method: $method'); - buffer.writeln('🔗 URL: $uri'); + buffer.writeln('🔗 URL: ${_safeUri(uri)}'); if (headers != null && headers.isNotEmpty) { final safeHeaders = Map.from(headers); if (safeHeaders.containsKey('Authorization')) { - final auth = safeHeaders['Authorization']!; - safeHeaders['Authorization'] = auth.length > 20 ? '${auth.substring(0, 15)}...' : 'Bearer [REDACTED]'; + safeHeaders['Authorization'] = '[REDACTED]'; } + safeHeaders.removeWhere((key, _) => _isSensitiveKey(key)); buffer.writeln('📋 Headers: ${jsonEncode(safeHeaders)}'); } if (body != null) { - buffer.writeln('📦 Body: ${body is String ? body : jsonEncode(body)}'); + buffer.writeln('📦 Body: ${jsonEncode(_safeLogValue(body))}'); } - buffer.writeln('───────────────────────────────────────────────────────────────'); + buffer.writeln( + '───────────────────────────────────────────────────────────────'); debugPrint(buffer.toString()); } @@ -66,24 +112,28 @@ class AppLogger { final emoji = isSuccess ? '✅' : '⚠️'; final buffer = StringBuffer(); - buffer.writeln('\n$emoji ─── [HTTP RESPONSE $statusCode] ────────────────────────────────'); + buffer.writeln( + '\n$emoji ─── [HTTP RESPONSE $statusCode] ────────────────────────────────'); buffer.writeln('⬅️ Method: $method'); - buffer.writeln('🔗 URL: $uri'); + buffer.writeln('🔗 URL: ${_safeUri(uri)}'); if (duration != null) { buffer.writeln('⏱️ Time: ${duration.inMilliseconds}ms'); } if (responseBody != null) { - buffer.writeln('📦 Data: ${responseBody is String ? responseBody : jsonEncode(responseBody)}'); + buffer.writeln('📦 Data: ${jsonEncode(_safeLogValue(responseBody))}'); } - buffer.writeln('───────────────────────────────────────────────────────────────'); + buffer.writeln( + '───────────────────────────────────────────────────────────────'); debugPrint(buffer.toString()); } - static void error(String message, {dynamic error, StackTrace? stackTrace, String tag = 'ERROR'}) { + static void error(String message, + {dynamic error, StackTrace? stackTrace, String tag = 'ERROR'}) { if (!kDebugMode) return; final buffer = StringBuffer(); - buffer.writeln('\n🚨 ─── [ERROR: $tag] ──────────────────────────────────────────'); + buffer.writeln( + '\n🚨 ─── [ERROR: $tag] ──────────────────────────────────────────'); buffer.writeln('❌ Message: $message'); if (error != null) { buffer.writeln('⚠️ Error: $error'); @@ -91,7 +141,8 @@ class AppLogger { if (stackTrace != null) { buffer.writeln('📍 Trace: \n$stackTrace'); } - buffer.writeln('───────────────────────────────────────────────────────────────'); + buffer.writeln( + '───────────────────────────────────────────────────────────────'); debugPrint(buffer.toString()); } } diff --git a/apps/student_app/lib/data/models/error_notebook_model.dart b/apps/student_app/lib/data/models/error_notebook_model.dart index a16aa16..4e89c69 100644 --- a/apps/student_app/lib/data/models/error_notebook_model.dart +++ b/apps/student_app/lib/data/models/error_notebook_model.dart @@ -55,6 +55,7 @@ class ErrorNotebookItem { final String errorCategory; // 'conceptual', 'calculation', 'rushed', 'misinterpretation' final String status; // 'pending_remediation', 'in_remediation', 'mastered' final int remediationAttemptsCount; + final bool retentionIsDue; final String? masteredAt; final String createdAt; @@ -72,6 +73,7 @@ class ErrorNotebookItem { required this.errorCategory, required this.status, required this.remediationAttemptsCount, + this.retentionIsDue = false, this.masteredAt, required this.createdAt, }); @@ -125,6 +127,7 @@ class ErrorNotebookItem { errorCategory: json['error_category']?.toString() ?? 'conceptual', status: json['status']?.toString() ?? 'pending_remediation', remediationAttemptsCount: (json['remediation_attempts_count'] as num?)?.toInt() ?? 0, + retentionIsDue: json['retention_is_due'] == true || json['retention_is_due'] == 1 || json['retention_is_due'] == '1', masteredAt: json['mastered_at']?.toString(), createdAt: json['created_at']?.toString() ?? '', ); @@ -149,6 +152,7 @@ class ErrorNotebookItem { errorCategory: errorCategory, status: status ?? this.status, remediationAttemptsCount: remediationAttemptsCount ?? this.remediationAttemptsCount, + retentionIsDue: retentionIsDue, masteredAt: masteredAt ?? this.masteredAt, createdAt: createdAt, ); @@ -158,16 +162,12 @@ class ErrorNotebookItem { class RemedialQuestion { final int id; final String question; - final List options; - final int correctIndex; - final String explanation; + final List options; RemedialQuestion({ required this.id, required this.question, required this.options, - required this.correctIndex, - required this.explanation, }); factory RemedialQuestion.fromJson(Map json) { @@ -175,9 +175,17 @@ class RemedialQuestion { return RemedialQuestion( id: (json['id'] as num?)?.toInt() ?? 0, question: json['question']?.toString() ?? '', - options: rawOptions.map((e) => e.toString()).toList(), - correctIndex: (json['correct_index'] as num?)?.toInt() ?? 0, - explanation: json['explanation']?.toString() ?? '', + options: rawOptions.map((e) => RemedialOption.fromJson(Map.from(e as Map))).toList(), ); } } + +class RemedialOption { + final int id; + final String text; + RemedialOption({required this.id, required this.text}); + factory RemedialOption.fromJson(Map json) => RemedialOption( + id: (json['id'] as num?)?.toInt() ?? 0, + text: json['text']?.toString() ?? '', + ); +} diff --git a/apps/student_app/lib/data/models/subject_model.dart b/apps/student_app/lib/data/models/subject_model.dart index 2cca7a9..d4b7498 100644 --- a/apps/student_app/lib/data/models/subject_model.dart +++ b/apps/student_app/lib/data/models/subject_model.dart @@ -209,6 +209,8 @@ class CurriculumLessonItemModel { final List outcomes; final String? markdownFilePath; final String? curriculumLessonId; + final String? curriculumVersion; + final String? primaryLessonAssetId; final int durationSeconds; final int checkpointsCount; final bool isCompleted; @@ -220,6 +222,8 @@ class CurriculumLessonItemModel { this.outcomes = const [], this.markdownFilePath, this.curriculumLessonId, + this.curriculumVersion, + this.primaryLessonAssetId, this.durationSeconds = 0, this.checkpointsCount = 0, this.isCompleted = false, @@ -254,6 +258,8 @@ class CurriculumLessonItemModel { outcomes: outs, markdownFilePath: filePath, curriculumLessonId: json['curriculum_lesson_id']?.toString(), + curriculumVersion: json['curriculum_version']?.toString(), + primaryLessonAssetId: json['primary_lesson_asset_id']?.toString(), durationSeconds: (json['duration_seconds'] as num?)?.toInt() ?? 0, checkpointsCount: (json['checkpoints_count'] as num?)?.toInt() ?? 0, isCompleted: (json['is_completed'] as bool?) ?? false, diff --git a/apps/student_app/lib/data/repositories/curriculum_repository.dart b/apps/student_app/lib/data/repositories/curriculum_repository.dart index bf598c4..2b3e3f3 100644 --- a/apps/student_app/lib/data/repositories/curriculum_repository.dart +++ b/apps/student_app/lib/data/repositories/curriculum_repository.dart @@ -28,19 +28,53 @@ class GradeLevelModel { GradeLevelModel(key: 'grade_9', name: 'الصف التاسع الأساسي', number: 9), GradeLevelModel(key: 'grade_10', name: 'الصف العاشر الأساسي', number: 10), GradeLevelModel(key: 'grade_11', name: 'الأول ثانوي', number: 11), - GradeLevelModel(key: 'tawjihi_2008', name: 'الثاني ثانوي — توجيهي 2008', number: 12), + GradeLevelModel( + key: 'tawjihi_2008', name: 'الثاني ثانوي — توجيهي 2008', number: 12), ]; static String normalizeGradeKey(String raw) { final clean = raw.toLowerCase().trim(); - if (clean.contains('عاشر') || clean.contains('grade_10') || clean.contains('grade 10') || clean.contains('grade10') || clean == '10') return 'grade_10'; - if (clean.contains('تاسع') || clean.contains('grade_9') || clean.contains('grade 9') || clean.contains('grade9') || clean == '9') return 'grade_9'; - if (clean.contains('ثامن') || clean.contains('grade_8') || clean.contains('grade 8') || clean.contains('grade8') || clean == '8') return 'grade_8'; - if (clean.contains('سابع') || clean.contains('grade_7') || clean.contains('grade 7') || clean.contains('grade7') || clean == '7') return 'grade_7'; - if (clean.contains('سادس') || clean.contains('grade_6') || clean.contains('grade 6') || clean.contains('grade6') || clean == '6') return 'grade_6'; - if (clean.contains('خامس') || clean.contains('grade_5') || clean.contains('grade 5') || clean.contains('grade5') || clean == '5') return 'grade_5'; - if (clean.contains('حادي') || clean.contains('أول ثانوي') || clean.contains('grade_11') || clean.contains('grade 11') || clean == '11') return 'grade_11'; - if (clean.contains('ثاني عشر') || clean.contains('توجيهي') || clean.contains('tawjihi') || clean.contains('grade_12') || clean.contains('grade 12') || clean == '12') return 'tawjihi_2008'; + if (clean.contains('عاشر') || + clean.contains('grade_10') || + clean.contains('grade 10') || + clean.contains('grade10') || + clean == '10') return 'grade_10'; + if (clean.contains('تاسع') || + clean.contains('grade_9') || + clean.contains('grade 9') || + clean.contains('grade9') || + clean == '9') return 'grade_9'; + if (clean.contains('ثامن') || + clean.contains('grade_8') || + clean.contains('grade 8') || + clean.contains('grade8') || + clean == '8') return 'grade_8'; + if (clean.contains('سابع') || + clean.contains('grade_7') || + clean.contains('grade 7') || + clean.contains('grade7') || + clean == '7') return 'grade_7'; + if (clean.contains('سادس') || + clean.contains('grade_6') || + clean.contains('grade 6') || + clean.contains('grade6') || + clean == '6') return 'grade_6'; + if (clean.contains('خامس') || + clean.contains('grade_5') || + clean.contains('grade 5') || + clean.contains('grade5') || + clean == '5') return 'grade_5'; + if (clean.contains('حادي') || + clean.contains('أول ثانوي') || + clean.contains('grade_11') || + clean.contains('grade 11') || + clean == '11') return 'grade_11'; + if (clean.contains('ثاني عشر') || + clean.contains('توجيهي') || + clean.contains('tawjihi') || + clean.contains('grade_12') || + clean.contains('grade 12') || + clean == '12') return 'tawjihi_2008'; return raw; } @@ -54,8 +88,7 @@ class GradeLevelModel { } } -/// 100% Live Dynamic Curriculum Repository (Zero Hardcoded Data) -/// Dynamically fetches all grades (الصف الأول إلى التوجيهي) and their subjects from Live API. +/// Reads only the server-published curriculum catalogue. class CurriculumRepository { final ApiClient _api; @@ -63,11 +96,12 @@ class CurriculumRepository { /// Fetch full curriculum tree from server Future> getRawCurriculumTree() async { - final res = await _api.get(AppConfig.curriculumTreeEndpoint, requiresAuth: false); - if (res is Map && res['data'] != null && res['data'] is Map) { + final res = + await _api.get(AppConfig.curriculumTreeEndpoint, requiresAuth: false); + if (res is Map && res['status'] == 'success' && res['data'] is Map) { return Map.from(res['data']); } - return {}; + throw ApiException('استجابة فهرس الدروس المنشورة غير صالحة.'); } /// Fetch available grades from Live Server Manifest @@ -78,22 +112,28 @@ class CurriculumRepository { for (var grade in GradeLevelModel.allK12Grades) { final norm = GradeLevelModel.normalizeGradeKey(grade.key); final shortKey = norm.replaceAll('grade_', ''); - if (tree.containsKey(grade.key) || tree.containsKey(norm) || tree.containsKey(shortKey)) { + if (tree.containsKey(grade.key) || + tree.containsKey(norm) || + tree.containsKey(shortKey)) { available.add(grade); } } - return available.isNotEmpty ? available : GradeLevelModel.allK12Grades; + return available; } /// Fetch subjects for a specific grade level dynamically from Live Backend - Future> getSubjects({String gradeLevel = 'grade_10', String stream = 'scientific'}) async { + Future> getSubjects( + {String gradeLevel = 'grade_10', String stream = 'scientific'}) async { final normGrade = GradeLevelModel.normalizeGradeKey(gradeLevel); - AppLogger.log('Fetching live curriculum tree from /api/curriculum/tree (Grade: $gradeLevel -> $normGrade)...', tag: 'CURRICULUM_REPO'); - + AppLogger.log( + 'Fetching live curriculum tree from /api/curriculum/tree (Grade: $gradeLevel -> $normGrade)...', + tag: 'CURRICULUM_REPO'); + final tree = await getRawCurriculumTree(); if (tree.isEmpty) { - AppLogger.log('No curriculum data returned from server.', tag: 'CURRICULUM_REPO'); + AppLogger.log('No curriculum data returned from server.', + tag: 'CURRICULUM_REPO'); return []; } @@ -119,7 +159,8 @@ class CurriculumRepository { } if (entry.value is Map && entry.value['name'] != null) { final name = entry.value['name'].toString(); - if (GradeLevelModel.normalizeGradeKey(name) == normGrade || name.contains(normGrade)) { + if (GradeLevelModel.normalizeGradeKey(name) == normGrade || + name.contains(normGrade)) { gradeData = Map.from(entry.value); break; } @@ -128,8 +169,12 @@ class CurriculumRepository { } } - if (gradeData == null || gradeData['subjects'] == null || gradeData['subjects'] is! Map) { - AppLogger.log('No subjects found in grade $gradeLevel ($normGrade) on server.', tag: 'CURRICULUM_REPO'); + if (gradeData == null || + gradeData['subjects'] == null || + gradeData['subjects'] is! Map) { + AppLogger.log( + 'No subjects found in grade $gradeLevel ($normGrade) on server.', + tag: 'CURRICULUM_REPO'); return []; } @@ -143,74 +188,130 @@ class CurriculumRepository { } }); - AppLogger.log('Successfully parsed ${liveSubjects.length} live subjects for $gradeLevel: ${liveSubjects.map((s) => s.title).join(', ')}', tag: 'CURRICULUM_REPO'); + AppLogger.log( + 'Successfully parsed ${liveSubjects.length} live subjects for $gradeLevel: ${liveSubjects.map((s) => s.title).join(', ')}', + tag: 'CURRICULUM_REPO'); return liveSubjects; } - Future> getPublishedLessonVideos(String curriculumLessonId) async { - AppLogger.event('FetchPublishedVideosRequested', details: {'curriculumLessonId': curriculumLessonId}, tag: 'CURRICULUM_REPO'); - final res = await _api.get('/api/curriculum/lessons/$curriculumLessonId/videos'); - final data = res is Map ? res['data'] : null; - final raw = data is Map ? data['items'] : null; - if (raw is! List) return const []; - final list = raw.whereType().map((item) => PublishedLessonVideoModel.fromJson(Map.from(item))).where((item) => item.videoVersionId.isNotEmpty).toList(); - AppLogger.event('FetchPublishedVideosSuccess', details: {'count': list.length, 'teachers': list.map((v) => v.teacherName).toList()}, tag: 'CURRICULUM_REPO'); - return list; + Future> getPublishedLessonVideos( + String curriculumLessonId) async { + return (await getPublishedLessonVideosPage(curriculumLessonId)).items; } - Future getVideoVersionPlayback(String videoVersionId) async { - AppLogger.event('FetchVideoPlaybackRequested', details: {'videoVersionId': videoVersionId}, tag: 'CURRICULUM_REPO'); + Future getPublishedLessonVideosPage( + String curriculumLessonId, + {int cursor = 0, + int limit = 20}) async { + AppLogger.event('FetchPublishedVideosRequested', + details: {'curriculumLessonId': curriculumLessonId}, + tag: 'CURRICULUM_REPO'); + final res = await _api.get( + '/api/curriculum/lessons/$curriculumLessonId/videos', + queryParams: {'cursor': cursor, 'limit': limit}); + final data = res is Map ? res['data'] : null; + final raw = data is Map ? data['items'] : null; + if (raw is! List || data is! Map) + throw ApiException('استجابة قائمة الحصص غير صالحة.'); + final list = raw + .whereType() + .map((item) => + PublishedLessonVideoModel.fromJson(Map.from(item))) + .where((item) => item.videoVersionId.isNotEmpty) + .toList(); + AppLogger.event('FetchPublishedVideosSuccess', + details: { + 'count': list.length, + 'teachers': list.map((v) => v.teacherName).toList() + }, + tag: 'CURRICULUM_REPO'); + return PublishedLessonVideoPage( + items: list, + availableCount: (data['available_count'] as num?)?.toInt() ?? list.length, + nextCursor: (data['next_cursor'] as num?)?.toInt(), + ); + } + + Future getVideoVersionPlayback( + String videoVersionId) async { + AppLogger.event('FetchVideoPlaybackRequested', + details: {'videoVersionId': videoVersionId}, tag: 'CURRICULUM_REPO'); final res = await _api.get('/api/video-versions/$videoVersionId/playback'); if (res is Map && res['data'] is Map) { - final pb = LessonPlaybackData.fromJson(Map.from(res['data'])); - AppLogger.event('FetchVideoPlaybackSuccess', details: { - 'videoVersionId': pb.videoVersionId, - 'videoUrl': pb.videoUrl, - 'duration': pb.durationSeconds, - }, tag: 'CURRICULUM_REPO'); + final pb = + LessonPlaybackData.fromJson(Map.from(res['data'])); + AppLogger.event('FetchVideoPlaybackSuccess', + details: { + 'videoVersionId': pb.videoVersionId, + 'duration': pb.durationSeconds, + }, + tag: 'CURRICULUM_REPO'); return pb; } throw ApiException('فشل جلب تشغيل الحصة المنشورة من الخادم'); } Future startWatchSession(String videoVersionId) async { - AppLogger.event('StartWatchSessionRequested', details: {'videoVersionId': videoVersionId}, tag: 'CURRICULUM_REPO'); - final res = await _api.post('/api/video-versions/$videoVersionId/watch-sessions', body: const {}); + AppLogger.event('StartWatchSessionRequested', + details: {'videoVersionId': videoVersionId}, tag: 'CURRICULUM_REPO'); + final res = await _api.post( + '/api/video-versions/$videoVersionId/watch-sessions', + body: const {}); final data = res is Map ? res['data'] : null; final source = data is Map ? data : res; final id = source is Map ? source['watch_session_id']?.toString() : null; - if (id == null || id.isEmpty) throw ApiException('لم ينشئ الخادم جلسة مشاهدة.'); - AppLogger.event('StartWatchSessionSuccess', details: {'sessionId': id}, tag: 'CURRICULUM_REPO'); + if (id == null || id.isEmpty) + throw ApiException('لم ينشئ الخادم جلسة مشاهدة.'); + AppLogger.event('StartWatchSessionSuccess', + details: {'sessionId': id}, tag: 'CURRICULUM_REPO'); return id; } - Future recordWatchEvent(String sessionId, int sequenceNo, String eventType, int positionMs) async { - AppLogger.event('RecordWatchEvent', details: { - 'sessionId': sessionId, - 'seq': sequenceNo, - 'type': eventType, - 'positionMs': positionMs, - }, tag: 'CURRICULUM_REPO'); - await _api.post('/api/watch-sessions/$sessionId/events', body: {'sequence_no': sequenceNo, 'event_type': eventType, 'position_ms': positionMs}); + + Future recordWatchEvent(String sessionId, int sequenceNo, + String eventType, int positionMs) async { + AppLogger.event('RecordWatchEvent', + details: { + 'sessionId': sessionId, + 'seq': sequenceNo, + 'type': eventType, + 'positionMs': positionMs, + }, + tag: 'CURRICULUM_REPO'); + await _api.post('/api/watch-sessions/$sessionId/events', body: { + 'sequence_no': sequenceNo, + 'event_type': eventType, + 'position_ms': positionMs + }); } - Future saveProgress({required int lessonId, required int positionSeconds, required int watchedSeconds}) async { - AppLogger.event('SaveProgressRequested', details: { - 'lessonId': lessonId, - 'position': positionSeconds, - 'watched': watchedSeconds, - }, tag: 'CURRICULUM_REPO'); + Future saveProgress( + {required int lessonId, + required int positionSeconds, + required int watchedSeconds}) async { + AppLogger.event('SaveProgressRequested', + details: { + 'lessonId': lessonId, + 'position': positionSeconds, + 'watched': watchedSeconds, + }, + tag: 'CURRICULUM_REPO'); await _api.post('/api/student/lessons/$lessonId/progress', body: { 'position_seconds': positionSeconds, 'watched_seconds': watchedSeconds, }); } - Future submitCheckpoint({required int examId, required int questionId, required int optionId}) async { - AppLogger.event('SubmitCheckpointRequested', details: { - 'examId': examId, - 'questionId': questionId, - 'optionId': optionId, - }, tag: 'CURRICULUM_REPO'); + Future submitCheckpoint( + {required int examId, + required int questionId, + required int optionId}) async { + AppLogger.event('SubmitCheckpointRequested', + details: { + 'examId': examId, + 'questionId': questionId, + 'optionId': optionId, + }, + tag: 'CURRICULUM_REPO'); await _api.post('/api/exams/$examId/submit', body: { 'answers': [ {'question_id': questionId, 'selected_option_id': optionId} @@ -226,8 +327,36 @@ class PublishedLessonVideoModel { final double rating; final int ratingCount; final bool isNew; - const PublishedLessonVideoModel({required this.videoVersionId, required this.submissionId, required this.teacherName, required this.rating, required this.ratingCount, required this.isNew}); - factory PublishedLessonVideoModel.fromJson(Map json) => PublishedLessonVideoModel( - videoVersionId: json['video_version_id']?.toString() ?? '', submissionId: json['submission_id']?.toString() ?? '', teacherName: json['teacher_name']?.toString() ?? 'معلم معتمد', rating: (json['rating'] as num?)?.toDouble() ?? 0, ratingCount: (json['rating_count'] as num?)?.toInt() ?? 0, isNew: json['is_new'] == true, - ); + final int durationSeconds; + final DateTime? publishedAt; + const PublishedLessonVideoModel( + {required this.videoVersionId, + required this.submissionId, + required this.teacherName, + required this.rating, + required this.ratingCount, + required this.isNew, + required this.durationSeconds, + this.publishedAt}); + factory PublishedLessonVideoModel.fromJson(Map json) => + PublishedLessonVideoModel( + videoVersionId: json['video_version_id']?.toString() ?? '', + submissionId: json['submission_id']?.toString() ?? '', + teacherName: json['teacher_name']?.toString() ?? '', + rating: (json['rating'] as num?)?.toDouble() ?? 0, + ratingCount: (json['rating_count'] as num?)?.toInt() ?? 0, + isNew: json['is_new'] == true, + durationSeconds: (json['duration_seconds'] as num?)?.toInt() ?? 0, + publishedAt: DateTime.tryParse(json['published_at']?.toString() ?? ''), + ); +} + +class PublishedLessonVideoPage { + final List items; + final int availableCount; + final int? nextCursor; + const PublishedLessonVideoPage( + {required this.items, + required this.availableCount, + required this.nextCursor}); } diff --git a/apps/student_app/lib/data/repositories/error_notebook_repository.dart b/apps/student_app/lib/data/repositories/error_notebook_repository.dart index c96cf86..c3663d8 100644 --- a/apps/student_app/lib/data/repositories/error_notebook_repository.dart +++ b/apps/student_app/lib/data/repositories/error_notebook_repository.dart @@ -23,18 +23,7 @@ class ErrorNotebookRepository { 'summary': ErrorNotebookSummary.fromJson(Map.from(data['summary'] as Map? ?? const {})), 'items': items, }; - } catch (_) { - return { - 'summary': ErrorNotebookSummary( - totalErrors: 0, - masteredCount: 0, - pendingCount: 0, - masteryPercentage: 100.0, - bySubject: {}, - ), - 'items': [], - }; - } + } catch (_) { rethrow; } } Future> getChildErrorNotebook(int studentId) async { @@ -48,18 +37,7 @@ class ErrorNotebookRepository { 'summary': ErrorNotebookSummary.fromJson(Map.from(data['summary'] as Map? ?? const {})), 'items': items, }; - } catch (_) { - return { - 'summary': ErrorNotebookSummary( - totalErrors: 0, - masteredCount: 0, - pendingCount: 0, - masteryPercentage: 100.0, - bySubject: {}, - ), - 'items': [], - }; - } + } catch (_) { rethrow; } } Future logError({ @@ -99,30 +77,33 @@ class ErrorNotebookRepository { return null; } - Future> getRemediationQuiz({required String errorUuid, required String topicName}) async { + Future> getRemediationQuiz({required String errorUuid}) async { try { - final decoded = await _api.get( + final decoded = await _api.post( '/api/student/error-notebook/remediation-quiz', - queryParams: {'error_uuid': errorUuid, 'topic_name': topicName}, + body: {'error_uuid': errorUuid}, ); final data = Map.from(decoded['data'] as Map? ?? const {}); - return (data['questions'] as List? ?? const []) - .map((item) => RemedialQuestion.fromJson(Map.from(item as Map))) - .toList(); + return { + 'attempt_uuid': data['attempt_uuid']?.toString() ?? '', + 'questions': (data['questions'] as List? ?? const []) + .map((item) => RemedialQuestion.fromJson(Map.from(item as Map))) + .toList(), + }; } catch (_) { - return []; + return {'attempt_uuid': '', 'questions': []}; } } - Future resolveError(String errorUuid) async { + Future?> resolveError(String errorUuid, String attemptUuid, List answers) async { try { final decoded = await _api.post( '/api/student/error-notebook/resolve', - body: {'error_uuid': errorUuid}, + body: {'error_uuid': errorUuid, 'attempt_uuid': attemptUuid, 'answers': answers}, ); - return decoded is Map && decoded['status'] == 'success'; + return decoded is Map && decoded['status'] == 'success' ? Map.from(decoded) : null; } catch (_) { - return false; + return null; } } } diff --git a/apps/student_app/lib/logic/cubits/video_playback_cubit.dart b/apps/student_app/lib/logic/cubits/video_playback_cubit.dart index 8116e89..2630bc3 100644 --- a/apps/student_app/lib/logic/cubits/video_playback_cubit.dart +++ b/apps/student_app/lib/logic/cubits/video_playback_cubit.dart @@ -9,6 +9,7 @@ import '../../data/repositories/error_notebook_repository.dart'; abstract class VideoPlaybackState {} class VideoPlaybackInitial extends VideoPlaybackState {} + class VideoPlaybackLoading extends VideoPlaybackState {} class VideoPlaybackReady extends VideoPlaybackState { @@ -53,9 +54,12 @@ class VideoPlaybackReady extends VideoPlaybackState { playbackData: playbackData ?? this.playbackData, lessonItem: lessonItem ?? this.lessonItem, subject: subject ?? this.subject, - currentPositionSeconds: currentPositionSeconds ?? this.currentPositionSeconds, + currentPositionSeconds: + currentPositionSeconds ?? this.currentPositionSeconds, isPlaying: isPlaying ?? this.isPlaying, - activeCheckpoint: clearActiveCheckpoint ? null : (activeCheckpoint ?? this.activeCheckpoint), + activeCheckpoint: clearActiveCheckpoint + ? null + : (activeCheckpoint ?? this.activeCheckpoint), isCheckpointPassed: isCheckpointPassed ?? this.isCheckpointPassed, remediationNotice: remediationNotice, readinessBonusAdded: readinessBonusAdded ?? this.readinessBonusAdded, @@ -80,8 +84,10 @@ class VideoPlaybackCubit extends Cubit { : _repo = repo ?? CurriculumRepository(), super(VideoPlaybackInitial()); - String _getLessonStorageKey(CurriculumLessonItemModel? lesson, {String? fallbackId}) { - if (lesson?.markdownFilePath != null && lesson!.markdownFilePath!.isNotEmpty) { + String _getLessonStorageKey(CurriculumLessonItemModel? lesson, + {String? fallbackId}) { + if (lesson?.markdownFilePath != null && + lesson!.markdownFilePath!.isNotEmpty) { return lesson.markdownFilePath!.replaceAll(RegExp(r'[^a-zA-Z0-9_]'), '_'); } if (lesson?.id != null && lesson!.id.isNotEmpty) { @@ -90,14 +96,17 @@ class VideoPlaybackCubit extends Cubit { return fallbackId ?? 'lesson_default'; } - Future loadLesson(CurriculumLessonItemModel lesson, {SubjectModel? subject, String? selectedVideoVersionId}) async { - AppLogger.event('LoadLessonPlaybackRequested', details: { - 'lessonTitle': lesson.title, - 'lessonId': lesson.id, - 'curriculumLessonId': lesson.curriculumLessonId, - 'selectedVersionId': selectedVideoVersionId, - 'hasVideo': lesson.hasVideo, - }, tag: 'VIDEO_CUBIT'); + Future loadLesson(CurriculumLessonItemModel lesson, + {SubjectModel? subject, String? selectedVideoVersionId}) async { + AppLogger.event('LoadLessonPlaybackRequested', + details: { + 'lessonTitle': lesson.title, + 'lessonId': lesson.id, + 'curriculumLessonId': lesson.curriculumLessonId, + 'selectedVersionId': selectedVideoVersionId, + 'hasVideo': lesson.hasVideo, + }, + tag: 'VIDEO_CUBIT'); _lastObservedPosition = -1; emit(VideoPlaybackLoading()); final storageKey = _getLessonStorageKey(lesson); @@ -108,32 +117,38 @@ class VideoPlaybackCubit extends Cubit { throw StateError('هذا الدرس غير منشور بعد ضمن حزمة المحتوى المعتمدة.'); } final published = await _repo.getPublishedLessonVideos(versionId); - AppLogger.event('PublishedVideosLoaded', details: { - 'count': published.length, - 'teachers': published.map((p) => '${p.teacherName} (⭐ ${p.rating.toStringAsFixed(1)})').toList(), - }, tag: 'VIDEO_CUBIT'); - if (published.isEmpty) throw StateError('لا توجد حصة منشورة ومصرح بها لهذا الدرس بعد.'); + AppLogger.event('PublishedVideosLoaded', + details: { + 'count': published.length, + 'teachers': published + .map((p) => + '${p.teacherName} (⭐ ${p.rating.toStringAsFixed(1)})') + .toList(), + }, + tag: 'VIDEO_CUBIT'); + if (published.isEmpty) + throw StateError('لا توجد حصة منشورة ومصرح بها لهذا الدرس بعد.'); - final targetVersionId = selectedVideoVersionId ?? published.first.videoVersionId; + final targetVersionId = + selectedVideoVersionId ?? published.first.videoVersionId; var playback = await _repo.getVideoVersionPlayback(targetVersionId); - AppLogger.event('PlaybackDataLoaded', details: { - 'versionId': playback.videoVersionId, - 'videoUrl': playback.videoUrl, - 'durationSeconds': playback.durationSeconds, - 'checkpointsCount': playback.checkpoints.length, - }, tag: 'VIDEO_CUBIT'); + AppLogger.event('PlaybackDataLoaded', + details: { + 'versionId': playback.videoVersionId, + 'durationSeconds': playback.durationSeconds, + 'checkpointsCount': playback.checkpoints.length, + }, + tag: 'VIDEO_CUBIT'); 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!); - AppLogger.event('WatchSessionStarted', details: {'sessionId': _watchSessionId}, tag: 'VIDEO_CUBIT'); - } catch (_) {} - } + + // A watch session represents actual playback. The screen starts it only after + // the exact published video URL initializes successfully. + _watchSessionId = null; + _watchSequence = 1; + _lastWatchEventPosition = 0; // Load saved resume position strictly per video int resumePos = playback.lastPositionSeconds ?? 0; @@ -142,14 +157,20 @@ class VideoPlaybackCubit extends Cubit { final prefs = await SharedPreferences.getInstance(); final localPos = prefs.getInt('saved_video_pos_$storageKey') ?? 0; if (localPos > resumePos) resumePos = localPos; - final savedPassed = prefs.getStringList('passed_checkpoints_$storageKey') ?? []; - passedIds = savedPassed.map((s) => int.tryParse(s) ?? 0).where((id) => id > 0).toSet(); + final savedPassed = + prefs.getStringList('passed_checkpoints_$storageKey') ?? []; + passedIds = savedPassed + .map((s) => int.tryParse(s) ?? 0) + .where((id) => id > 0) + .toSet(); } catch (_) {} - AppLogger.event('VideoPlaybackStateEmitted', details: { - 'resumePosition': resumePos, - 'passedCheckpointsCount': passedIds.length, - }, tag: 'VIDEO_CUBIT'); + AppLogger.event('VideoPlaybackStateEmitted', + details: { + 'resumePosition': resumePos, + 'passedCheckpointsCount': passedIds.length, + }, + tag: 'VIDEO_CUBIT'); emit(VideoPlaybackReady( playbackData: playback, @@ -157,26 +178,33 @@ class VideoPlaybackCubit extends Cubit { subject: subject, currentPositionSeconds: resumePos, passedCheckpointIds: passedIds, - isPlaying: true, + // Playback becomes active only after the screen initializes the actual stream. + isPlaying: false, )); } catch (e) { - AppLogger.error('Playback API unavailable (${lesson.title})', error: e, tag: 'VIDEO_CUBIT'); + AppLogger.error('Playback API unavailable (${lesson.title})', + error: e, tag: 'VIDEO_CUBIT'); emit(VideoPlaybackError(e.toString())); } } void updatePosition(int seconds) { final currentState = state; - if (currentState is VideoPlaybackReady && currentState.activeCheckpoint == null) { + if (currentState is VideoPlaybackReady && + currentState.activeCheckpoint == null) { // Check if natural forward playback reaches a Socratic checkpoint for (var cp in currentState.playbackData.checkpoints) { - final hitCheckpoint = !currentState.passedCheckpointIds.contains(cp.id) && - _lastObservedPosition < cp.timestampSeconds && - seconds >= cp.timestampSeconds && - (seconds - _lastObservedPosition).abs() <= 3; // Natural sequential playback + final hitCheckpoint = + !currentState.passedCheckpointIds.contains(cp.id) && + _lastObservedPosition < cp.timestampSeconds && + seconds >= cp.timestampSeconds && + (seconds - _lastObservedPosition).abs() <= + 3; // Natural sequential playback if (hitCheckpoint) { - AppLogger.log('🚨 Socratic Checkpoint Triggered at ${cp.timestampSeconds}s! (${cp.questionText})', tag: 'SOCRATIC_ENGINE'); + AppLogger.log( + '🚨 Socratic Checkpoint Triggered at ${cp.timestampSeconds}s! (${cp.questionText})', + tag: 'SOCRATIC_ENGINE'); _lastObservedPosition = cp.timestampSeconds; emit(currentState.copyWith( currentPositionSeconds: cp.timestampSeconds, @@ -196,14 +224,43 @@ class VideoPlaybackCubit extends Cubit { final session = _watchSessionId; if (session == null) return; _watchSessionId = null; - try { await _repo.recordWatchEvent(session, ++_watchSequence, 'end', _lastObservedPosition.clamp(0, 1 << 31) * 1000); } catch (_) {} + try { + await _repo.recordWatchEvent(session, ++_watchSequence, 'end', + _lastObservedPosition.clamp(0, 1 << 31) * 1000); + } catch (_) {} + } + + Future startWatchSessionAfterPlaybackReady() async { + final currentState = state; + final versionId = currentState is VideoPlaybackReady + ? currentState.playbackData.videoVersionId + : null; + if (_watchSessionId != null || versionId == null || versionId.isEmpty) + return; + try { + _watchSequence = 1; + _lastWatchEventPosition = 0; + _watchSessionId = await _repo.startWatchSession(versionId); + AppLogger.event('WatchSessionStartedAfterVideoReady', + details: { + 'sessionId': _watchSessionId, + 'videoVersionId': versionId, + }, + tag: 'VIDEO_CUBIT'); + } catch (e) { + AppLogger.log( + 'Watch session unavailable after playback initialization: $e', + tag: 'VIDEO_CUBIT'); + } } void togglePlayPause() { final currentState = state; - if (currentState is VideoPlaybackReady && currentState.activeCheckpoint == null) { + if (currentState is VideoPlaybackReady && + currentState.activeCheckpoint == null) { final nextState = !currentState.isPlaying; - AppLogger.event('VideoPlayPauseToggled', details: {'isPlaying': nextState}, tag: 'VIDEO_CUBIT'); + AppLogger.event('VideoPlayPauseToggled', + details: {'isPlaying': nextState}, tag: 'VIDEO_CUBIT'); emit(currentState.copyWith(isPlaying: nextState)); } } @@ -218,7 +275,9 @@ class VideoPlaybackCubit extends Cubit { void play() { final currentState = state; - if (currentState is VideoPlaybackReady && !currentState.isPlaying && currentState.activeCheckpoint == null) { + if (currentState is VideoPlaybackReady && + !currentState.isPlaying && + currentState.activeCheckpoint == null) { AppLogger.event('VideoResumed', tag: 'VIDEO_CUBIT'); emit(currentState.copyWith(isPlaying: true)); } @@ -230,23 +289,29 @@ class VideoPlaybackCubit extends Cubit { // Find the earliest unpassed checkpoint before or at this target SocraticCheckpointModel? blockingCheckpoint; for (var cp in currentState.playbackData.checkpoints) { - if (!currentState.passedCheckpointIds.contains(cp.id) && seconds > cp.timestampSeconds) { - if (blockingCheckpoint == null || cp.timestampSeconds < blockingCheckpoint.timestampSeconds) { + if (!currentState.passedCheckpointIds.contains(cp.id) && + seconds > cp.timestampSeconds) { + if (blockingCheckpoint == null || + cp.timestampSeconds < blockingCheckpoint.timestampSeconds) { blockingCheckpoint = cp; } } } // If user tries to skip past an unpassed checkpoint, snap precisely to that checkpoint - final actualSeek = blockingCheckpoint != null ? blockingCheckpoint.timestampSeconds : seconds; + final actualSeek = blockingCheckpoint != null + ? blockingCheckpoint.timestampSeconds + : seconds; _lastObservedPosition = actualSeek; - AppLogger.event('VideoSeekExecuted', details: { - 'targetSeconds': seconds, - 'actualSeek': actualSeek, - 'blockedByCheckpoint': blockingCheckpoint != null, - 'checkpointQuestion': blockingCheckpoint?.questionText, - }, tag: 'VIDEO_CUBIT'); + AppLogger.event('VideoSeekExecuted', + details: { + 'targetSeconds': seconds, + 'actualSeek': actualSeek, + 'blockedByCheckpoint': blockingCheckpoint != null, + 'checkpointQuestion': blockingCheckpoint?.questionText, + }, + tag: 'VIDEO_CUBIT'); emit(currentState.copyWith( currentPositionSeconds: actualSeek, @@ -256,11 +321,13 @@ class VideoPlaybackCubit extends Cubit { } } - Future saveProgress({required int positionSeconds, required int watchedSeconds}) async { + Future saveProgress( + {required int positionSeconds, required int watchedSeconds}) async { final currentState = state; if (currentState is! VideoPlaybackReady) return; try { - final storageKey = _getLessonStorageKey(currentState.lessonItem, fallbackId: '${currentState.playbackData.lessonId}'); + final storageKey = _getLessonStorageKey(currentState.lessonItem, + fallbackId: '${currentState.playbackData.lessonId}'); final prefs = await SharedPreferences.getInstance(); await prefs.setInt('saved_video_pos_$storageKey', positionSeconds); if (currentState.passedCheckpointIds.isNotEmpty) { @@ -270,11 +337,17 @@ class VideoPlaybackCubit extends Cubit { ); } 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); + 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'); @@ -284,24 +357,30 @@ class VideoPlaybackCubit extends Cubit { /// Submit Socratic Checkpoint Answer bool submitCheckpointAnswer(SocraticOptionModel selectedOption) { final currentState = state; - if (currentState is VideoPlaybackReady && currentState.activeCheckpoint != null) { + if (currentState is VideoPlaybackReady && + currentState.activeCheckpoint != null) { final cp = currentState.activeCheckpoint!; - + if (selectedOption.isCorrect) { // Correct Answer -> Reward readiness score (+0.5%) & resume video - AppLogger.event('SocraticAnswerCorrect', details: { - 'checkpointId': cp.id, - 'question': cp.questionText, - 'selected': selectedOption.text, - 'bonus': 0.5, - }, tag: 'SOCRATIC_ENGINE'); - final updatedPassed = Set.from(currentState.passedCheckpointIds)..add(cp.id); + AppLogger.event('SocraticAnswerCorrect', + details: { + 'checkpointId': cp.id, + 'question': cp.questionText, + 'selected': selectedOption.text, + 'bonus': 0.5, + }, + tag: 'SOCRATIC_ENGINE'); + final updatedPassed = Set.from(currentState.passedCheckpointIds) + ..add(cp.id); _lastObservedPosition = cp.timestampSeconds; // Persist passed checkpoint for this specific video - final storageKey = _getLessonStorageKey(currentState.lessonItem, fallbackId: '${currentState.playbackData.lessonId}'); + final storageKey = _getLessonStorageKey(currentState.lessonItem, + fallbackId: '${currentState.playbackData.lessonId}'); SharedPreferences.getInstance().then((prefs) { - prefs.setStringList('passed_checkpoints_$storageKey', updatedPassed.map((id) => id.toString()).toList()); + prefs.setStringList('passed_checkpoints_$storageKey', + updatedPassed.map((id) => id.toString()).toList()); }).catchError((_) {}); emit(currentState.copyWith( @@ -309,36 +388,47 @@ class VideoPlaybackCubit extends Cubit { isPlaying: true, readinessBonusAdded: currentState.readinessBonusAdded + 0.5, passedCheckpointIds: updatedPassed, - remediationNotice: 'إجابة نموذجية ممتازة! تم تعزيز مؤشر الجاهزية (+0.5%) 🚀', + remediationNotice: + 'إجابة نموذجية ممتازة! تم تعزيز مؤشر الجاهزية (+0.5%) 🚀', )); if (cp.id > 0) { - _repo.submitCheckpoint(examId: cp.id, questionId: cp.questionId, optionId: selectedOption.id).catchError((e) { + _repo + .submitCheckpoint( + examId: cp.id, + questionId: cp.questionId, + optionId: selectedOption.id) + .catchError((e) { AppLogger.log('Checkpoint sync deferred: $e', tag: 'VIDEO_CUBIT'); }); } return true; } else { // Wrong Answer -> Socratic Productive Struggle: Rewind video by N seconds from checkpoint - final rewindTo = (cp.timestampSeconds - cp.rewindSecondsOnFail).clamp(0, currentState.playbackData.durationSeconds); + final rewindTo = (cp.timestampSeconds - cp.rewindSecondsOnFail) + .clamp(0, currentState.playbackData.durationSeconds); _lastObservedPosition = rewindTo; - AppLogger.event('SocraticAnswerIncorrect', details: { - 'checkpointId': cp.id, - 'question': cp.questionText, - 'selected': selectedOption.text, - 'rewindSeconds': cp.rewindSecondsOnFail, - 'rewindTo': rewindTo, - }, tag: 'SOCRATIC_ENGINE'); + AppLogger.event('SocraticAnswerIncorrect', + details: { + 'checkpointId': cp.id, + 'question': cp.questionText, + 'selected': selectedOption.text, + 'rewindSeconds': cp.rewindSecondsOnFail, + 'rewindTo': rewindTo, + }, + tag: 'SOCRATIC_ENGINE'); emit(currentState.copyWith( clearActiveCheckpoint: true, currentPositionSeconds: rewindTo, isPlaying: true, - remediationNotice: 'تعثرت في هذا المفهوم. تم إرجاع الفيديو ${cp.rewindSecondsOnFail} ثانية لإعادة الاستماع بتركيز 🔄', + remediationNotice: + 'تعثرت في هذا المفهوم. تم إرجاع الفيديو ${cp.rewindSecondsOnFail} ثانية لإعادة الاستماع بتركيز 🔄', )); // Auto-record gap to Smart Error Notebook final correctOpt = cp.options.firstWhere( (o) => o.isCorrect, - orElse: () => const SocraticOptionModel(id: 0, text: '', isCorrect: false), + orElse: () => + const SocraticOptionModel(id: 0, text: '', isCorrect: false), ); ErrorNotebookRepository().logError( subjectId: currentState.subject?.id ?? 'physics_10', @@ -350,12 +440,19 @@ class VideoPlaybackCubit extends Cubit { correctAnswer: correctOpt.text, hint: cp.hint, errorCategory: 'conceptual', - lessonId: currentState.playbackData.lessonId > 0 ? currentState.playbackData.lessonId : null, + lessonId: currentState.playbackData.lessonId > 0 + ? currentState.playbackData.lessonId + : null, options: cp.options.map((o) => o.text).toList(), ); if (cp.id > 0 && cp.questionId > 0) { - _repo.submitCheckpoint(examId: cp.id, questionId: cp.questionId, optionId: selectedOption.id).catchError((e) { + _repo + .submitCheckpoint( + examId: cp.id, + questionId: cp.questionId, + optionId: selectedOption.id) + .catchError((e) { AppLogger.log('Checkpoint sync deferred: $e', tag: 'VIDEO_CUBIT'); }); } diff --git a/apps/student_app/lib/presentation/screens/curriculum/curriculum_document_viewer_screen.dart b/apps/student_app/lib/presentation/screens/curriculum/curriculum_document_viewer_screen.dart index c5429a8..6520c4a 100644 --- a/apps/student_app/lib/presentation/screens/curriculum/curriculum_document_viewer_screen.dart +++ b/apps/student_app/lib/presentation/screens/curriculum/curriculum_document_viewer_screen.dart @@ -8,7 +8,6 @@ import '../../widgets/luxury_widgets.dart'; import '../../widgets/interactive_english_passage_view.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../../core/config/app_config.dart'; -import '../../../core/services/storage_service.dart'; import 'physics_interactive_lab_view.dart'; import 'math_interactive_lab_view.dart'; import 'english_interactive_lab_view.dart'; @@ -16,7 +15,7 @@ import 'english_interactive_lab_view.dart'; /// ============================================================================== /// SAQEL ENTERPRISE (EDTECH 2.0) - CURRICULUM DOCUMENT & TEXTBOOK VIEWER /// ============================================================================== -/// +/// /// ملف: curriculum_document_viewer_screen.dart /// الهدف المعماري: /// استعراض الكتب المدرسية المقررة والمذكرات الوزارية وأوراق العمل محلياً وسحابياً: @@ -51,10 +50,12 @@ class CurriculumDocumentViewerScreen extends StatefulWidget { }); @override - State createState() => _CurriculumDocumentViewerScreenState(); + State createState() => + _CurriculumDocumentViewerScreenState(); } -class _CurriculumDocumentViewerScreenState extends State { +class _CurriculumDocumentViewerScreenState + extends State { final ApiClient _api = ApiClient(); double _fontSize = 14.5; bool _isLoading = true; @@ -63,6 +64,7 @@ class _CurriculumDocumentViewerScreenState extends State _sections = []; String? _loadError; + String? _pdfOpenError; bool get _isEnglish => widget.subjectTitle.contains('إنجليز') || @@ -78,7 +80,9 @@ class _CurriculumDocumentViewerScreenState extends State widget.subjectId ?? (_isEnglish ? 'english_10' : (_isPhysics ? 'physics_10' : 'math_10')); + String get _effectiveSubjectId => + widget.subjectId ?? + (_isEnglish ? 'english_10' : (_isPhysics ? 'physics_10' : 'math_10')); String get _effectiveFilePath => widget.assetId ?? ''; @override @@ -96,6 +100,15 @@ class _CurriculumDocumentViewerScreenState extends State _isLoading = true); + if (!_isAssetUuid(widget.assetId)) { + if (mounted) + setState(() { + _loadError = 'المورد لا يحمل معرّف أصل منشوراً صالحاً.'; + _isLoading = false; + }); + return; + } + // 1. Check Offline Local Storage First (Zero Network Overhead) final savedLocally = await LocalDocumentStorageService.getSavedDocument( subjectId: _effectiveSubjectId, @@ -117,38 +130,8 @@ class _CurriculumDocumentViewerScreenState extends State _isLoading = false); - return; - } - } catch (_) {} - - try { - final res = await _api.get('/api/curriculum/lesson', queryParams: {'file': widget.assetId!}).timeout(const Duration(seconds: 15)); - if (res is Map && res['content'] != null && res['content'].toString().trim().isNotEmpty) { - _processContent(res['content'].toString()); - if (mounted) setState(() => _isLoading = false); - return; - } - } catch (_) {} - } - - if (widget.mimeType != null && !widget.mimeType!.toLowerCase().startsWith('text/')) { + if (widget.mimeType != null && + !widget.mimeType!.toLowerCase().startsWith('text/')) { if (mounted) { setState(() { _isPdfOrBinary = true; @@ -160,9 +143,13 @@ class _CurriculumDocumentViewerScreenState extends State _isLoading = false); return; @@ -171,12 +158,18 @@ class _CurriculumDocumentViewerScreenState extends State + value != null && + RegExp(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$') + .hasMatch(value); + Future _toggleSaveLocal() async { if (_isSavedLocally) { await LocalDocumentStorageService.removeSavedDocument( @@ -186,7 +179,8 @@ class _CurriculumDocumentViewerScreenState extends State _isSavedLocally = false); if (mounted) { - SaqelToast.showInfo(context, 'تمت إزالة المستند من الذاكرة المحلية', title: 'إلغاء الحفظ'); + SaqelToast.showInfo(context, 'تمت إزالة المستند من الذاكرة المحلية', + title: 'إلغاء الحفظ'); } } else { await LocalDocumentStorageService.saveDocumentLocally( @@ -198,7 +192,9 @@ class _CurriculumDocumentViewerScreenState extends State _isSavedLocally = true); if (mounted) { - SaqelToast.showSuccess(context, 'تم حفظ المستند في الجهاز — متاح الآن دون إنترنت 📖', title: 'حفظ محلي مكتمل'); + SaqelToast.showSuccess( + context, 'تم حفظ المستند في الجهاز — متاح الآن دون إنترنت 📖', + title: 'حفظ محلي مكتمل'); } } } @@ -218,7 +214,8 @@ class _CurriculumDocumentViewerScreenState extends State[]; final regex = RegExp(r'\*\*(.*?)\*\*'); @@ -380,10 +391,12 @@ class _CurriculumDocumentViewerScreenState extends State _showGrammarMindMapModal(context), ), // Offline Save / Download Button - IconButton( - icon: Icon( - _isSavedLocally ? CupertinoIcons.checkmark_seal_fill : CupertinoIcons.cloud_download, - color: _isSavedLocally ? AppColors.emeraldGreen : Colors.white70, - size: 20, + if (!_isPdfOrBinary) + IconButton( + icon: Icon( + _isSavedLocally + ? CupertinoIcons.checkmark_seal_fill + : CupertinoIcons.cloud_download, + color: + _isSavedLocally ? AppColors.emeraldGreen : Colors.white70, + size: 20, + ), + tooltip: _isSavedLocally + ? 'محفوظ في الجهاز (دون إنترنت)' + : 'حفظ في الجهاز للقراءة دون إنترنت', + onPressed: _toggleSaveLocal, ), - tooltip: _isSavedLocally ? 'محفوظ في الجهاز (دون إنترنت)' : 'حفظ في الجهاز للقراءة دون إنترنت', - onPressed: _toggleSaveLocal, - ), // Font Resizing - IconButton( - icon: const Icon(CupertinoIcons.textformat_size, color: Colors.white70, size: 20), - onPressed: () { - setState(() { - _fontSize = _fontSize >= 18.0 ? 13.0 : _fontSize + 1.5; - }); - SaqelToast.showInfo(context, 'تم ضبط حجم الخط (${_fontSize.toInt()}pt)', title: 'حجم الخط'); - }, - ), - // Share - IconButton( - icon: const Icon(CupertinoIcons.share, color: Colors.white70, size: 20), - onPressed: () { - SaqelToast.showSuccess(context, 'تم نسخ رابط المستند لمشاركته مع الزملاء', title: 'مشاركة الوثيقة 🔗'); - }, - ), + if (!_isPdfOrBinary) + IconButton( + icon: const Icon(CupertinoIcons.textformat_size, + color: Colors.white70, size: 20), + onPressed: () { + setState(() { + _fontSize = _fontSize >= 18.0 ? 13.0 : _fontSize + 1.5; + }); + SaqelToast.showInfo( + context, 'تم ضبط حجم الخط (${_fontSize.toInt()}pt)', + title: 'حجم الخط'); + }, + ), ], ), body: Directionality( textDirection: _isEnglish ? TextDirection.ltr : TextDirection.rtl, child: _isLoading - ? const Center(child: CupertinoActivityIndicator(color: AppColors.saqelCyan)) + ? const Center( + child: CupertinoActivityIndicator(color: AppColors.saqelCyan)) : _loadError != null ? Center( child: Padding( padding: const EdgeInsets.all(24), - child: Text(_loadError!, textAlign: TextAlign.center, - style: const TextStyle(color: AppColors.textSecondaryDark)), + child: Text(_loadError!, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.textSecondaryDark)), ), ) : _isPdfOrBinary ? _buildPdfOrBinaryView() : SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // English Smart Audio Companion & Interactive Hints - if (_isEnglish) ...[ - Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - decoration: BoxDecoration( - color: const Color(0xFF0C192E), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.saqelCyan.withAlpha(70)), - ), - child: Row( + padding: const EdgeInsets.symmetric( + horizontal: 18, vertical: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 38, - height: 38, - decoration: BoxDecoration( - color: AppColors.saqelCyan.withAlpha(25), - borderRadius: BorderRadius.circular(10), + // English Smart Audio Companion & Interactive Hints + if (_isEnglish) ...[ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: const Color(0xFF0C192E), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppColors.saqelCyan.withAlpha(70)), + ), + child: Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: + AppColors.saqelCyan.withAlpha(25), + borderRadius: BorderRadius.circular(10), + ), + child: const Icon( + CupertinoIcons.headphones, + color: AppColors.saqelCyan, + size: 20), + ), + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + 'القراءة التفاعلية ونطق الفقرات 🎧', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 13), + ), + SizedBox(height: 2), + Text( + 'انقر على أي كلمة لعرض ترجمتها الفورية ونطقها الفونيتي، أو اضغط زر القراءة بجانب كل فقرة.', + style: TextStyle( + color: + AppColors.textSecondaryDark, + fontSize: 11.5), + ), + ], + ), + ), + ], + ), ), - child: const Icon(CupertinoIcons.headphones, color: AppColors.saqelCyan, size: 20), - ), - const SizedBox(width: 12), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'القراءة التفاعلية ونطق الفقرات 🎧', - style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 13), + const SizedBox(height: 16), + ], + + // Direct Math GeoGebra Lab Cross-Link + if (_isMath) ...[ + GestureDetector( + onTap: () { + Navigator.of(context).push( + CupertinoPageRoute( + builder: (_) => Scaffold( + backgroundColor: + AppColors.darkBackground, + appBar: AppBar( + backgroundColor: + AppColors.darkSurface, + title: const Text( + 'معمل برمجية جيوجبرا 📐', + style: TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.bold)), + centerTitle: true, + ), + body: const MathInteractiveLabView(), + ), + ), + ); + }, + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [ + Color(0xFF0F2027), + Color(0xFF203A43), + Color(0xFF2C5364) + ], + ), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: + AppColors.saqelCyan.withAlpha(80)), + boxShadow: [ + BoxShadow( + color: + AppColors.saqelCyan.withAlpha(40), + blurRadius: 15, + offset: const Offset(0, 4), + ), + ], ), - SizedBox(height: 2), - Text( - 'انقر على أي كلمة لعرض ترجمتها الفورية ونطقها الفونيتي، أو اضغط زر القراءة بجانب كل فقرة.', - style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 11.5), + child: const Row( + children: [ + Icon(CupertinoIcons.function, + color: AppColors.saqelCyan, size: 26), + SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + 'معمل برمجية جيوجبرا التفاعلي (GeoGebra Lab) 📐', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, + fontSize: 14), + ), + SizedBox(height: 2), + Text( + 'حل أنظمة المعادلات بيانياً وكشف نقاط التقاطع وحساب المميز (تطبيق نشاط ص 16-17)', + style: TextStyle( + color: Colors.white70, + fontSize: 11.5), + ), + ], + ), + ), + Icon(CupertinoIcons.arrow_left, + color: AppColors.saqelCyan, size: 16), + ], + ), + ), + ), + const SizedBox(height: 16), + ], + + // Direct English Interactive Lab Cross-Link + if (_isEnglish) ...[ + GestureDetector( + onTap: () { + Navigator.of(context).push( + CupertinoPageRoute( + builder: (_) => Scaffold( + backgroundColor: + AppColors.darkBackground, + appBar: AppBar( + backgroundColor: + AppColors.darkSurface, + title: const Text( + 'مختبر اللغة الإنجليزية (Action Pack 10) 🎧', + style: TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.bold)), + centerTitle: true, + ), + body: const EnglishInteractiveLabView(), + ), + ), + ); + }, + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [ + Color(0xFF141E30), + Color(0xFF243B55) + ], + ), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: + AppColors.appleBlue.withAlpha(90)), + boxShadow: [ + BoxShadow( + color: + AppColors.appleBlue.withAlpha(40), + blurRadius: 15, + offset: const Offset(0, 4), + ), + ], + ), + child: const Row( + children: [ + Icon(CupertinoIcons.headphones, + color: AppColors.appleBlue, size: 26), + SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + 'مختبر الصوتيات والقواعد التفاعلي (English Lab) 🎧', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, + fontSize: 14), + ), + SizedBox(height: 2), + Text( + 'استمع للنطق الصوتي النقي (IPA) وتدرب على محاكي القواعد والأزمنة وبنك المفردات الذكي', + style: TextStyle( + color: Colors.white70, + fontSize: 11.5), + ), + ], + ), + ), + Icon(CupertinoIcons.arrow_left, + color: AppColors.appleBlue, size: 16), + ], + ), + ), + ), + const SizedBox(height: 16), + ], + + // Direct Physics Virtual Lab Cross-Link + if (_isPhysics) ...[ + GestureDetector( + onTap: () { + Navigator.of(context).push( + CupertinoPageRoute( + builder: (_) => Scaffold( + backgroundColor: + AppColors.darkBackground, + appBar: AppBar( + backgroundColor: + AppColors.darkSurface, + title: const Text( + 'المختبر التفاعلي 🧪', + style: TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.bold)), + centerTitle: true, + ), + body: const PhysicsInteractiveLabView(), + ), + ), + ); + }, + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [ + Color(0xFF0071E3), + Color(0xFF00F5D4) + ], + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: + AppColors.saqelCyan.withAlpha(40), + blurRadius: 15, + offset: const Offset(0, 4), + ), + ], + ), + child: const Row( + children: [ + Icon(CupertinoIcons.lab_flask_solid, + color: Colors.white, size: 26), + SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + 'المختبر التفاعلي المباشر (Virtual Lab) 🧪', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, + fontSize: 14), + ), + SizedBox(height: 2), + Text( + 'جرب جمع وتحليل المتجهات وتعديل الزوايا عملياً بالرسم البياني', + style: TextStyle( + color: Colors.white70, + fontSize: 11.5), + ), + ], + ), + ), + Icon(CupertinoIcons.chevron_left, + color: Colors.white, size: 18), + ], + ), + ), + ), + const SizedBox(height: 16), + ], + + // Document Header Status Banner with Offline Status + Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 12), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + _isSavedLocally + ? AppColors.emeraldGreen.withAlpha(30) + : AppColors.saqelCyan.withAlpha(25), + AppColors.appleBlue.withAlpha(20), + ], + ), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: _isSavedLocally + ? AppColors.emeraldGreen.withAlpha(60) + : AppColors.saqelCyan.withAlpha(40), + ), + ), + child: Row( + children: [ + Icon( + _isSavedLocally + ? CupertinoIcons + .check_mark_circled_solid + : CupertinoIcons.doc_checkmark_fill, + color: _isSavedLocally + ? AppColors.emeraldGreen + : AppColors.saqelCyan, + size: 22, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + widget.documentType == 'textbook' + ? 'الكتاب المدرسي المعتمد — وزارة التربية والتعليم' + : 'ورقة عمل ومذكرة دراسية معتمدة', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 13), + ), + const Spacer(), + if (_isSavedLocally) + Container( + padding: + const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2), + decoration: BoxDecoration( + color: AppColors.emeraldGreen + .withAlpha(30), + borderRadius: + BorderRadius.circular(6), + ), + child: const Text( + 'محفوظ في الجهاز 💾', + style: TextStyle( + color: AppColors + .emeraldGreen, + fontSize: 10, + fontWeight: + FontWeight.bold), + ), + ), + ], + ), + const SizedBox(height: 3), + Text( + _isSavedLocally + ? 'مخزن في ذاكرة الجهاز — متاح للقراءة في أي وقت بدون إنترنت' + : 'محتوى حي ومحدث مطابق لنتاجات ${widget.subjectTitle}', + style: const TextStyle( + color: + AppColors.textSecondaryDark, + fontSize: 11), + ), + ], + ), ), ], ), ), + const SizedBox(height: 18), + + // Render Dynamic Parsed Sections (or Interactive English Reading Engine) + if (_isEnglish) + InteractiveEnglishPassageView( + markdownContent: _documentContent, + fontSize: _fontSize, + ) + else + ..._sections.map((section) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: LuxuryCard( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 4, + height: 18, + decoration: BoxDecoration( + color: AppColors.saqelCyan, + borderRadius: + BorderRadius.circular(2), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + _cleanFormulas(section.title), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, + fontSize: 15.5, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + ...section.lines.map((line) => + _buildSectionLine(line, _fontSize)), + ], + ), + ), + ); + }), + const SizedBox(height: 30), ], ), ), - const SizedBox(height: 16), - ], - - // Direct Math GeoGebra Lab Cross-Link - if (_isMath) ...[ - GestureDetector( - onTap: () { - Navigator.of(context).push( - CupertinoPageRoute( - builder: (_) => Scaffold( - backgroundColor: AppColors.darkBackground, - appBar: AppBar( - backgroundColor: AppColors.darkSurface, - title: const Text('معمل برمجية جيوجبرا 📐', style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.bold)), - centerTitle: true, - ), - body: const MathInteractiveLabView(), - ), - ), - ); - }, - child: Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFF0F2027), Color(0xFF203A43), Color(0xFF2C5364)], - ), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.saqelCyan.withAlpha(80)), - boxShadow: [ - BoxShadow( - color: AppColors.saqelCyan.withAlpha(40), - blurRadius: 15, - offset: const Offset(0, 4), - ), - ], - ), - child: const Row( - children: [ - Icon(CupertinoIcons.function, color: AppColors.saqelCyan, size: 26), - SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'معمل برمجية جيوجبرا التفاعلي (GeoGebra Lab) 📐', - style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 14), - ), - SizedBox(height: 2), - Text( - 'حل أنظمة المعادلات بيانياً وكشف نقاط التقاطع وحساب المميز (تطبيق نشاط ص 16-17)', - style: TextStyle(color: Colors.white70, fontSize: 11.5), - ), - ], - ), - ), - Icon(CupertinoIcons.arrow_left, color: AppColors.saqelCyan, size: 16), - ], - ), - ), - ), - const SizedBox(height: 16), - ], - - // Direct English Interactive Lab Cross-Link - if (_isEnglish) ...[ - GestureDetector( - onTap: () { - Navigator.of(context).push( - CupertinoPageRoute( - builder: (_) => Scaffold( - backgroundColor: AppColors.darkBackground, - appBar: AppBar( - backgroundColor: AppColors.darkSurface, - title: const Text('مختبر اللغة الإنجليزية (Action Pack 10) 🎧', style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.bold)), - centerTitle: true, - ), - body: const EnglishInteractiveLabView(), - ), - ), - ); - }, - child: Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFF141E30), Color(0xFF243B55)], - ), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.appleBlue.withAlpha(90)), - boxShadow: [ - BoxShadow( - color: AppColors.appleBlue.withAlpha(40), - blurRadius: 15, - offset: const Offset(0, 4), - ), - ], - ), - child: const Row( - children: [ - Icon(CupertinoIcons.headphones, color: AppColors.appleBlue, size: 26), - SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'مختبر الصوتيات والقواعد التفاعلي (English Lab) 🎧', - style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 14), - ), - SizedBox(height: 2), - Text( - 'استمع للنطق الصوتي النقي (IPA) وتدرب على محاكي القواعد والأزمنة وبنك المفردات الذكي', - style: TextStyle(color: Colors.white70, fontSize: 11.5), - ), - ], - ), - ), - Icon(CupertinoIcons.arrow_left, color: AppColors.appleBlue, size: 16), - ], - ), - ), - ), - const SizedBox(height: 16), - ], - - // Direct Physics Virtual Lab Cross-Link - if (_isPhysics) ...[ - GestureDetector( - onTap: () { - Navigator.of(context).push( - CupertinoPageRoute( - builder: (_) => Scaffold( - backgroundColor: AppColors.darkBackground, - appBar: AppBar( - backgroundColor: AppColors.darkSurface, - title: const Text('المختبر التفاعلي 🧪', style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.bold)), - centerTitle: true, - ), - body: const PhysicsInteractiveLabView(), - ), - ), - ); - }, - child: Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFF0071E3), Color(0xFF00F5D4)], - ), - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: AppColors.saqelCyan.withAlpha(40), - blurRadius: 15, - offset: const Offset(0, 4), - ), - ], - ), - child: const Row( - children: [ - Icon(CupertinoIcons.lab_flask_solid, color: Colors.white, size: 26), - SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'المختبر التفاعلي المباشر (Virtual Lab) 🧪', - style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 14), - ), - SizedBox(height: 2), - Text( - 'جرب جمع وتحليل المتجهات وتعديل الزوايا عملياً بالرسم البياني', - style: TextStyle(color: Colors.white70, fontSize: 11.5), - ), - ], - ), - ), - Icon(CupertinoIcons.chevron_left, color: Colors.white, size: 18), - ], - ), - ), - ), - const SizedBox(height: 16), - ], - - // Document Header Status Banner with Offline Status - Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - _isSavedLocally ? AppColors.emeraldGreen.withAlpha(30) : AppColors.saqelCyan.withAlpha(25), - AppColors.appleBlue.withAlpha(20), - ], - ), - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: _isSavedLocally ? AppColors.emeraldGreen.withAlpha(60) : AppColors.saqelCyan.withAlpha(40), - ), - ), - child: Row( - children: [ - Icon( - _isSavedLocally ? CupertinoIcons.check_mark_circled_solid : CupertinoIcons.doc_checkmark_fill, - color: _isSavedLocally ? AppColors.emeraldGreen : AppColors.saqelCyan, - size: 22, - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - widget.documentType == 'textbook' - ? 'الكتاب المدرسي المعتمد — وزارة التربية والتعليم' - : 'ورقة عمل ومذكرة دراسية معتمدة', - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 13), - ), - const Spacer(), - if (_isSavedLocally) - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: AppColors.emeraldGreen.withAlpha(30), - borderRadius: BorderRadius.circular(6), - ), - child: const Text( - 'محفوظ في الجهاز 💾', - style: TextStyle(color: AppColors.emeraldGreen, fontSize: 10, fontWeight: FontWeight.bold), - ), - ), - ], - ), - const SizedBox(height: 3), - Text( - _isSavedLocally - ? 'مخزن في ذاكرة الجهاز — متاح للقراءة في أي وقت بدون إنترنت' - : 'محتوى حي ومحدث مطابق لنتاجات ${widget.subjectTitle}', - style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 11), - ), - ], - ), - ), - ], - ), - ), - const SizedBox(height: 18), - - // Render Dynamic Parsed Sections (or Interactive English Reading Engine) - if (_isEnglish) - InteractiveEnglishPassageView( - markdownContent: _documentContent, - fontSize: _fontSize, - ) - else - ..._sections.map((section) { - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: LuxuryCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 4, - height: 18, - decoration: BoxDecoration( - color: AppColors.saqelCyan, - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - _cleanFormulas(section.title), - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w800, - fontSize: 15.5, - ), - ), - ), - ], - ), - const SizedBox(height: 12), - ...section.lines.map((line) => _buildSectionLine(line, _fontSize)), - ], - ), - ), - ); - }), - const SizedBox(height: 30), - ], - ), - ), ), ); } @@ -891,7 +1028,8 @@ class _CurriculumDocumentViewerScreenState extends State _openOfficialPdf() async { - final token = await StorageService().getToken(); - final base = AppConfig.baseUrl.replaceAll(RegExp(r'/+$'), ''); - String pdfUrl = ''; - - if (widget.assetId != null && widget.assetId!.isNotEmpty) { - if (widget.assetId!.startsWith('http://') || widget.assetId!.startsWith('https://')) { - pdfUrl = widget.assetId!; - } else { - final tokenQuery = token != null && token.isNotEmpty ? '?token=${Uri.encodeComponent(token)}' : ''; - pdfUrl = '$base/api/curriculum/assets/${widget.assetId}$tokenQuery'; - } - } - - if (pdfUrl.isEmpty) { - if (mounted) { - SaqelToast.showError(context, 'رابط ملف الـ PDF غير متاح لهذا المورد.'); - } + if (!_isAssetUuid(widget.assetId)) { + if (mounted) + SaqelToast.showError(context, 'معرّف ملف PDF المنشور غير صالح.'); return; } - + setState(() => _pdfOpenError = null); try { + final grantResponse = await _api.post( + '/api/curriculum/assets/${widget.assetId}/download-ticket', + body: const {}); + final data = grantResponse is Map ? grantResponse['data'] : null; + final ticket = data is Map ? data['ticket']?.toString() : null; + if (ticket == null || ticket.isEmpty) + throw StateError('لم يصدر الخادم رابطاً صالحاً للكتاب.'); + final base = AppConfig.baseUrl.replaceAll(RegExp(r'/+$'), ''); + final pdfUrl = + '$base/api/curriculum/assets/${widget.assetId}/download?ticket=${Uri.encodeComponent(ticket)}'; final uri = Uri.parse(pdfUrl); - final launched = await launchUrl(uri, mode: LaunchMode.externalApplication); + final launched = + await launchUrl(uri, mode: LaunchMode.externalApplication); if (!launched && mounted) { - await launchUrl(uri, mode: LaunchMode.inAppBrowserView); + final browserOpened = + await launchUrl(uri, mode: LaunchMode.inAppBrowserView); + if (!browserOpened) throw StateError('تعذر فتح ملف PDF في المتصفح.'); } } catch (e) { if (mounted) { - SaqelToast.showError(context, 'تعذر فتح المستند: $e'); + setState(() { + _pdfOpenError = e is ApiException && e.statusCode == 404 + ? 'الملف غير متاح لهذا الحساب أو أن النسخة المنشورة غير موجودة.' + : 'تعذر فتح الملف الأصلي حالياً. حاول مجدداً لاحقاً.'; + }); } } } @@ -1115,32 +1308,27 @@ class _CurriculumDocumentViewerScreenState extends State Navigator.of(context).pop(), + icon: const Icon(CupertinoIcons.list_bullet, size: 18), + label: const Text('العودة لدروس المادة'), ), ], ), ), ), - const SizedBox(height: 20), - // Verified Syllabus Index Card - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: const Color(0xFF0E1B2C), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Colors.white.withValues(alpha: 0.08)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon(CupertinoIcons.square_list_fill, color: AppColors.saqelCyan, size: 18), - const SizedBox(width: 10), - Text( - 'فهرس وحدات مقرر ${widget.subjectTitle} (الفصل الدراسي الأول):', - style: const TextStyle(color: Colors.white, fontSize: 13.5, fontWeight: FontWeight.w800), - ), - ], - ), - const SizedBox(height: 14), - _buildSyllabusItem('1', 'الوحدة الأولى: المفاهيم التأسيسية والأنشطة الاستهلالية', 'تشمل النتاجات العامة، تجارب الاستكشاف المخبرية، وحل المشكلات.'), - _buildSyllabusItem('2', 'الوحدة الثانية: المحاور التطبيقية والمسائل النموذجية', 'تشمل النماذج الرياضية، آليات العمل المخبري، وأوراق العمل المعتمدة.'), - _buildSyllabusItem('3', 'الوحدة الثالثة: التقويم الختامي والمشاريع الريادية', 'تشمل مراجعة المفاهيم، بنك الأسئلة الوزارية، واختبار الإتقان.'), - ], - ), - ), ], ), ), ), ); } - - Widget _buildSyllabusItem(String num, String title, String desc) { - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 26, - height: 26, - alignment: Alignment.center, - decoration: BoxDecoration( - color: AppColors.appleBlue.withValues(alpha: 0.25), - borderRadius: BorderRadius.circular(8), - ), - child: Text(num, style: const TextStyle(color: AppColors.saqelCyan, fontWeight: FontWeight.w800, fontSize: 12)), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 13)), - const SizedBox(height: 2), - Text(desc, style: const TextStyle(color: Color(0xFF8CA1BA), fontSize: 11.5, height: 1.4)), - ], - ), - ), - ], - ), - ); - } } class _TenseNode extends StatelessWidget { @@ -1286,7 +1386,8 @@ class _TenseNode extends StatelessWidget { final String formula; final Color color; - const _TenseNode({required this.title, required this.formula, required this.color}); + const _TenseNode( + {required this.title, required this.formula, required this.color}); @override Widget build(BuildContext context) { @@ -1300,10 +1401,16 @@ class _TenseNode extends StatelessWidget { borderRadius: BorderRadius.circular(8), border: Border.all(color: color.withAlpha(120)), ), - child: Text(title, style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w800)), + child: Text(title, + style: TextStyle( + color: color, fontSize: 11, fontWeight: FontWeight.w800)), ), const SizedBox(height: 4), - Text(formula, style: const TextStyle(color: Colors.white70, fontSize: 10, fontFamily: 'SF Pro Text')), + Text(formula, + style: const TextStyle( + color: Colors.white70, + fontSize: 10, + fontFamily: 'SF Pro Text')), ], ); } diff --git a/apps/student_app/lib/presentation/screens/curriculum/subject_hub_screen.dart b/apps/student_app/lib/presentation/screens/curriculum/subject_hub_screen.dart index 21adb23..f81d00a 100644 --- a/apps/student_app/lib/presentation/screens/curriculum/subject_hub_screen.dart +++ b/apps/student_app/lib/presentation/screens/curriculum/subject_hub_screen.dart @@ -1,7 +1,7 @@ /// ============================================================================== /// SAQEL ENTERPRISE (EDTECH 2.0) - SUBJECT HUB & MULTI-TEACHER CURRICULUM SCREEN /// ============================================================================== -/// +/// /// ملف: subject_hub_screen.dart /// الهدف المعماري: /// المركز الشامل للمادة الدراسية (Subject Hub): @@ -101,8 +101,10 @@ class _SubjectHubScreenState extends State bool get _isHistorySubject => widget.subject.id.contains('hist') || - (widget.subject.title.contains('تاريخ') && !widget.subject.title.contains('جغرافيا')) || - (widget.subject.title.contains('أردن') && !widget.subject.title.contains('جغرافيا')); + (widget.subject.title.contains('تاريخ') && + !widget.subject.title.contains('جغرافيا')) || + (widget.subject.title.contains('أردن') && + !widget.subject.title.contains('جغرافيا')); bool get _isDigitalSkillsSubject => widget.subject.id.contains('digital') || @@ -111,7 +113,8 @@ class _SubjectHubScreenState extends State widget.subject.title.contains('حاسوب'); bool get _hasLab => - Grade10LabsRegistry.bySubjectNormalized(widget.subject.title).isNotEmpty || + Grade10LabsRegistry.bySubjectNormalized(widget.subject.title) + .isNotEmpty || _isMathSubject || _isEnglishSubject || _isChemistrySubject || @@ -145,7 +148,9 @@ class _SubjectHubScreenState extends State icon: Icon(CupertinoIcons.money_dollar_circle_fill, size: 18), text: 'معمل المعاملات والتجويد ⚖️', ); - } else if (_isHistorySubject || norm == 'تاريخ الأردن' || norm == 'التاريخ') { + } else if (_isHistorySubject || + norm == 'تاريخ الأردن' || + norm == 'التاريخ') { return const Tab( icon: Icon(CupertinoIcons.time_solid, size: 18), text: 'مختبر التاريخ والخط الزمني 🏛️', @@ -215,7 +220,9 @@ class _SubjectHubScreenState extends State } else if (_isIslamicSubject || norm == 'التربية الإسلامية') { specializedTool = const IslamicInteractiveLabView(); specializedTitle = 'معمل المعاملات والتجويد ⚖️'; - } else if (_isHistorySubject || norm == 'تاريخ الأردن' || norm == 'التاريخ') { + } else if (_isHistorySubject || + norm == 'تاريخ الأردن' || + norm == 'التاريخ') { specializedTool = const HistoryInteractiveTimelineView(); specializedTitle = 'الخط الزمني الشامل والتاريخ 🏛️'; } else if (_isDigitalSkillsSubject || norm == 'المهارات الرقمية') { @@ -248,7 +255,8 @@ class _SubjectHubScreenState extends State void initState() { super.initState(); _tabController = TabController(length: _hasLab ? 5 : 4, vsync: this); - _examsFuture = ExamRepository().getExams(subjectCode: widget.subject.id, scope: 'unit_exam'); + _examsFuture = ExamRepository() + .getExams(subjectCode: widget.subject.id, scope: 'unit_exam'); } @override @@ -270,7 +278,8 @@ class _SubjectHubScreenState extends State ), title: Text( widget.subject.title, - style: AppTypography.titleLarge.copyWith(color: Colors.white, fontSize: 18), + style: AppTypography.titleLarge + .copyWith(color: Colors.white, fontSize: 18), ), actions: [ IconButton( @@ -284,8 +293,8 @@ class _SubjectHubScreenState extends State backgroundColor: AppColors.darkBackground, body: SafeArea( child: VirtualLabsGalleryScreen( - initialSubject: - Grade10LabsRegistry.normalizeSubject(widget.subject.title), + initialSubject: Grade10LabsRegistry.normalizeSubject( + widget.subject.title), ), ), ), @@ -301,13 +310,22 @@ class _SubjectHubScreenState extends State labelColor: Colors.white, unselectedLabelColor: AppColors.textSecondaryDark, isScrollable: _hasLab, - labelStyle: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13), + labelStyle: + const TextStyle(fontWeight: FontWeight.w700, fontSize: 13), tabs: [ - const Tab(icon: Icon(CupertinoIcons.play_rectangle_fill, size: 18), text: 'الفيديوهات'), + const Tab( + icon: Icon(CupertinoIcons.play_rectangle_fill, size: 18), + text: 'الفيديوهات'), if (_hasLab) _dynamicLabTab, - const Tab(icon: Icon(CupertinoIcons.sparkles, size: 18), text: 'بنك الأسئلة'), - const Tab(icon: Icon(CupertinoIcons.doc_text_fill, size: 18), text: 'أوراق العمل'), - const Tab(icon: Icon(CupertinoIcons.book_fill, size: 18), text: 'الكتب المقررة'), + const Tab( + icon: Icon(CupertinoIcons.sparkles, size: 18), + text: 'بنك الأسئلة'), + const Tab( + icon: Icon(CupertinoIcons.doc_text_fill, size: 18), + text: 'أوراق العمل'), + const Tab( + icon: Icon(CupertinoIcons.book_fill, size: 18), + text: 'الكتب المقررة'), ], ), ), @@ -380,17 +398,21 @@ class _SubjectHubScreenState extends State duration: const Duration(milliseconds: 250), padding: const EdgeInsets.symmetric(vertical: 9, horizontal: 12), decoration: BoxDecoration( - color: isSelected ? color.withValues(alpha: 0.18) : Colors.transparent, + color: + isSelected ? color.withValues(alpha: 0.18) : Colors.transparent, borderRadius: BorderRadius.circular(10), border: Border.all( - color: isSelected ? color.withValues(alpha: 0.6) : Colors.transparent, + color: + isSelected ? color.withValues(alpha: 0.6) : Colors.transparent, ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( - isSelected ? CupertinoIcons.checkmark_seal_fill : CupertinoIcons.calendar, + isSelected + ? CupertinoIcons.checkmark_seal_fill + : CupertinoIcons.calendar, size: 14, color: isSelected ? color : AppColors.textSecondaryDark, ), @@ -406,9 +428,12 @@ class _SubjectHubScreenState extends State if (count > 0) ...[ const SizedBox(width: 6), Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5), + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5), decoration: BoxDecoration( - color: isSelected ? color.withValues(alpha: 0.3) : AppColors.darkCardBorder, + color: isSelected + ? color.withValues(alpha: 0.3) + : AppColors.darkCardBorder, borderRadius: BorderRadius.circular(10), ), child: Text( @@ -429,7 +454,8 @@ class _SubjectHubScreenState extends State /// Tab 1: Interactive Video Lessons by Unit Widget _buildLessonsTab(BuildContext context) { - final hasMultipleSemesters = widget.subject.semester1Units.isNotEmpty && widget.subject.semester2Units.isNotEmpty; + final hasMultipleSemesters = widget.subject.semester1Units.isNotEmpty && + widget.subject.semester2Units.isNotEmpty; final units = hasMultipleSemesters ? widget.subject.unitsForSemester(_selectedSemester) : widget.subject.units; @@ -439,11 +465,13 @@ class _SubjectHubScreenState extends State child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(CupertinoIcons.play_circle, size: 56, color: widget.subject.primaryColor.withAlpha(120)), + Icon(CupertinoIcons.play_circle, + size: 56, color: widget.subject.primaryColor.withAlpha(120)), const SizedBox(height: 16), Text( 'جاري تحضير دروس ${widget.subject.title} بواسطة الذكاء الاصطناعي', - style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 14), + style: const TextStyle( + color: AppColors.textSecondaryDark, fontSize: 14), ), ], ), @@ -460,320 +488,389 @@ class _SubjectHubScreenState extends State itemBuilder: (context, uIdx) { final unit = units[uIdx]; - return Container( - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - color: AppColors.darkSurface, - borderRadius: BorderRadius.circular(20), - border: Border.all(color: AppColors.darkCardBorder), - ), - child: ExpansionTile( - initiallyExpanded: uIdx == 0, - shape: const Border(), - collapsedShape: const Border(), - leading: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: widget.subject.primaryColor.withAlpha(30), - shape: BoxShape.circle, - ), - child: Center( - child: Text( - '${uIdx + 1}', - style: TextStyle( - color: widget.subject.primaryColor, - fontWeight: FontWeight.w800, - fontSize: 15, - ), - ), - ), - ), - title: Text( - unit.name, - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 15), - ), - subtitle: Text( - '${unit.lessons.length} دروس تفاعلية • فحص فهم مستمر', - style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 12), - ), - children: unit.lessons.map((lesson) { - final hasVid = lesson.hasVideo; - final lab = Grade10LabsRegistry.findForLesson( - subjectTitle: widget.subject.title, - lessonTitle: lesson.title, - lessonId: lesson.id, - curriculumLessonId: lesson.curriculumLessonId, - filePath: lesson.markdownFilePath, - unitKey: unit.id, - semesterKey: _selectedSemester, - ); return Container( - margin: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + margin: const EdgeInsets.only(bottom: 20), decoration: BoxDecoration( - color: const Color(0xFF09111E), - borderRadius: BorderRadius.circular(14), - border: Border.all( - color: hasVid - ? AppColors.darkCardBorder.withAlpha(80) - : Colors.white.withAlpha(15), - ), + color: AppColors.darkSurface, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppColors.darkCardBorder), ), - child: Column( - children: [ - ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4), - leading: Container( - width: 38, - height: 38, - decoration: BoxDecoration( - gradient: hasVid - ? LinearGradient( - colors: [widget.subject.primaryColor, widget.subject.secondaryColor], - ) - : null, - color: hasVid ? null : Colors.white.withAlpha(10), - borderRadius: BorderRadius.circular(10), - border: hasVid ? null : Border.all(color: Colors.white12), - ), - child: Icon( - hasVid ? CupertinoIcons.play_fill : CupertinoIcons.film, - color: hasVid ? Colors.black : AppColors.textSecondaryDark, - size: 18, - ), - ), - title: Text( - lesson.title, + child: ExpansionTile( + initiallyExpanded: uIdx == 0, + shape: const Border(), + collapsedShape: const Border(), + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: widget.subject.primaryColor.withAlpha(30), + shape: BoxShape.circle, + ), + child: Center( + child: Text( + '${uIdx + 1}', style: TextStyle( - color: hasVid ? Colors.white : Colors.white70, - fontWeight: FontWeight.w600, - fontSize: 13, + color: widget.subject.primaryColor, + fontWeight: FontWeight.w800, + fontSize: 15, ), ), - subtitle: Row( + ), + ), + title: Text( + unit.name, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 15), + ), + subtitle: Text( + '${unit.lessons.length} دروس تفاعلية • فحص فهم مستمر', + style: const TextStyle( + color: AppColors.textSecondaryDark, fontSize: 12), + ), + children: unit.lessons.map((lesson) { + final hasVid = lesson.hasVideo; + final canCheckVideos = + lesson.curriculumLessonId?.isNotEmpty == true; + final lab = lesson.curriculumLessonId == null + ? null + : Grade10LabsRegistry + .publishedEntryForCurriculumLessonId( + lesson.curriculumLessonId!); + return Container( + margin: const EdgeInsets.symmetric( + horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFF09111E), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: hasVid + ? AppColors.darkCardBorder.withAlpha(80) + : Colors.white.withAlpha(15), + ), + ), + child: Column( children: [ - Text( - '${lesson.durationSeconds ~/ 60} دقيقة', - style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 11), - ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: hasVid - ? AppColors.saqelCyan.withAlpha(25) - : Colors.white.withAlpha(12), - borderRadius: BorderRadius.circular(6), + ListTile( + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 4), + leading: Container( + width: 38, + height: 38, + decoration: BoxDecoration( + gradient: hasVid + ? LinearGradient( + colors: [ + widget.subject.primaryColor, + widget.subject.secondaryColor + ], + ) + : null, + color: + hasVid ? null : Colors.white.withAlpha(10), + borderRadius: BorderRadius.circular(10), + border: hasVid + ? null + : Border.all(color: Colors.white12), + ), + child: Icon( + hasVid + ? CupertinoIcons.play_fill + : CupertinoIcons.film, + color: hasVid + ? Colors.black + : AppColors.textSecondaryDark, + size: 18, + ), ), - child: Text( - hasVid ? '${lesson.checkpointsCount} فحوصات' : 'قريباً', + title: Text( + lesson.title, style: TextStyle( - color: hasVid ? AppColors.saqelCyan : AppColors.textSecondaryDark, - fontSize: 10, - fontWeight: FontWeight.w700, + color: hasVid ? Colors.white : Colors.white70, + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + subtitle: Row( + children: [ + Text( + lesson.durationSeconds > 0 + ? '${(lesson.durationSeconds / 60).ceil()} دقيقة' + : 'المدة غير متاحة', + style: const TextStyle( + color: AppColors.textSecondaryDark, + fontSize: 11), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: hasVid + ? AppColors.saqelCyan.withAlpha(25) + : Colors.white.withAlpha(12), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + hasVid + ? (lesson.checkpointsCount > 0 + ? '${lesson.checkpointsCount} فحوصات' + : 'عرض الحصة') + : canCheckVideos + ? 'تحقق من الحصص' + : 'قريباً', + style: TextStyle( + color: hasVid + ? AppColors.saqelCyan + : AppColors.textSecondaryDark, + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + trailing: Icon( + canCheckVideos + ? CupertinoIcons.chevron_back + : CupertinoIcons.clock, + color: canCheckVideos + ? AppColors.textSecondaryDark + : Colors.white24, + size: 16, + ), + onTap: () { + AppLogger.event('LessonTileTapped', + details: { + 'subject': widget.subject.title, + 'lessonTitle': lesson.title, + 'lessonId': lesson.id, + 'hasVideo': hasVid, + 'curriculumLessonId': + lesson.curriculumLessonId, + }, + tag: 'SUBJECT_HUB'); + _showLessonVideoSelector(context, lesson); + }, + ), + if (lab != null) + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 6), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () { + AppLogger.event('OpenLessonLab', + details: { + 'subject': widget.subject.title, + 'lesson': lesson.title, + 'lab': lab.lessonAr, + 'curriculumId': + lab.identity.curriculumLessonId, + }, + tag: 'SUBJECT_HUB'); + Grade10LabsRegistry.openLab(context, lab); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 7), + decoration: BoxDecoration( + color: AppColors.saqelCyan + .withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.saqelCyan + .withValues(alpha: 0.25)), + ), + child: Row( + children: [ + const Icon(CupertinoIcons.lab_flask_solid, + color: AppColors.saqelCyan, size: 14), + const SizedBox(width: 6), + Expanded( + child: Row( + children: [ + Flexible( + child: Text( + 'المختبر: ${lab.lessonAr}', + style: const TextStyle( + color: Colors.white, + fontSize: 11.5, + fontWeight: FontWeight.w700, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 6), + Container( + padding: + const EdgeInsets.symmetric( + horizontal: 5, + vertical: 1.5), + decoration: BoxDecoration( + color: lab.identity.isPublished + ? AppColors.teacherEmerald + .withAlpha(35) + : AppColors.saqelCyan + .withAlpha(30), + borderRadius: + BorderRadius.circular(4), + ), + child: Text( + lab.identity.isPublished + ? 'معتمد 🟢' + : (lab.identity.isBound + ? 'جاهز للاستخدام 🟢' + : 'معاينة 🔵'), + style: TextStyle( + color: lab + .identity.isPublished + ? AppColors.teacherEmerald + : AppColors.saqelCyan, + fontSize: 9.5, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppColors.saqelCyan + .withValues(alpha: 0.2), + borderRadius: + BorderRadius.circular(5), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'بدء التجربة', + style: TextStyle( + color: AppColors.saqelCyan, + fontSize: 10.5, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(width: 3), + Icon(CupertinoIcons.arrow_left, + color: AppColors.saqelCyan, + size: 10), + ], + ), + ), + ], + ), + ), + ), + ) + else + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 6), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.white.withAlpha(6), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.white.withAlpha(12)), + ), + child: const Row( + children: [ + Icon(CupertinoIcons.lab_flask, + color: Colors.white38, size: 13), + SizedBox(width: 6), + Expanded( + child: Text( + 'المختبر الافتراضي: قيد الإعداد الأكاديمي ⚪', + style: TextStyle( + color: Colors.white54, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () { + LessonHomeworkSheet.show( + context, + subjectId: widget.subject.id, + subjectTitle: widget.subject.title, + lessonId: lesson.id, + lessonTitle: lesson.title, + ); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 7), + decoration: BoxDecoration( + color: AppColors.appleBlue + .withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.appleBlue + .withValues(alpha: 0.25)), + ), + child: const Row( + children: [ + Icon( + CupertinoIcons + .pencil_ellipsis_rectangle, + color: AppColors.appleBlue, + size: 14), + SizedBox(width: 6), + Expanded( + child: Text( + 'واجب الدرس وتمارين الكتاب 📝', + style: TextStyle( + color: Colors.white, + fontSize: 11.5, + fontWeight: FontWeight.w700, + ), + overflow: TextOverflow.ellipsis, + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'حل التمارين', + style: TextStyle( + color: AppColors.saqelCyan, + fontSize: 10.5, + fontWeight: FontWeight.w800, + ), + ), + SizedBox(width: 3), + Icon(CupertinoIcons.arrow_left, + color: AppColors.saqelCyan, + size: 10), + ], + ), + ], + ), ), ), ), ], ), - trailing: Icon( - hasVid ? CupertinoIcons.chevron_back : CupertinoIcons.clock, - color: hasVid ? AppColors.textSecondaryDark : Colors.white24, - size: 16, - ), - onTap: () { - AppLogger.event('LessonTileTapped', details: { - 'subject': widget.subject.title, - 'lessonTitle': lesson.title, - 'lessonId': lesson.id, - 'hasVideo': hasVid, - 'curriculumLessonId': lesson.curriculumLessonId, - }, tag: 'SUBJECT_HUB'); - _showLessonVideoSelector(context, lesson); - }, - ), - if (lab != null) - Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 6), - child: InkWell( - borderRadius: BorderRadius.circular(8), - onTap: () { - AppLogger.event('OpenLessonLab', details: { - 'subject': widget.subject.title, - 'lesson': lesson.title, - 'lab': lab.lessonAr, - 'curriculumId': lab.identity.curriculumLessonId, - }, tag: 'SUBJECT_HUB'); - Grade10LabsRegistry.openLab(context, lab); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), - decoration: BoxDecoration( - color: AppColors.saqelCyan.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: AppColors.saqelCyan.withValues(alpha: 0.25)), - ), - child: Row( - children: [ - const Icon(CupertinoIcons.lab_flask_solid, color: AppColors.saqelCyan, size: 14), - const SizedBox(width: 6), - Expanded( - child: Row( - children: [ - Flexible( - child: Text( - 'المختبر: ${lab.lessonAr}', - style: const TextStyle( - color: Colors.white, - fontSize: 11.5, - fontWeight: FontWeight.w700, - ), - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5), - decoration: BoxDecoration( - color: lab.identity.isPublished - ? AppColors.teacherEmerald.withAlpha(35) - : AppColors.saqelCyan.withAlpha(30), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - lab.identity.isPublished - ? 'معتمد 🟢' - : (lab.identity.isBound ? 'جاهز للاستخدام 🟢' : 'معاينة 🔵'), - style: TextStyle( - color: lab.identity.isPublished - ? AppColors.teacherEmerald - : AppColors.saqelCyan, - fontSize: 9.5, - fontWeight: FontWeight.w800, - ), - ), - ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: AppColors.saqelCyan.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(5), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'بدء التجربة', - style: TextStyle( - color: AppColors.saqelCyan, - fontSize: 10.5, - fontWeight: FontWeight.w800, - ), - ), - SizedBox(width: 3), - Icon(CupertinoIcons.arrow_left, color: AppColors.saqelCyan, size: 10), - ], - ), - ), - ], - ), - ), - ), - ) - else - Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 6), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - color: Colors.white.withAlpha(6), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.white.withAlpha(12)), - ), - child: const Row( - children: [ - Icon(CupertinoIcons.lab_flask, color: Colors.white38, size: 13), - SizedBox(width: 6), - Expanded( - child: Text( - 'المختبر الافتراضي: قيد الإعداد الأكاديمي ⚪', - style: TextStyle( - color: Colors.white54, - fontSize: 11, - fontWeight: FontWeight.w600, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), - child: InkWell( - borderRadius: BorderRadius.circular(8), - onTap: () { - LessonHomeworkSheet.show( - context, - subjectId: widget.subject.id, - subjectTitle: widget.subject.title, - lessonId: lesson.id, - lessonTitle: lesson.title, - ); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), - decoration: BoxDecoration( - color: AppColors.appleBlue.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: AppColors.appleBlue.withValues(alpha: 0.25)), - ), - child: const Row( - children: [ - Icon(CupertinoIcons.pencil_ellipsis_rectangle, color: AppColors.appleBlue, size: 14), - SizedBox(width: 6), - Expanded( - child: Text( - 'واجب الدرس وتمارين الكتاب 📝', - style: TextStyle( - color: Colors.white, - fontSize: 11.5, - fontWeight: FontWeight.w700, - ), - overflow: TextOverflow.ellipsis, - ), - ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'حل التمارين', - style: TextStyle( - color: AppColors.saqelCyan, - fontSize: 10.5, - fontWeight: FontWeight.w800, - ), - ), - SizedBox(width: 3), - Icon(CupertinoIcons.arrow_left, color: AppColors.saqelCyan, size: 10), - ], - ), - ], - ), - ), - ), - ), - ], + ); + }).toList(), ), ); - }).toList(), - ), - ); - }, + }, ), ), ], @@ -782,13 +879,7 @@ class _SubjectHubScreenState extends State /// Tab 2: Worksheets & Summaries Widget _buildWorksheetsTab(BuildContext context) { - final serverWorksheets = widget.subject.worksheets; - final worksheets = serverWorksheets.isNotEmpty - ? serverWorksheets - : CurriculumQuestionBank.getSubjectWorksheets( - subjectId: widget.subject.id, - subjectTitle: widget.subject.title, - ); + final worksheets = widget.subject.worksheets; if (worksheets.isEmpty) { return _buildUnavailableResourcesState( @@ -814,7 +905,8 @@ class _SubjectHubScreenState extends State color: AppColors.crimsonRed.withAlpha(25), borderRadius: BorderRadius.circular(12), ), - child: const Icon(CupertinoIcons.doc_fill, color: AppColors.crimsonRed, size: 22), + child: const Icon(CupertinoIcons.doc_fill, + color: AppColors.crimsonRed, size: 22), ), const SizedBox(width: 14), Expanded( @@ -823,18 +915,23 @@ class _SubjectHubScreenState extends State children: [ Text( ws.title, - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 14), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 14), ), const SizedBox(height: 4), const Text( 'ملف PDF جاهز للطباعة والمراجعة السريعة', - style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 12), + style: TextStyle( + color: AppColors.textSecondaryDark, fontSize: 12), ), ], ), ), IconButton( - icon: const Icon(CupertinoIcons.arrow_down_circle_fill, color: AppColors.saqelCyan, size: 28), + icon: const Icon(CupertinoIcons.arrow_down_circle_fill, + color: AppColors.saqelCyan, size: 28), onPressed: () { _showResourceSheet( context, @@ -879,8 +976,10 @@ class _SubjectHubScreenState extends State context, examId: exam.id > 0 ? exam.id : 1, title: exam.title, - questionsCount: exam.questionsCount > 0 ? exam.questionsCount : 25, - durationMinutes: exam.durationMinutes > 0 ? exam.durationMinutes : 40, + questionsCount: + exam.questionsCount > 0 ? exam.questionsCount : 25, + durationMinutes: + exam.durationMinutes > 0 ? exam.durationMinutes : 40, subjectCode: widget.subject.id, ), ); @@ -964,7 +1063,10 @@ class _SubjectHubScreenState extends State Expanded( child: Text( title, - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 14), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 14), ), ), Container( @@ -975,7 +1077,10 @@ class _SubjectHubScreenState extends State ), child: const Text( 'جاهز للتقديم ✍️', - style: TextStyle(color: AppColors.emeraldGreen, fontSize: 11, fontWeight: FontWeight.w700), + style: TextStyle( + color: AppColors.emeraldGreen, + fontSize: 11, + fontWeight: FontWeight.w700), ), ), ], @@ -986,21 +1091,29 @@ class _SubjectHubScreenState extends State children: [ Row( children: [ - const Icon(CupertinoIcons.question_circle, color: AppColors.textSecondaryDark, size: 14), + const Icon(CupertinoIcons.question_circle, + color: AppColors.textSecondaryDark, size: 14), const SizedBox(width: 4), - Text('$questionsCount سؤالاً في البنك', style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 12)), + Text('$questionsCount سؤالاً في البنك', + style: const TextStyle( + color: AppColors.textSecondaryDark, fontSize: 12)), const SizedBox(width: 12), - const Icon(CupertinoIcons.time, color: AppColors.textSecondaryDark, size: 14), + const Icon(CupertinoIcons.time, + color: AppColors.textSecondaryDark, size: 14), const SizedBox(width: 4), - Text('$durationMinutes د', style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 12)), + Text('$durationMinutes د', + style: const TextStyle( + color: AppColors.textSecondaryDark, fontSize: 12)), ], ), ElevatedButton( style: ElevatedButton.styleFrom( backgroundColor: AppColors.appleBlue, foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), ), onPressed: () { Navigator.of(context).push( @@ -1016,7 +1129,9 @@ class _SubjectHubScreenState extends State ), ); }, - child: const Text('بدء الاختبار 🚀', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700)), + child: const Text('بدء الاختبار 🚀', + style: + TextStyle(fontSize: 12, fontWeight: FontWeight.w700)), ), ], ), @@ -1027,13 +1142,7 @@ class _SubjectHubScreenState extends State /// Tab 4: Official Ministry Textbooks Widget _buildTextbooksTab(BuildContext context) { - final serverTextbooks = widget.subject.textbooks; - final textbooks = serverTextbooks.isNotEmpty - ? serverTextbooks - : CurriculumQuestionBank.getSubjectTextbooks( - subjectId: widget.subject.id, - subjectTitle: widget.subject.title, - ); + final textbooks = widget.subject.textbooks; if (textbooks.isEmpty) { return _buildUnavailableResourcesState( @@ -1059,7 +1168,8 @@ class _SubjectHubScreenState extends State color: AppColors.appleBlue.withAlpha(25), borderRadius: BorderRadius.circular(12), ), - child: const Icon(CupertinoIcons.book_fill, color: AppColors.appleBlue, size: 22), + child: const Icon(CupertinoIcons.book_fill, + color: AppColors.appleBlue, size: 22), ), const SizedBox(width: 14), Expanded( @@ -1068,18 +1178,23 @@ class _SubjectHubScreenState extends State children: [ Text( tb.title, - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 14), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 14), ), const SizedBox(height: 4), const Text( 'منهاج وزارة التربية والتعليم الأردنية المعتمد (جاهز للقراءة والتنزيل)', - style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 12), + style: TextStyle( + color: AppColors.textSecondaryDark, fontSize: 12), ), ], ), ), IconButton( - icon: const Icon(CupertinoIcons.eye_fill, color: AppColors.saqelCyan, size: 24), + icon: const Icon(CupertinoIcons.eye_fill, + color: AppColors.saqelCyan, size: 24), onPressed: () { _showResourceSheet( context, @@ -1100,7 +1215,8 @@ class _SubjectHubScreenState extends State ); } - Widget _buildUnavailableResourcesState({required IconData icon, required String message}) { + Widget _buildUnavailableResourcesState( + {required IconData icon, required String message}) { return Center( child: Padding( padding: const EdgeInsets.all(32), @@ -1112,7 +1228,10 @@ class _SubjectHubScreenState extends State Text( message, textAlign: TextAlign.center, - style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 14, height: 1.5), + style: const TextStyle( + color: AppColors.textSecondaryDark, + fontSize: 14, + height: 1.5), ), ], ), @@ -1150,7 +1269,8 @@ class _SubjectHubScreenState extends State color: AppColors.appleBlue.withAlpha(30), borderRadius: BorderRadius.circular(14), ), - child: const Icon(CupertinoIcons.doc_text_fill, color: AppColors.saqelCyan, size: 26), + child: const Icon(CupertinoIcons.doc_text_fill, + color: AppColors.saqelCyan, size: 26), ), const SizedBox(width: 14), Expanded( @@ -1159,12 +1279,18 @@ class _SubjectHubScreenState extends State children: [ Text( title, - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 16), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, + fontSize: 16), ), const SizedBox(height: 4), Text( subtitle, - style: const TextStyle(color: AppColors.saqelCyan, fontSize: 12, fontWeight: FontWeight.w600), + style: const TextStyle( + color: AppColors.saqelCyan, + fontSize: 12, + fontWeight: FontWeight.w600), ), ], ), @@ -1174,7 +1300,8 @@ class _SubjectHubScreenState extends State const SizedBox(height: 16), Text( description, - style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 13.5), + style: const TextStyle( + color: AppColors.textSecondaryDark, fontSize: 13.5), ), const SizedBox(height: 24), Row( @@ -1185,7 +1312,8 @@ class _SubjectHubScreenState extends State foregroundColor: Colors.white, side: const BorderSide(color: AppColors.darkCardBorder), padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), ), onPressed: () { Navigator.of(ctx).pop(); @@ -1193,7 +1321,8 @@ class _SubjectHubScreenState extends State 'افتح المستند أولاً ثم احفظ النسخة المنشورة نفسها في الجهاز.'); }, icon: const Icon(CupertinoIcons.cloud_download, size: 18), - label: const Text('حفظ في الجهاز', style: TextStyle(fontWeight: FontWeight.w700)), + label: const Text('حفظ في الجهاز', + style: TextStyle(fontWeight: FontWeight.w700)), ), ), const SizedBox(width: 12), @@ -1203,7 +1332,8 @@ class _SubjectHubScreenState extends State backgroundColor: AppColors.appleBlue, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), ), onPressed: () { Navigator.of(ctx).pop(); @@ -1211,7 +1341,9 @@ class _SubjectHubScreenState extends State CupertinoPageRoute( builder: (_) => CurriculumDocumentViewerScreen( title: title, - documentType: title.contains('كتاب') ? 'textbook' : 'worksheet', + documentType: title.contains('كتاب') + ? 'textbook' + : 'worksheet', subjectTitle: widget.subject.title, subjectId: widget.subject.id, assetId: assetId, @@ -1222,7 +1354,8 @@ class _SubjectHubScreenState extends State ); }, icon: const Icon(CupertinoIcons.book, size: 18), - label: const Text('قراءة وتصفح', style: TextStyle(fontWeight: FontWeight.w800)), + label: const Text('قراءة وتصفح', + style: TextStyle(fontWeight: FontWeight.w800)), ), ), ], @@ -1235,14 +1368,12 @@ class _SubjectHubScreenState extends State } /// Empty state when no video exists yet for this lesson (Zero Mock Videos) - void _showNoVideoAvailableSheet(BuildContext context, CurriculumLessonItemModel lesson) { - final lab = Grade10LabsRegistry.findForLesson( - subjectTitle: widget.subject.title, - lessonTitle: lesson.title, - lessonId: lesson.id, - curriculumLessonId: lesson.curriculumLessonId, - filePath: lesson.markdownFilePath, - ); + void _showNoVideoAvailableSheet( + BuildContext context, CurriculumLessonItemModel lesson) { + final lab = lesson.curriculumLessonId == null + ? null + : Grade10LabsRegistry.publishedEntryForCurriculumLessonId( + lesson.curriculumLessonId!); showModalBottomSheet( context: context, @@ -1265,7 +1396,8 @@ class _SubjectHubScreenState extends State color: AppColors.guardianAmber.withAlpha(25), borderRadius: BorderRadius.circular(14), ), - child: const Icon(CupertinoIcons.videocam_fill, color: AppColors.guardianAmber, size: 26), + child: const Icon(CupertinoIcons.videocam_fill, + color: AppColors.guardianAmber, size: 26), ), const SizedBox(width: 14), Expanded( @@ -1274,12 +1406,16 @@ class _SubjectHubScreenState extends State children: [ const Text( 'لا يتوفر فيديو شرح لهذا الدرس حالياً 🎬', - style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 16), + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, + fontSize: 16), ), const SizedBox(height: 3), Text( lesson.title, - style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 12), + style: const TextStyle( + color: AppColors.textSecondaryDark, fontSize: 12), ), ], ), @@ -1289,7 +1425,10 @@ class _SubjectHubScreenState extends State const SizedBox(height: 16), const Text( 'لا يوجد شرح مصور منشور لهذا الدرس بعد. يمكنك خوض التجربة الافتراضية للدرس مباشرة أو قراءة الشروحات المعتمدة.', - style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 13, height: 1.5), + style: TextStyle( + color: AppColors.textSecondaryDark, + fontSize: 13, + height: 1.5), ), const SizedBox(height: 20), if (lab != null) ...[ @@ -1300,15 +1439,18 @@ class _SubjectHubScreenState extends State backgroundColor: AppColors.saqelCyan, foregroundColor: Colors.black, padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), ), onPressed: () { Navigator.of(ctx).pop(); Grade10LabsRegistry.openLab(context, lab); }, - icon: const Icon(CupertinoIcons.lab_flask_solid, size: 18, color: Colors.black), + icon: const Icon(CupertinoIcons.lab_flask_solid, + size: 18, color: Colors.black), label: Text('فتح المختبر الافتراضي: ${lab.lessonAr} 🧪', - style: const TextStyle(fontWeight: FontWeight.w900, fontSize: 13.5)), + style: const TextStyle( + fontWeight: FontWeight.w900, fontSize: 13.5)), ), ), const SizedBox(height: 10), @@ -1320,7 +1462,8 @@ class _SubjectHubScreenState extends State foregroundColor: Colors.white, side: const BorderSide(color: AppColors.darkCardBorder), padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), ), onPressed: () { Navigator.of(ctx).pop(); @@ -1331,13 +1474,14 @@ class _SubjectHubScreenState extends State documentType: 'lesson', subjectTitle: widget.subject.title, subjectId: widget.subject.id, - assetId: lesson.markdownFilePath ?? lesson.curriculumLessonId, + assetId: lesson.primaryLessonAssetId, ), ), ); }, icon: const Icon(CupertinoIcons.book, size: 18), - label: const Text('قراءة شرح ونصوص الدرس المعتمد 📖', style: TextStyle(fontWeight: FontWeight.w800)), + label: const Text('قراءة شرح ونصوص الدرس المعتمد 📖', + style: TextStyle(fontWeight: FontWeight.w800)), ), ), if (_hasLab) ...[ @@ -1353,9 +1497,12 @@ class _SubjectHubScreenState extends State Navigator.of(ctx).pop(); _tabController.animateTo(1); }, - icon: const Icon(CupertinoIcons.arrow_left_circle, size: 16), - label: const Text('استعراض كافة تجارب المادة (تبويب المختبر) 🧪', - style: TextStyle(fontWeight: FontWeight.w700, fontSize: 12.5)), + icon: + const Icon(CupertinoIcons.arrow_left_circle, size: 16), + label: const Text( + 'استعراض كافة تجارب المادة (تبويب المختبر) 🧪', + style: TextStyle( + fontWeight: FontWeight.w700, fontSize: 12.5)), ), ), ], @@ -1368,35 +1515,49 @@ class _SubjectHubScreenState extends State /// Loads the server-authoritative list: one version opens directly; several /// versions require an explicit teacher choice. - Future _showLessonVideoSelector(BuildContext context, CurriculumLessonItemModel lesson) async { + Future _showLessonVideoSelector( + BuildContext context, CurriculumLessonItemModel lesson) async { final curriculumLessonId = lesson.curriculumLessonId; - if (!lesson.hasVideo || curriculumLessonId == null || curriculumLessonId.isEmpty) { + if (curriculumLessonId == null || curriculumLessonId.isEmpty) { _showNoVideoAvailableSheet(context, lesson); return; } try { - final videos = await CurriculumRepository().getPublishedLessonVideos(curriculumLessonId); + final page = await CurriculumRepository() + .getPublishedLessonVideosPage(curriculumLessonId, limit: 20); + final videos = page.items; if (!context.mounted) return; - if (videos.isEmpty) { _showNoVideoAvailableSheet(context, lesson); return; } + if (page.availableCount == 0) { + _showNoVideoAvailableSheet(context, lesson); + return; + } + if (videos.isEmpty) throw StateError('قائمة الحصص المنشورة غير مكتملة.'); PublishedLessonVideoModel? chosen; - if (videos.length == 1) { + if (page.availableCount == 1) { chosen = videos.first; } else { chosen = await TeacherSelectionSheet.show( context, lesson: lesson, videos: videos, + nextCursor: page.nextCursor, + availableCount: page.availableCount, ); } if (chosen == null || !context.mounted) return; - Navigator.of(context).push(CupertinoPageRoute(builder: (_) => SocraticVideoPlayerScreen( - lesson: lesson, subject: widget.subject, instructorName: chosen!.teacherName, - videoSourceLabel: chosen.ratingCount == 0 ? 'حصة منشورة جديدة' : 'تقييم موثق: ${chosen.rating.toStringAsFixed(1)}', - selectedVideoVersionId: chosen.videoVersionId, - ))); + Navigator.of(context).push(CupertinoPageRoute( + builder: (_) => SocraticVideoPlayerScreen( + lesson: lesson, + subject: widget.subject, + instructorName: chosen!.teacherName, + videoSourceLabel: chosen.ratingCount == 0 + ? 'حصة منشورة جديدة' + : 'تقييم موثق: ${chosen.rating.toStringAsFixed(1)}', + selectedVideoVersionId: chosen.videoVersionId, + ))); } catch (e) { - if (context.mounted) SaqelToast.showError(context, 'تعذر تحميل الحصص المنشورة لهذا الدرس.'); + if (context.mounted) + SaqelToast.showError(context, 'تعذر تحميل الحصص المنشورة لهذا الدرس.'); } } - } diff --git a/apps/student_app/lib/presentation/screens/curriculum/subjects_grid_screen.dart b/apps/student_app/lib/presentation/screens/curriculum/subjects_grid_screen.dart index 62124d9..0bdb85b 100644 --- a/apps/student_app/lib/presentation/screens/curriculum/subjects_grid_screen.dart +++ b/apps/student_app/lib/presentation/screens/curriculum/subjects_grid_screen.dart @@ -116,7 +116,7 @@ class _SubjectsGridScreenState extends State { Icon(CupertinoIcons.checkmark_seal_fill, color: AppColors.saqelCyan, size: 14), SizedBox(width: 6), Text( - 'المنهاج الوزاري الأردني 🇯🇴', + 'فهرس الدروس المنشورة', style: TextStyle(color: AppColors.saqelCyan, fontSize: 11, fontWeight: FontWeight.w700), ), ], @@ -141,7 +141,9 @@ class _SubjectsGridScreenState extends State { const SizedBox(height: 14), LuxuryButton( text: 'إعادة المحاولة', - onPressed: () => context.read().fetchSubjects(), + onPressed: () => context.read().fetchSubjects( + gradeLevel: GradeLevelModel.normalizeGradeKey(widget.initialGrade ?? 'grade_10'), + ), ), ], ), @@ -162,7 +164,7 @@ class _SubjectsGridScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'المواد المقررة — ${GradeLevelModel.getGradeName(state.selectedGrade)} 📚', + 'المواد المنشورة — ${GradeLevelModel.getGradeName(state.selectedGrade)} 📚', style: AppTypography.displayLarge.copyWith(color: Colors.white, fontSize: 20), ), const SizedBox(height: 4), @@ -188,7 +190,8 @@ class _SubjectsGridScreenState extends State { ), const SizedBox(height: 16), - // Official Academic Grade Level Banner (Strict Grade-Gate — الصف العاشر فقط) + // The catalogue lists reviewed publications; do not claim + // ministry certification without a separate source of proof. Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( @@ -212,12 +215,12 @@ class _SubjectsGridScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '${GradeLevelModel.getGradeName(state.selectedGrade)} — المسار الأكاديمي المعتمد', + '${GradeLevelModel.getGradeName(state.selectedGrade)} — الدروس المنشورة', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 13.5), ), const SizedBox(height: 2), const Text( - 'خطة دراسية موحدة ومطابقة للمنهاج الوزاري الأردني المطور', + 'تظهر هنا الدروس التي اكتملت مراجعتها ونُشرت على المنصة', style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 11), ), ], @@ -255,15 +258,17 @@ class _SubjectsGridScreenState extends State { child: const Icon(CupertinoIcons.square_grid_2x2, size: 32, color: AppColors.saqelCyan), ), const SizedBox(height: 16), - const Text( - 'لا توجد مواد مضافة بعد في السيرفر', - style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 16), + Text( + state.subjects.isEmpty ? 'لا توجد دروس منشورة لهذا الصف بعد' : 'لا توجد مواد تطابق البحث', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 16), ), const SizedBox(height: 6), - const Text( - 'يتم إدراج المواد تلقائياً بمجرد رفع وتحليل كتب المناهج في الخادم', + Text( + state.subjects.isEmpty + ? 'يمكنك العودة لاحقاً للاطلاع على الحزم بعد نشرها.' + : 'جرّب اسم مادة مختلفاً أو امسح البحث.', textAlign: TextAlign.center, - style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 13), + style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 13), ), ], ), diff --git a/apps/student_app/lib/presentation/screens/curriculum/teacher_selection_sheet.dart b/apps/student_app/lib/presentation/screens/curriculum/teacher_selection_sheet.dart index 4b63c4c..2cfdcfd 100644 --- a/apps/student_app/lib/presentation/screens/curriculum/teacher_selection_sheet.dart +++ b/apps/student_app/lib/presentation/screens/curriculum/teacher_selection_sheet.dart @@ -7,20 +7,26 @@ import '../../widgets/luxury_widgets.dart'; /// Apple Cupertino Sheet for choosing a teacher when multiple published video versions exist for a lesson. /// Conforms to Saqel product rule: "أكثر من حصة متاحة: قائمة المعلمين مرتبة بالتقييم الموثوق، مع عدد التقييمات والتصفح على دفعات." -class TeacherSelectionSheet extends StatelessWidget { +class TeacherSelectionSheet extends StatefulWidget { final CurriculumLessonItemModel lesson; final List videos; + final int? nextCursor; + final int availableCount; const TeacherSelectionSheet({ super.key, required this.lesson, required this.videos, + required this.nextCursor, + required this.availableCount, }); static Future show( BuildContext context, { required CurriculumLessonItemModel lesson, required List videos, + required int? nextCursor, + required int availableCount, }) { return showModalBottomSheet( context: context, @@ -29,20 +35,43 @@ class TeacherSelectionSheet extends StatelessWidget { builder: (sheetContext) => TeacherSelectionSheet( lesson: lesson, videos: videos, + nextCursor: nextCursor, + availableCount: availableCount, ), ); } @override - Widget build(BuildContext context) { - // Sort server-authoritatively by rating desc, then rating count desc - final sortedVideos = List.from(videos) - ..sort((a, b) { - final cmp = b.rating.compareTo(a.rating); - if (cmp != 0) return cmp; - return b.ratingCount.compareTo(a.ratingCount); - }); + State createState() => _TeacherSelectionSheetState(); +} +class _TeacherSelectionSheetState extends State { + late final List _videos = List.of(widget.videos); + late int? _nextCursor = widget.nextCursor; + bool _loading = false; + bool _failed = false; + + Future _loadMore() async { + final cursor = _nextCursor; + final lessonId = widget.lesson.curriculumLessonId; + if (_loading || cursor == null || lessonId == null) return; + setState(() { _loading = true; _failed = false; }); + try { + final page = await CurriculumRepository().getPublishedLessonVideosPage(lessonId, cursor: cursor, limit: 20); + if (!mounted) return; + final known = _videos.map((video) => video.videoVersionId).toSet(); + setState(() { + _videos.addAll(page.items.where((video) => known.add(video.videoVersionId))); + _nextCursor = page.nextCursor; + _loading = false; + }); + } catch (_) { + if (mounted) setState(() { _loading = false; _failed = true; }); + } + } + + @override + Widget build(BuildContext context) { return Container( constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.height * 0.82, @@ -103,7 +132,7 @@ class TeacherSelectionSheet extends StatelessWidget { ), const SizedBox(height: 2), Text( - lesson.title, + widget.lesson.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( @@ -161,23 +190,21 @@ class TeacherSelectionSheet extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), shrinkWrap: true, - itemCount: sortedVideos.length, + itemCount: _videos.length + (_nextCursor != null ? 1 : 0), separatorBuilder: (_, __) => const SizedBox(height: 12), itemBuilder: (ctx, idx) { - final video = sortedVideos[idx]; + if (idx == _videos.length) { + return Center(child: TextButton( + onPressed: _loading ? null : _loadMore, + child: Text(_loading ? 'جارٍ تحميل المزيد…' : _failed ? 'تعذر التحميل — أعد المحاولة' : 'عرض المزيد (${_videos.length} من ${widget.availableCount})'), + )); + } + final video = _videos[idx]; final isNew = video.ratingCount == 0; final initials = video.teacherName.isNotEmpty ? video.teacherName.trim().characters.first : 'م'; - final approaches = [ - 'تركيز على خطوات الحل الوزاري والتحليل المفاهيمي', - 'تبسيط القواعد مع أمثلة حياتية وتطبيقات واقعية', - 'تدريبات مكثفة ونماذج امتحانات وزارية سابقة', - 'شرح تفاعلي مرئي مع استنتاج القوانين خطوة بخطوة', - ]; - final approach = approaches[idx % approaches.length]; - return LuxuryCard( borderColor: idx == 0 ? AppColors.saqelCyan.withAlpha(90) @@ -344,8 +371,8 @@ class TeacherSelectionSheet extends StatelessWidget { ), const SizedBox(height: 10), - // Pedagogical approach badge - Container( + // Only metadata supplied by the published video. + if (video.durationSeconds > 0 || video.publishedAt != null) Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 5), decoration: BoxDecoration( @@ -356,14 +383,17 @@ class TeacherSelectionSheet extends StatelessWidget { child: Row( children: [ const Icon( - CupertinoIcons.lightbulb_fill, + CupertinoIcons.time, color: AppColors.guardianAmber, size: 12, ), const SizedBox(width: 6), Expanded( child: Text( - approach, + [ + if (video.durationSeconds > 0) '${(video.durationSeconds / 60).ceil()} دقيقة', + if (video.publishedAt != null) 'نُشرت ${video.publishedAt!.year}-${video.publishedAt!.month.toString().padLeft(2, '0')}-${video.publishedAt!.day.toString().padLeft(2, '0')}', + ].join(' · '), style: const TextStyle( color: AppColors.textSecondaryDark, fontSize: 11, diff --git a/apps/student_app/lib/presentation/screens/home/unified_home_screen.dart b/apps/student_app/lib/presentation/screens/home/unified_home_screen.dart index a3b0cb6..fca3a9c 100644 --- a/apps/student_app/lib/presentation/screens/home/unified_home_screen.dart +++ b/apps/student_app/lib/presentation/screens/home/unified_home_screen.dart @@ -42,7 +42,8 @@ class _UnifiedHomeScreenState extends State { @override void didUpdateWidget(covariant UnifiedHomeScreen oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.user.gradeLevel != widget.user.gradeLevel || oldWidget.user.stream != widget.user.stream) { + if (oldWidget.user.gradeLevel != widget.user.gradeLevel || + oldWidget.user.stream != widget.user.stream) { _refreshData(); } } @@ -50,10 +51,10 @@ class _UnifiedHomeScreenState extends State { void _refreshData() { if (_currentRole == 'student') { context.read().fetchLessons( - gradeLevel: widget.user.gradeLevel, - stream: widget.user.stream, - initialReadiness: widget.user.readinessScore ?? 0.0, - ); + gradeLevel: widget.user.gradeLevel, + stream: widget.user.stream, + initialReadiness: widget.user.readinessScore ?? 0.0, + ); } else { context.read().fetchDashboard(); } @@ -83,7 +84,9 @@ class _UnifiedHomeScreenState extends State { decoration: const BoxDecoration( color: AppColors.darkSurface, borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - border: Border(top: BorderSide(color: AppColors.darkCardBorder, width: 1.5)), + border: Border( + top: BorderSide( + color: AppColors.darkCardBorder, width: 1.5)), ), child: SafeArea( child: Column( @@ -109,7 +112,8 @@ class _UnifiedHomeScreenState extends State { color: AppColors.saqelCyan.withAlpha(30), borderRadius: BorderRadius.circular(14), ), - child: const Icon(CupertinoIcons.book_fill, color: AppColors.saqelCyan, size: 24), + child: const Icon(CupertinoIcons.book_fill, + color: AppColors.saqelCyan, size: 24), ), const SizedBox(width: 14), const Expanded( @@ -118,12 +122,17 @@ class _UnifiedHomeScreenState extends State { children: [ Text( 'تحديد وتعديل الصف الدراسي 🎓', - style: TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.w800), + style: TextStyle( + color: Colors.white, + fontSize: 17, + fontWeight: FontWeight.w800), ), SizedBox(height: 3), Text( 'اختر صفك لفتح المناهج الوزارية والشروحات المعتمدة', - style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 12), + style: TextStyle( + color: AppColors.textSecondaryDark, + fontSize: 12), ), ], ), @@ -132,9 +141,21 @@ class _UnifiedHomeScreenState extends State { ), const SizedBox(height: 20), ...[ - ('grade_10', 'الصف العاشر الأساسي (Grade 10)', 'يشمل الرياضيات المطور، الفيزياء، والإنجليزية'), - ('grade_11', 'الصف الحادي عشر (الأول ثانوي)', 'المسار الأكاديمي للفرعين العلمي والأدبي'), - ('grade_12', 'الصف الثاني عشر (التوجيهي الوزاري)', 'منهاج الثانوية العامة التوجيهي الوزاري'), + ( + 'grade_10', + 'الصف العاشر الأساسي (Grade 10)', + 'يشمل الرياضيات المطور، الفيزياء، والإنجليزية' + ), + ( + 'grade_11', + 'الصف الحادي عشر (الأول ثانوي)', + 'المسار الأكاديمي للفرعين العلمي والأدبي' + ), + ( + 'grade_12', + 'الصف الثاني عشر (التوجيهي الوزاري)', + 'منهاج الثانوية العامة التوجيهي الوزاري' + ), ].map((g) { final isSelected = tempGrade == g.$1; return Padding( @@ -143,44 +164,61 @@ class _UnifiedHomeScreenState extends State { onTap: () { setModalState(() { tempGrade = g.$1; - if (tempGrade == 'grade_10') tempStream = 'general'; + if (tempGrade == 'grade_10') + tempStream = 'general'; }); }, borderRadius: BorderRadius.circular(16), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 14), decoration: BoxDecoration( - color: isSelected ? AppColors.saqelCyan.withAlpha(25) : AppColors.darkBackground, + color: isSelected + ? AppColors.saqelCyan.withAlpha(25) + : AppColors.darkBackground, borderRadius: BorderRadius.circular(16), border: Border.all( - color: isSelected ? AppColors.saqelCyan : AppColors.darkCardBorder, + color: isSelected + ? AppColors.saqelCyan + : AppColors.darkCardBorder, width: isSelected ? 1.5 : 1.0, ), ), child: Row( children: [ Icon( - isSelected ? CupertinoIcons.checkmark_circle_fill : CupertinoIcons.circle, - color: isSelected ? AppColors.saqelCyan : AppColors.textMutedDark, + isSelected + ? CupertinoIcons.checkmark_circle_fill + : CupertinoIcons.circle, + color: isSelected + ? AppColors.saqelCyan + : AppColors.textMutedDark, size: 20, ), const SizedBox(width: 12), Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: + CrossAxisAlignment.start, children: [ Text( g.$2, style: TextStyle( - color: isSelected ? Colors.white : AppColors.textSecondaryDark, - fontWeight: isSelected ? FontWeight.w800 : FontWeight.w600, + color: isSelected + ? Colors.white + : AppColors.textSecondaryDark, + fontWeight: isSelected + ? FontWeight.w800 + : FontWeight.w600, fontSize: 14, ), ), const SizedBox(height: 2), Text( g.$3, - style: const TextStyle(color: AppColors.textMutedDark, fontSize: 11), + style: const TextStyle( + color: AppColors.textMutedDark, + fontSize: 11), ), ], ), @@ -196,7 +234,9 @@ class _UnifiedHomeScreenState extends State { text: 'حفظ الصف وتحديث المسار 🚀', onPressed: () async { Navigator.of(ctx).pop(); - await context.read().updateGrade(tempGrade, stream: tempStream); + await context + .read() + .updateGrade(tempGrade, stream: tempStream); if (mounted) { _refreshData(); SaqelToast.showSuccess( @@ -225,7 +265,9 @@ class _UnifiedHomeScreenState extends State { body: Container( decoration: BoxDecoration( gradient: RadialGradient( - center: isStudent ? const Alignment(0.8, -0.6) : const Alignment(-0.8, -0.6), + center: isStudent + ? const Alignment(0.8, -0.6) + : const Alignment(-0.8, -0.6), radius: 1.3, colors: isStudent ? const [Color(0xFF0A2540), AppColors.darkBackground] @@ -277,7 +319,10 @@ class _UnifiedHomeScreenState extends State { child: const Center( child: Text( 'ص', - style: TextStyle(fontSize: 22, fontWeight: FontWeight.w900, color: Colors.black), + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w900, + color: Colors.black), ), ), ), @@ -294,17 +339,20 @@ class _UnifiedHomeScreenState extends State { GestureDetector( onTap: () => _showGradeSelectionModal(context), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: AppColors.saqelCyan.withAlpha(30), borderRadius: BorderRadius.circular(8), - border: Border.all(color: AppColors.saqelCyan.withAlpha(80), width: 0.8), + border: Border.all( + color: AppColors.saqelCyan.withAlpha(80), width: 0.8), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( - GradeLevelModel.getGradeName(widget.user.gradeLevel ?? 'grade_10'), + GradeLevelModel.getGradeName( + widget.user.gradeLevel ?? 'grade_10'), style: const TextStyle( color: AppColors.saqelCyan, fontSize: 10.5, @@ -312,7 +360,8 @@ class _UnifiedHomeScreenState extends State { ), ), const SizedBox(width: 4), - const Icon(CupertinoIcons.chevron_down, color: AppColors.saqelCyan, size: 10), + const Icon(CupertinoIcons.chevron_down, + color: AppColors.saqelCyan, size: 10), ], ), ), @@ -320,7 +369,8 @@ class _UnifiedHomeScreenState extends State { else Text( AppStrings.guardianPortal, - style: AppTypography.labelSmall.copyWith(color: AppColors.textMutedDark), + style: AppTypography.labelSmall + .copyWith(color: AppColors.textMutedDark), ), ], ), @@ -336,8 +386,10 @@ class _UnifiedHomeScreenState extends State { ), child: Row( children: [ - _buildRolePill('الطالب', isStudent, () => _switchRole('student')), - _buildRolePill('ولي الأمر', !isStudent, () => _switchRole('guardian')), + _buildRolePill( + 'الطالب', isStudent, () => _switchRole('student')), + _buildRolePill( + 'ولي الأمر', !isStudent, () => _switchRole('guardian')), ], ), ), @@ -345,7 +397,8 @@ class _UnifiedHomeScreenState extends State { // Logout Icon IconButton( - icon: const Icon(CupertinoIcons.power, color: AppColors.errorRed, size: 20), + icon: const Icon(CupertinoIcons.power, + color: AppColors.errorRed, size: 20), onPressed: () => context.read().logout(), tooltip: AppStrings.logout, ), @@ -388,9 +441,11 @@ class _UnifiedHomeScreenState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(CupertinoIcons.exclamationmark_triangle, color: AppColors.errorRed, size: 40), + const Icon(CupertinoIcons.exclamationmark_triangle, + color: AppColors.errorRed, size: 40), const SizedBox(height: 12), - Text(state.message, style: const TextStyle(color: Colors.white)), + Text(state.message, + style: const TextStyle(color: Colors.white)), const SizedBox(height: 16), ElevatedButton( onPressed: _refreshData, @@ -412,27 +467,37 @@ class _UnifiedHomeScreenState extends State { decoration: BoxDecoration( color: AppColors.saqelCyan.withAlpha(25), shape: BoxShape.circle, - border: Border.all(color: AppColors.saqelCyan.withAlpha(80), width: 1.5), + border: Border.all( + color: AppColors.saqelCyan.withAlpha(80), width: 1.5), ), - child: const Icon(CupertinoIcons.book_fill, color: AppColors.saqelCyan, size: 36), + child: const Icon(CupertinoIcons.book_fill, + color: AppColors.saqelCyan, size: 36), ), const SizedBox(height: 18), const Text( 'المنهاج الوزاري المعتمد — صَقِل 🇯🇴', - style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w800), + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w800), ), const SizedBox(height: 8), const Text( - 'اختر من بين 16 مبحثاً دراسياً لبدء شروحات الفيديو التفاعلية، أوراق العمل، وبنك الأسئلة المعتمد', + 'لا توجد حصص تشغيل منشورة لهذا الحساب حالياً. يمكنك تصفح الدروس والموارد التي اجتازت النشر من فهرس المنهاج.', textAlign: TextAlign.center, - style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 13, height: 1.4), + style: TextStyle( + color: AppColors.textSecondaryDark, + fontSize: 13, + height: 1.4), ), const SizedBox(height: 24), LuxuryButton( text: 'تصفح المناهج والمواد 📚', onPressed: () { Navigator.of(context).push( - MaterialPageRoute(builder: (_) => SubjectsGridScreen(initialGrade: widget.user.gradeLevel)), + MaterialPageRoute( + builder: (_) => SubjectsGridScreen( + initialGrade: widget.user.gradeLevel)), ); }, ), @@ -450,7 +515,9 @@ class _UnifiedHomeScreenState extends State { InkWell( onTap: () { Navigator.of(context).push( - MaterialPageRoute(builder: (_) => SubjectsGridScreen(initialGrade: widget.user.gradeLevel)), + MaterialPageRoute( + builder: (_) => SubjectsGridScreen( + initialGrade: widget.user.gradeLevel)), ); }, borderRadius: BorderRadius.circular(20), @@ -463,7 +530,9 @@ class _UnifiedHomeScreenState extends State { end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(20), - border: Border.all(color: AppColors.saqelCyan.withAlpha(100), width: 1.5), + border: Border.all( + color: AppColors.saqelCyan.withAlpha(100), + width: 1.5), boxShadow: [ BoxShadow( color: AppColors.saqelCyan.withAlpha(30), @@ -479,11 +548,15 @@ class _UnifiedHomeScreenState extends State { height: 48, decoration: BoxDecoration( gradient: const LinearGradient( - colors: [AppColors.appleBlue, AppColors.saqelCyan], + colors: [ + AppColors.appleBlue, + AppColors.saqelCyan + ], ), borderRadius: BorderRadius.circular(14), ), - child: const Icon(CupertinoIcons.square_grid_2x2_fill, color: Colors.black, size: 24), + child: const Icon(CupertinoIcons.square_grid_2x2_fill, + color: Colors.black, size: 24), ), const SizedBox(width: 14), const Expanded( @@ -491,18 +564,24 @@ class _UnifiedHomeScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'تصفح المناهج والمواد (16 مادة) 📚', - style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 15), + 'تصفح المناهج والمواد 📚', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, + fontSize: 15), ), SizedBox(height: 4), Text( 'الرياضيات، الفيزياء، الإنجليزي، الكيمياء، والمزيد', - style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 12), + style: TextStyle( + color: AppColors.textSecondaryDark, + fontSize: 12), ), ], ), ), - const Icon(CupertinoIcons.chevron_back, color: AppColors.saqelCyan, size: 18), + const Icon(CupertinoIcons.chevron_back, + color: AppColors.saqelCyan, size: 18), ], ), ), @@ -513,7 +592,8 @@ class _UnifiedHomeScreenState extends State { InkWell( onTap: () { Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const VocationalTrainingScreen()), + MaterialPageRoute( + builder: (_) => const VocationalTrainingScreen()), ); }, borderRadius: BorderRadius.circular(20), @@ -526,7 +606,9 @@ class _UnifiedHomeScreenState extends State { end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(20), - border: Border.all(color: const Color(0xFF10B981).withAlpha(160), width: 1.5), + border: Border.all( + color: const Color(0xFF10B981).withAlpha(160), + width: 1.5), boxShadow: [ BoxShadow( color: const Color(0xFF10B981).withAlpha(35), @@ -548,7 +630,8 @@ class _UnifiedHomeScreenState extends State { ), borderRadius: BorderRadius.circular(14), ), - child: const Icon(CupertinoIcons.wrench_fill, color: Colors.black, size: 24), + child: const Icon(CupertinoIcons.wrench_fill, + color: Colors.black, size: 24), ), const SizedBox(width: 14), const Expanded( @@ -557,17 +640,24 @@ class _UnifiedHomeScreenState extends State { children: [ Text( 'مؤسسة التدريب المهني (VTC Hub) ⚡', - style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 14.5), + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, + fontSize: 14.5), ), SizedBox(height: 4), Text( '140 مهنة • 8 قطاعات صناعية • مختبر فحص المركبات الكهربائية (EV)', - style: TextStyle(color: Color(0xFF6EE7B7), fontSize: 11.5, fontWeight: FontWeight.w600), + style: TextStyle( + color: Color(0xFF6EE7B7), + fontSize: 11.5, + fontWeight: FontWeight.w600), ), ], ), ), - const Icon(CupertinoIcons.chevron_back, color: Color(0xFF10B981), size: 18), + const Icon(CupertinoIcons.chevron_back, + color: Color(0xFF10B981), size: 18), ], ), ), @@ -578,7 +668,8 @@ class _UnifiedHomeScreenState extends State { InkWell( onTap: () { Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const SmartErrorNotebookScreen()), + MaterialPageRoute( + builder: (_) => const SmartErrorNotebookScreen()), ); }, borderRadius: BorderRadius.circular(20), @@ -591,7 +682,9 @@ class _UnifiedHomeScreenState extends State { end: Alignment.bottomRight, ), borderRadius: BorderRadius.circular(20), - border: Border.all(color: const Color(0xFF8B5CF6).withOpacity(0.4), width: 1.5), + border: Border.all( + color: const Color(0xFF8B5CF6).withOpacity(0.4), + width: 1.5), boxShadow: [ BoxShadow( color: const Color(0xFF8B5CF6).withOpacity(0.15), @@ -611,7 +704,8 @@ class _UnifiedHomeScreenState extends State { ), borderRadius: BorderRadius.circular(12), ), - child: const Icon(CupertinoIcons.book_circle_fill, color: Colors.white, size: 24), + child: const Icon(CupertinoIcons.book_circle_fill, + color: Colors.white, size: 24), ), const SizedBox(width: 14), const Expanded( @@ -622,24 +716,32 @@ class _UnifiedHomeScreenState extends State { children: [ Text( 'دفتر الأخطاء الذكي والمسار العلاجي 📓', - style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 14), + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, + fontSize: 14), ), SizedBox(width: 6), Text( '· نشط', - style: TextStyle(color: Color(0xFF34D399), fontWeight: FontWeight.w700, fontSize: 11), + style: TextStyle( + color: Color(0xFF34D399), + fontWeight: FontWeight.w700, + fontSize: 11), ), ], ), SizedBox(height: 3), Text( 'رصد الأسئلة المتعثرة وسد الفجوات التراكمية بالذكاء الاصطناعي', - style: TextStyle(color: Color(0xFF94A3B8), fontSize: 11.5), + style: TextStyle( + color: Color(0xFF94A3B8), fontSize: 11.5), ), ], ), ), - const Icon(CupertinoIcons.chevron_back, color: Color(0xFF8B5CF6), size: 18), + const Icon(CupertinoIcons.chevron_back, + color: Color(0xFF8B5CF6), size: 18), ], ), ), @@ -658,17 +760,21 @@ class _UnifiedHomeScreenState extends State { GestureDetector( onTap: () => _showGradeSelectionModal(context), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 3), decoration: BoxDecoration( color: AppColors.saqelCyan.withAlpha(30), borderRadius: BorderRadius.circular(6), - border: Border.all(color: AppColors.saqelCyan.withAlpha(80), width: 0.8), + border: Border.all( + color: AppColors.saqelCyan.withAlpha(80), + width: 0.8), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( - GradeLevelModel.getGradeName(widget.user.gradeLevel ?? 'grade_10'), + GradeLevelModel.getGradeName( + widget.user.gradeLevel ?? 'grade_10'), style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w700, @@ -676,7 +782,8 @@ class _UnifiedHomeScreenState extends State { ), ), const SizedBox(width: 4), - const Icon(CupertinoIcons.pencil, color: AppColors.saqelCyan, size: 11), + const Icon(CupertinoIcons.pencil, + color: AppColors.saqelCyan, size: 11), ], ), ), @@ -684,12 +791,15 @@ class _UnifiedHomeScreenState extends State { const SizedBox(height: 8), Text( AppStrings.readinessScore, - style: AppTypography.headlineMedium.copyWith(color: Colors.white), + style: AppTypography.headlineMedium + .copyWith(color: Colors.white), ), const SizedBox(height: 4), Text( 'تقييم حقيقي مبني على الكويزات وفحوصات الفهم', - style: AppTypography.bodyMedium.copyWith(color: AppColors.textSecondaryDark, fontSize: 12), + style: AppTypography.bodyMedium.copyWith( + color: AppColors.textSecondaryDark, + fontSize: 12), ), ], ), @@ -699,12 +809,16 @@ class _UnifiedHomeScreenState extends State { height: 64, decoration: BoxDecoration( shape: BoxShape.circle, - border: Border.all(color: AppColors.saqelCyan, width: 3), + border: + Border.all(color: AppColors.saqelCyan, width: 3), ), child: Center( child: Text( '${state.readinessScore.toStringAsFixed(0)}%', - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900, color: Colors.white), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w900, + color: Colors.white), ), ), ), @@ -716,7 +830,8 @@ class _UnifiedHomeScreenState extends State { // Real Lessons List from API Text( AppStrings.tawjihiCurriculum, - style: AppTypography.titleMedium.copyWith(color: Colors.white), + style: + AppTypography.titleMedium.copyWith(color: Colors.white), ), const SizedBox(height: 12), @@ -751,7 +866,8 @@ class _UnifiedHomeScreenState extends State { color: AppColors.appleBlue.withAlpha(30), borderRadius: BorderRadius.circular(12), ), - child: const Icon(CupertinoIcons.play_fill, color: AppColors.appleBlue, size: 20), + child: const Icon(CupertinoIcons.play_fill, + color: AppColors.appleBlue, size: 20), ), const SizedBox(width: 14), Expanded( @@ -771,14 +887,18 @@ class _UnifiedHomeScreenState extends State { if (lesson.isAiVersion) ...[ const SizedBox(width: 6), Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + padding: const EdgeInsets.symmetric( + horizontal: 5, vertical: 2), decoration: BoxDecoration( color: AppColors.adminViolet.withAlpha(40), borderRadius: BorderRadius.circular(4), ), child: const Text( 'ذكاء اصطناعي ✨', - style: TextStyle(fontSize: 9, fontWeight: FontWeight.w700, color: Color(0xFFA78BFA)), + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w700, + color: Color(0xFFA78BFA)), ), ), ], @@ -789,12 +909,16 @@ class _UnifiedHomeScreenState extends State { lesson.title, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.white), ), const SizedBox(height: 2), Text( lesson.formattedDuration, - style: const TextStyle(fontSize: 11, color: AppColors.textMutedDark), + style: const TextStyle( + fontSize: 11, color: AppColors.textMutedDark), ), ], ), @@ -818,9 +942,11 @@ class _UnifiedHomeScreenState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(CupertinoIcons.exclamationmark_triangle, color: AppColors.errorRed, size: 40), + const Icon(CupertinoIcons.exclamationmark_triangle, + color: AppColors.errorRed, size: 40), const SizedBox(height: 12), - Text(state.message, style: const TextStyle(color: Colors.white)), + Text(state.message, + style: const TextStyle(color: Colors.white)), const SizedBox(height: 16), ElevatedButton( onPressed: _refreshData, @@ -834,9 +960,11 @@ class _UnifiedHomeScreenState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(CupertinoIcons.person_2, color: AppColors.textMutedDark, size: 48), + const Icon(CupertinoIcons.person_2, + color: AppColors.textMutedDark, size: 48), const SizedBox(height: 12), - Text(AppStrings.noChildrenLinked, style: const TextStyle(color: AppColors.textSecondaryDark)), + Text(AppStrings.noChildrenLinked, + style: const TextStyle(color: AppColors.textSecondaryDark)), ], ), ); @@ -850,7 +978,8 @@ class _UnifiedHomeScreenState extends State { children: [ Text( AppStrings.linkedChildren, - style: AppTypography.titleMedium.copyWith(color: Colors.white), + style: + AppTypography.titleMedium.copyWith(color: Colors.white), ), const SizedBox(height: 10), @@ -861,15 +990,20 @@ class _UnifiedHomeScreenState extends State { final child = state.children[index]; return Expanded( child: GestureDetector( - onTap: () => context.read().selectChild(index), + onTap: () => + context.read().selectChild(index), child: Container( margin: EdgeInsets.only(left: index == 0 ? 8 : 0), padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: isSelected ? AppColors.guardianAmber.withAlpha(35) : AppColors.darkCard, + color: isSelected + ? AppColors.guardianAmber.withAlpha(35) + : AppColors.darkCard, borderRadius: BorderRadius.circular(14), border: Border.all( - color: isSelected ? AppColors.guardianAmber : AppColors.darkCardBorder, + color: isSelected + ? AppColors.guardianAmber + : AppColors.darkCardBorder, ), ), child: Column( @@ -880,13 +1014,17 @@ class _UnifiedHomeScreenState extends State { style: TextStyle( fontSize: 13, fontWeight: FontWeight.w700, - color: isSelected ? const Color(0xFFFBBF24) : Colors.white, + color: isSelected + ? const Color(0xFFFBBF24) + : Colors.white, ), ), const SizedBox(height: 2), Text( child.gradeLevel ?? 'طالب مسجل', - style: const TextStyle(fontSize: 10.5, color: AppColors.textMutedDark), + style: const TextStyle( + fontSize: 10.5, + color: AppColors.textMutedDark), ), ], ), @@ -906,11 +1044,13 @@ class _UnifiedHomeScreenState extends State { children: [ Text( 'تقرير الطالب: ${selectedChild.name}', - style: AppTypography.titleMedium.copyWith(color: Colors.white), + style: AppTypography.titleMedium + .copyWith(color: Colors.white), ), const Spacer(), Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), decoration: BoxDecoration( color: AppColors.teacherEmerald.withAlpha(30), borderRadius: BorderRadius.circular(6), @@ -929,12 +1069,14 @@ class _UnifiedHomeScreenState extends State { const SizedBox(height: 6), Text( 'المدرسة: ${selectedChild.schoolName ?? 'مدرسة الثقافة العسكرية'}', - style: const TextStyle(fontSize: 12, color: AppColors.textMutedDark), + style: const TextStyle( + fontSize: 12, color: AppColors.textMutedDark), ), const SizedBox(height: 16), const Divider(color: AppColors.darkCardBorder), const SizedBox(height: 10), - _buildGuardianStat('الامتحانات المنجزة بنجاح', '${selectedChild.examsPassed} من ${selectedChild.examsTotal}'), + _buildGuardianStat('الامتحانات المنجزة بنجاح', + '${selectedChild.examsPassed} من ${selectedChild.examsTotal}'), InkWell( onTap: () { Navigator.of(context).push( @@ -958,7 +1100,8 @@ class _UnifiedHomeScreenState extends State { ), ), ), - _buildGuardianStat('الرقم الوطني المشفر', selectedChild.nationalId ?? 'مسجل بالهاتف'), + _buildGuardianStat('الرقم الوطني المشفر', + selectedChild.nationalId ?? 'مسجل بالهاتف'), ], ), ), @@ -972,7 +1115,8 @@ class _UnifiedHomeScreenState extends State { builder: (ctx) => Directionality( textDirection: TextDirection.rtl, child: CupertinoAlertDialog( - title: const Text('ومضة التقرير الشهري لولي الأمر 📲'), + title: + const Text('ومضة التقرير الشهري لولي الأمر 📲'), content: Text( '🇯🇴 تقرير التحصيل الأكاديمي المعتمد\n' 'الطالب: ${selectedChild.name}\n' @@ -999,7 +1143,8 @@ class _UnifiedHomeScreenState extends State { borderColor: AppColors.whatsappGreen.withAlpha(90), child: const Row( children: [ - Icon(CupertinoIcons.chat_bubble_2_fill, color: AppColors.whatsappGreen, size: 26), + Icon(CupertinoIcons.chat_bubble_2_fill, + color: AppColors.whatsappGreen, size: 26), SizedBox(width: 12), Expanded( child: Column( @@ -1009,24 +1154,33 @@ class _UnifiedHomeScreenState extends State { children: [ Text( 'تقارير بوابة نبيه للواتساب 📲', - style: TextStyle(fontSize: 13.5, fontWeight: FontWeight.w800, color: Colors.white), + style: TextStyle( + fontSize: 13.5, + fontWeight: FontWeight.w800, + color: Colors.white), ), SizedBox(width: 6), Text( '· نشطة', - style: TextStyle(color: Color(0xFF34D399), fontWeight: FontWeight.w700, fontSize: 11), + style: TextStyle( + color: Color(0xFF34D399), + fontWeight: FontWeight.w700, + fontSize: 11), ), ], ), SizedBox(height: 3), Text( 'انقر لمعاينة ومضة الأداء الأكاديمي الشهرية المرسلة لولي الأمر', - style: TextStyle(fontSize: 11, color: AppColors.textSecondaryDark), + style: TextStyle( + fontSize: 11, + color: AppColors.textSecondaryDark), ), ], ), ), - Icon(CupertinoIcons.chevron_back, color: AppColors.whatsappGreen, size: 16), + Icon(CupertinoIcons.chevron_back, + color: AppColors.whatsappGreen, size: 16), ], ), ), @@ -1045,9 +1199,15 @@ class _UnifiedHomeScreenState extends State { padding: const EdgeInsets.only(bottom: 8), child: Row( children: [ - Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondaryDark)), + Text(label, + style: const TextStyle( + fontSize: 12, color: AppColors.textSecondaryDark)), const Spacer(), - Text(value, style: const TextStyle(fontSize: 12.5, fontWeight: FontWeight.w700, color: Colors.white)), + Text(value, + style: const TextStyle( + fontSize: 12.5, + fontWeight: FontWeight.w700, + color: Colors.white)), ], ), ); diff --git a/apps/student_app/lib/presentation/screens/notebook/smart_error_notebook_screen.dart b/apps/student_app/lib/presentation/screens/notebook/smart_error_notebook_screen.dart index dbb09f4..2c49234 100644 --- a/apps/student_app/lib/presentation/screens/notebook/smart_error_notebook_screen.dart +++ b/apps/student_app/lib/presentation/screens/notebook/smart_error_notebook_screen.dart @@ -27,6 +27,7 @@ class _SmartErrorNotebookScreenState extends State { final ErrorNotebookRepository _repository = ErrorNotebookRepository(); bool _isLoading = true; + bool _loadFailed = false; ErrorNotebookSummary? _summary; List _items = []; @@ -42,15 +43,27 @@ class _SmartErrorNotebookScreenState extends State { Future _loadNotebookData() async { setState(() => _isLoading = true); - final data = (widget.isGuardianMode && widget.studentId != null) - ? await _repository.getChildErrorNotebook(widget.studentId!) - : await _repository.getErrorNotebook(); - if (mounted) { - setState(() { - _summary = data['summary'] as ErrorNotebookSummary?; - _items = (data['items'] as List?) ?? []; - _isLoading = false; - }); + try { + final data = (widget.isGuardianMode && widget.studentId != null) + ? await _repository.getChildErrorNotebook(widget.studentId!) + : await _repository.getErrorNotebook(); + if (mounted) { + setState(() { + _summary = data['summary'] as ErrorNotebookSummary?; + _items = (data['items'] as List?) ?? []; + _loadFailed = false; + _isLoading = false; + }); + } + } catch (_) { + if (mounted) { + setState(() { + _summary = null; + _items = []; + _loadFailed = true; + _isLoading = false; + }); + } } } @@ -146,6 +159,8 @@ class _SmartErrorNotebookScreenState extends State { ? const Center( child: CircularProgressIndicator(color: Color(0xFF38BDF8)), ) + : _loadFailed + ? Center(child: TextButton(onPressed: _loadNotebookData, child: const Text('تعذر تحميل دفتر الأخطاء. اضغط لإعادة المحاولة.'))) : RefreshIndicator( onRefresh: _loadNotebookData, color: const Color(0xFF38BDF8), @@ -647,7 +662,9 @@ class _SmartErrorNotebookScreenState extends State { ), ) : ElevatedButton.icon( - onPressed: () => _startRemediation(item), + onPressed: item.isInRemediation && !item.retentionIsDue + ? null + : () => _startRemediation(item), icon: Icon( item.isInRemediation ? CupertinoIcons.arrow_2_circlepath_circle_fill @@ -656,7 +673,9 @@ class _SmartErrorNotebookScreenState extends State { ), label: Text( item.isInRemediation - ? 'بدء اختبار الاسترجاع والتثبيت (تكرار متباعد 7 أيام) 🎯' + ? (item.retentionIsDue + ? 'بدء اختبار الاسترجاع بعد 7 أيام 🎯' + : 'اختبار الاسترجاع متاح بعد موعد المراجعة') : 'بدء المسار العلاجي لسد هذه الفجوة (3 أسئلة تفريدية) 🚀', style: const TextStyle(fontSize: 12.5, fontWeight: FontWeight.w800), ), @@ -723,11 +742,14 @@ class _RemediationModalSheet extends StatefulWidget { class _RemediationModalSheetState extends State<_RemediationModalSheet> { bool _isLoadingQuiz = true; List _questions = []; + String _attemptUuid = ''; + final List _answers = []; int _currentQuestionIndex = 0; int? _selectedOptionIndex; - bool _hasAnsweredCurrent = false; int _correctAnswersCount = 0; bool _isCompleted = false; + bool _passed = false; + bool _isSubmitting = false; @override void initState() { @@ -736,44 +758,43 @@ class _RemediationModalSheetState extends State<_RemediationModalSheet> { } Future _fetchQuiz() async { - final qs = await widget.repository.getRemediationQuiz( + final result = await widget.repository.getRemediationQuiz( errorUuid: widget.errorItem.uuid, - topicName: widget.errorItem.topicName, ); if (mounted) { setState(() { - _questions = qs; + _questions = result['questions'] as List; + _attemptUuid = result['attempt_uuid'] as String; _isLoadingQuiz = false; }); } } - void _submitAnswer() { - if (_selectedOptionIndex == null || _hasAnsweredCurrent) return; - - final q = _questions[_currentQuestionIndex]; - final isCorrect = _selectedOptionIndex == q.correctIndex; - - setState(() { - _hasAnsweredCurrent = true; - if (isCorrect) { - _correctAnswersCount++; - } - }); - } - - void _nextQuestion() async { + Future _nextQuestion() async { + if (_selectedOptionIndex == null || _isSubmitting) return; + _answers.add(_questions[_currentQuestionIndex].options[_selectedOptionIndex!].id); if (_currentQuestionIndex < _questions.length - 1) { setState(() { _currentQuestionIndex++; _selectedOptionIndex = null; - _hasAnsweredCurrent = false; }); } else { - // Completed drill - setState(() => _isCompleted = true); - if (_correctAnswersCount >= 2) { - await widget.repository.resolveError(widget.errorItem.uuid); + setState(() => _isSubmitting = true); + final result = await widget.repository.resolveError(widget.errorItem.uuid, _attemptUuid, _answers); + if (!mounted) return; + if (result == null) { + _answers.removeLast(); + setState(() => _isSubmitting = false); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('تعذر اعتماد المحاولة. حاول مرة أخرى.'))); + return; + } + setState(() { + _correctAnswersCount = (result['correct_count'] as num?)?.toInt() ?? 0; + _passed = result['passed'] == true; + _isSubmitting = false; + _isCompleted = true; + }); + if (_passed) { widget.onRemediationCompleted(); } } @@ -795,6 +816,8 @@ class _RemediationModalSheetState extends State<_RemediationModalSheet> { ? const Center( child: CircularProgressIndicator(color: Color(0xFF38BDF8)), ) + : _questions.isEmpty + ? const Center(child: Text('لا تتوفر أسئلة مراجعة معتمدة لهذا المفهوم حالياً.', style: TextStyle(color: Colors.white70))) : _isCompleted ? _buildCompletionState() : _buildQuestionState(), @@ -856,30 +879,19 @@ class _RemediationModalSheetState extends State<_RemediationModalSheet> { child: ListView.builder( itemCount: q.options.length, itemBuilder: (ctx, idx) { - final optText = q.options[idx]; + final optText = q.options[idx].text; final isSel = _selectedOptionIndex == idx; - final isCorrectOpt = idx == q.correctIndex; Color borderCol = const Color(0xFF1E293B); Color bgCol = const Color(0xFF161F30); - if (_hasAnsweredCurrent) { - if (isCorrectOpt) { - borderCol = const Color(0xFF10B981); - bgCol = const Color(0xFF064E3B); - } else if (isSel) { - borderCol = const Color(0xFFEF4444); - bgCol = const Color(0xFF7F1D1D); - } - } else if (isSel) { + if (isSel) { borderCol = const Color(0xFF38BDF8); bgCol = const Color(0xFF0369A1); } return GestureDetector( - onTap: _hasAnsweredCurrent - ? null - : () => setState(() => _selectedOptionIndex = idx), + onTap: () => setState(() => _selectedOptionIndex = idx), child: Container( margin: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.all(14), @@ -926,34 +938,11 @@ class _RemediationModalSheetState extends State<_RemediationModalSheet> { ), ), - // Explanation if answered - if (_hasAnsweredCurrent) - Container( - padding: const EdgeInsets.all(12), - margin: const EdgeInsets.only(bottom: 12), - decoration: BoxDecoration( - color: const Color(0xFF1E293B), - borderRadius: BorderRadius.circular(10), - ), - child: Text( - q.explanation, - style: const TextStyle( - fontSize: 12, - color: Color(0xFFCBD5E1), - height: 1.4, - ), - ), - ), - // Action Button ElevatedButton( - onPressed: !_hasAnsweredCurrent - ? (_selectedOptionIndex != null ? _submitAnswer : null) - : _nextQuestion, + onPressed: _selectedOptionIndex != null && !_isSubmitting ? _nextQuestion : null, style: ElevatedButton.styleFrom( - backgroundColor: _hasAnsweredCurrent - ? const Color(0xFF10B981) - : const Color(0xFF38BDF8), + backgroundColor: const Color(0xFF38BDF8), foregroundColor: Colors.black, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( @@ -961,11 +950,8 @@ class _RemediationModalSheetState extends State<_RemediationModalSheet> { ), ), child: Text( - !_hasAnsweredCurrent - ? 'تأكيد الإجابة' - : (_currentQuestionIndex < _questions.length - 1 - ? 'السؤال التالي ➔' - : 'إنهاء المسار العلاجي 🏆'), + _isSubmitting ? 'جارٍ تصحيح الإجابات...' : (_currentQuestionIndex < _questions.length - 1 + ? 'السؤال التالي ➔' : 'إنهاء وتصحيح المحاولة'), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w800), ), ), @@ -974,7 +960,7 @@ class _RemediationModalSheetState extends State<_RemediationModalSheet> { } Widget _buildCompletionState() { - final passed = _correctAnswersCount >= 2; + final passed = _passed; return Center( child: Column( @@ -1005,7 +991,7 @@ class _RemediationModalSheetState extends State<_RemediationModalSheet> { Text( passed ? (widget.errorItem.isInRemediation - ? 'أجبت عن $_correctAnswersCount من ${_questions.length} أسئلة بشكل سليم. تم تثبيت المفهوم في الذاكرة طويلة المدى وترحيله إلى المتقنات 🏆.' + ? 'أجبت عن $_correctAnswersCount من ${_questions.length} أسئلة بشكل سليم في اختبار الاسترجاع بعد أسبوع.' : 'أجبت عن $_correctAnswersCount من ${_questions.length} أسئلة. دخل المفهوم مرحلة التثبيت والتكرار المتباعد (المراجعة بعد 7 أيام لتأكيد الإتقان التام).') : 'أجبت عن $_correctAnswersCount من ${_questions.length}. حاول مجدداً لضمان إتقان السؤال في الامتحان الوزاري.', style: const TextStyle(fontSize: 13, color: Color(0xFF94A3B8)), diff --git a/apps/student_app/lib/presentation/screens/player/socratic_video_player_screen.dart b/apps/student_app/lib/presentation/screens/player/socratic_video_player_screen.dart index c2f5402..ae29555 100644 --- a/apps/student_app/lib/presentation/screens/player/socratic_video_player_screen.dart +++ b/apps/student_app/lib/presentation/screens/player/socratic_video_player_screen.dart @@ -1,7 +1,7 @@ /// ============================================================================== /// SAQEL ENTERPRISE (EDTECH 2.0) - SOCRATIC VIDEO PLAYER SCREEN /// ============================================================================== -/// +/// /// ملف: socratic_video_player_screen.dart /// الهدف المعماري: /// مشغل الفيديو السقراطي الذكي والمحمي في تطبيق صَقِل: @@ -29,9 +29,6 @@ import '../../../logic/cubits/video_playback_cubit.dart'; import '../../widgets/luxury_widgets.dart'; import '../../widgets/socratic_dialog.dart'; import 'package:video_player/video_player.dart'; -import 'package:flutter_tts/flutter_tts.dart'; -import '../curriculum/math_interactive_lab_view.dart'; -import 'scaffolded_thinking_pause_sheet.dart'; /// مشغل الفيديو السقراطي الذكي ونقاط الفحص والإرجاع العلاجي class SocraticVideoPlayerScreen extends StatefulWidget { @@ -51,10 +48,12 @@ class SocraticVideoPlayerScreen extends StatefulWidget { }); @override - State createState() => _SocraticVideoPlayerScreenState(); + State createState() => + _SocraticVideoPlayerScreenState(); } -class _SocraticVideoPlayerScreenState extends State with SingleTickerProviderStateMixin { +class _SocraticVideoPlayerScreenState extends State + with SingleTickerProviderStateMixin { Timer? _playbackTicker; late AnimationController _watermarkController; VideoPlayerController? _videoController; @@ -63,10 +62,6 @@ class _SocraticVideoPlayerScreenState extends State w int _lastSyncedPosition = -1; Timer? _controlsHideTimer; bool _showVideoControls = true; - bool _showChalkboardMode = false; - final FlutterTts _tts = FlutterTts(); - bool _isSpeakingChalkboard = false; - int _chalkboardStep = 0; bool _isDisposed = false; bool _isMuted = false; @@ -80,40 +75,49 @@ class _SocraticVideoPlayerScreenState extends State w }); } + String _safeVideoSourceLabel(String value) { + final uri = Uri.tryParse(value); + if (uri == null) return 'رابط فيديو غير صالح'; + if (!uri.hasAuthority) return uri.path; + return '${uri.host}${uri.path}'; + } + void _stopAllAudioAndVideo() { - AppLogger.log('🛑 [AUDIO_VIDEO] Stopping all audio, video, and speech synthesis...', tag: 'VIDEO_LIFECYCLE'); - try { - _tts.stop(); - } catch (e) { - AppLogger.error('Error stopping TTS', error: e, tag: 'VIDEO_LIFECYCLE'); - } + AppLogger.log( + '🛑 [AUDIO_VIDEO] Stopping all audio, video, and speech synthesis...', + tag: 'VIDEO_LIFECYCLE'); if (_videoController != null) { try { _videoController!.pause(); _videoController!.setVolume(0.0); _videoController!.dispose(); } catch (e) { - AppLogger.error('Error disposing video controller', error: e, tag: 'VIDEO_LIFECYCLE'); + AppLogger.error('Error disposing video controller', + error: e, tag: 'VIDEO_LIFECYCLE'); } _videoController = null; _isVideoInitialized = false; } } - Future _initPlayer(String videoUrl, int resumePos, bool shouldPlay) async { + Future _initPlayer( + String videoUrl, int resumePos, bool shouldPlay) async { if (_isDisposed || !mounted) return; - AppLogger.log('🎬 [VIDEO_INIT_REQUEST] Received raw video URL for lesson: "${widget.lesson.title}"\n' - ' • Raw URL: "$videoUrl"\n' + AppLogger.log( + '🎬 [VIDEO_INIT_REQUEST] Initializing lesson: "${widget.lesson.title}"\n' + ' • Source: ${_safeVideoSourceLabel(videoUrl)}\n' ' • Resume Position: ${resumePos}s\n' ' • Should AutoPlay: $shouldPlay\n' ' • Curriculum Lesson ID: ${widget.lesson.curriculumLessonId}', tag: 'VIDEO_PLAYER'); if (videoUrl.isEmpty) { - AppLogger.error('Video URL is empty for lesson: ${widget.lesson.title}', tag: 'VIDEO_PLAYER'); + AppLogger.error('Video URL is empty for lesson: ${widget.lesson.title}', + tag: 'VIDEO_PLAYER'); if (mounted && !_isDisposed) { - setState(() => _videoInitError = 'لم يتم ربط فيديو معتمد لهذا الدرس بعد.'); + setState( + () => _videoInitError = 'لم يتم ربط فيديو معتمد لهذا الدرس بعد.'); } return; } @@ -124,7 +128,9 @@ class _SocraticVideoPlayerScreenState extends State w if (finalVideoUrl.startsWith('/')) { final base = AppConfig.baseUrl.replaceAll(RegExp(r'/+$'), ''); finalVideoUrl = '$base$finalVideoUrl'; - AppLogger.log('🔗 [URL_RESOLVE] Resolved relative path to absolute: $finalVideoUrl', tag: 'VIDEO_PLAYER'); + AppLogger.log( + '🔗 [URL_RESOLVE] Resolved source: ${_safeVideoSourceLabel(finalVideoUrl)}', + tag: 'VIDEO_PLAYER'); } // 2. Attach authentication token for our backend streaming endpoints @@ -134,18 +140,21 @@ class _SocraticVideoPlayerScreenState extends State w finalVideoUrl.contains('localhost') || finalVideoUrl.contains('127.0.0.1'); - final headers = { - 'User-Agent': 'SaqelEdTech/2.0 (macOS; Grade10-Production)', - }; + // Cloudflare R2 rejects the former app-specific User-Agent (HTTP 403, + // error 1010). Keep the native player's User-Agent for public media. + final headers = {}; if (isOurBackend && token != null && token.isNotEmpty) { headers['Authorization'] = 'Bearer $token'; if (!finalVideoUrl.contains('token=')) { final sep = finalVideoUrl.contains('?') ? '&' : '?'; - finalVideoUrl = '$finalVideoUrl${sep}token=${Uri.encodeComponent(token)}'; + finalVideoUrl = + '$finalVideoUrl${sep}token=${Uri.encodeComponent(token)}'; } } - AppLogger.log('🚀 [VIDEO_CONNECT] Connecting to real video stream: $finalVideoUrl', tag: 'VIDEO_PLAYER'); + AppLogger.log( + '🚀 [VIDEO_CONNECT] Connecting to video source: ${_safeVideoSourceLabel(finalVideoUrl)}', + tag: 'VIDEO_PLAYER'); // Cleanly stop any existing controller before assigning a new one _stopAllAudioAndVideo(); @@ -158,14 +167,17 @@ class _SocraticVideoPlayerScreenState extends State w controller.initialize().then((_) { if (_isDisposed || !mounted) { - AppLogger.log('🛑 [LIFECYCLE_ABORT] Screen disposed before video completed loading. Silencing controller.', tag: 'VIDEO_LIFECYCLE'); + AppLogger.log( + '🛑 [LIFECYCLE_ABORT] Screen disposed before video completed loading. Silencing controller.', + tag: 'VIDEO_LIFECYCLE'); controller.pause(); controller.setVolume(0.0); controller.dispose(); return; } - AppLogger.log('✅ [VIDEO_READY] Video successfully initialized!\n' + AppLogger.log( + '✅ [VIDEO_READY] Video successfully initialized!\n' ' • Duration: ${controller.value.duration.inSeconds}s (${_formatTime(controller.value.duration.inSeconds)})\n' ' • Resolution: ${controller.value.size.width.toInt()}x${controller.value.size.height.toInt()}\n' ' • Aspect Ratio: ${controller.value.aspectRatio.toStringAsFixed(2)}', @@ -176,6 +188,7 @@ class _SocraticVideoPlayerScreenState extends State w _videoInitError = null; if (_isMuted) controller.setVolume(0.0); }); + context.read().startWatchSessionAfterPlaybackReady(); _revealVideoControls(); if (resumePos > 3 && resumePos < controller.value.duration.inSeconds) { @@ -191,71 +204,50 @@ class _SocraticVideoPlayerScreenState extends State w if (shouldPlay && !_isDisposed) { controller.play(); - AppLogger.log('▶️ [VIDEO_PLAY] Playback started naturally.', tag: 'VIDEO_PLAYER'); + AppLogger.log('▶️ [VIDEO_PLAY] Playback started naturally.', + tag: 'VIDEO_PLAYER'); } - }).catchError((err, stack) { + }).catchError((err, _) { final errStr = err.toString(); - AppLogger.error( - '🚨 [VIDEO_PLAYER_ERROR] Real video playback failed!\n' - ' • Target URL: $finalVideoUrl\n' - ' • Error: $errStr', - error: err, - stackTrace: stack, - tag: 'VIDEO_PLAYER', - ); - if (mounted && !_isDisposed) { - final is403 = errStr.contains('403') || errStr.contains('permission') || errStr.contains('-12660'); + final is403 = errStr.contains('403') || + errStr.contains('permission') || + errStr.contains('-12660'); final is404 = errStr.contains('404') || errStr.contains('not found'); - - // Automatic Resilience Fallback: - // If the CDN domain is unconfigured (like saqel.b-cdn.net returning 403 Forbidden) or returns 404, - // automatically fallback to an authorized high-performance verified HLS educational stream - // so the video ACTUALLY PLAYS with sound, controls, timeline, and socratic checkpoints! - if ((is403 || is404 || finalVideoUrl.contains('saqel.b-cdn.net')) && - !finalVideoUrl.contains('mux.dev') && - !finalVideoUrl.contains('apple.com')) { - AppLogger.log('🔄 [VIDEO_FALLBACK] CDN unconfigured (403). Seamlessly switching to verified HLS stream...', tag: 'VIDEO_PLAYER'); - const fallbackStream = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8'; - _initPlayer(fallbackStream, resumePos, shouldPlay); - return; - } + + AppLogger.error( + 'Video playback initialization failed for ' + '${_safeVideoSourceLabel(finalVideoUrl)} ' + '(${is403 ? '403' : is404 ? '404' : 'unclassified'}).', + tag: 'VIDEO_PLAYER', + ); setState(() { _isVideoInitialized = false; - _showChalkboardMode = true; // Auto-activate the Socratic Smart Chalkboard if (is403) { - _videoInitError = 'بث الفيديو السحابي قيد المزامنة على مزود الاستضافة (HTTP 403).\nيمكنك متابعة دراسة الحصة فوراً عبر السبورة السقراطية الذكية بالصوت 🎙️ أو فتح المختبر التفاعلي 🔬.'; + _videoInitError = + 'تعذر الوصول إلى فيديو هذه الحصة المنشورة (HTTP 403). لم يبدأ تشغيل الفيديو. ارجع إلى فهرس الدروس أو حاول مجدداً لاحقاً.'; } else if (is404) { - _videoInitError = 'ملف الفيديو غير متوفر حالياً على الخادم (HTTP 404).\nيمكنك متابعة الدرس عبر السبورة السقراطية الذكية بالصوت 🎙️ أو فتح المختبر التفاعلي 🔬.'; + _videoInitError = + 'فيديو هذه الحصة المنشورة غير موجود حالياً (HTTP 404). لم يبدأ تشغيل الفيديو. ارجع إلى فهرس الدروس أو حاول مجدداً لاحقاً.'; } else { - _videoInitError = 'تعذر تشغيل الفيديو: $errStr'; + _videoInitError = + 'تعذر تهيئة بث الفيديو. لم يبدأ التشغيل. حاول مجدداً لاحقاً.'; } }); } }); } - Future _speakConcept(String text) async { - if (text.isEmpty || _isDisposed || _isMuted) return; - setState(() => _isSpeakingChalkboard = true); - try { - await _tts.setLanguage('ar'); - await _tts.setSpeechRate(0.48); - _tts.setCompletionHandler(() { - if (mounted && !_isDisposed) setState(() => _isSpeakingChalkboard = false); - }); - await _tts.speak(text); - } catch (_) { - if (mounted && !_isDisposed) setState(() => _isSpeakingChalkboard = false); - } - } - @override void initState() { super.initState(); - AppLogger.log('📱 [SCREEN_ENTER] Opened SocraticVideoPlayerScreen for lesson: ${widget.lesson.title}', tag: 'VIDEO_LIFECYCLE'); - context.read().loadLesson(widget.lesson, subject: widget.subject, selectedVideoVersionId: widget.selectedVideoVersionId); + AppLogger.log( + '📱 [SCREEN_ENTER] Opened SocraticVideoPlayerScreen for lesson: ${widget.lesson.title}', + tag: 'VIDEO_LIFECYCLE'); + context.read().loadLesson(widget.lesson, + subject: widget.subject, + selectedVideoVersionId: widget.selectedVideoVersionId); // Dynamic Floating Forensic Anti-Piracy Watermark Animation _watermarkController = AnimationController( @@ -284,7 +276,8 @@ class _SocraticVideoPlayerScreenState extends State w (duration > 0 && position >= duration - 1); if (shouldSync && position != _lastSyncedPosition) { _lastSyncedPosition = position; - cubit.saveProgress(positionSeconds: position, watchedSeconds: position); + cubit.saveProgress( + positionSeconds: position, watchedSeconds: position); } } }); @@ -292,7 +285,9 @@ class _SocraticVideoPlayerScreenState extends State w @override void deactivate() { - AppLogger.log('⏸️ [SCREEN_DEACTIVATE] Deactivating video player screen. Stopping audio & video...', tag: 'VIDEO_LIFECYCLE'); + AppLogger.log( + '⏸️ [SCREEN_DEACTIVATE] Deactivating video player screen. Stopping audio & video...', + tag: 'VIDEO_LIFECYCLE'); _isDisposed = true; _stopAllAudioAndVideo(); super.deactivate(); @@ -300,7 +295,9 @@ class _SocraticVideoPlayerScreenState extends State w @override void dispose() { - AppLogger.log('🧹 [SCREEN_DISPOSE] Disposing video player screen resources.', tag: 'VIDEO_LIFECYCLE'); + AppLogger.log( + '🧹 [SCREEN_DISPOSE] Disposing video player screen resources.', + tag: 'VIDEO_LIFECYCLE'); _isDisposed = true; context.read().endWatchSession(); _stopAllAudioAndVideo(); @@ -319,8 +316,10 @@ class _SocraticVideoPlayerScreenState extends State w @override Widget build(BuildContext context) { final authState = context.read().state; - final studentName = authState is Authenticated ? authState.user.name : 'طالب صَقِل'; - final studentPhone = authState is Authenticated ? authState.user.phone : '0798583052'; + final studentName = + authState is Authenticated ? authState.user.name : 'طالب صَقِل'; + final studentPhone = + authState is Authenticated ? authState.user.phone : '0798583052'; return Scaffold( backgroundColor: AppColors.darkBackground, @@ -333,7 +332,8 @@ class _SocraticVideoPlayerScreenState extends State w ), title: Text( widget.lesson.title, - style: AppTypography.titleLarge.copyWith(color: Colors.white, fontSize: 16), + style: AppTypography.titleLarge + .copyWith(color: Colors.white, fontSize: 16), ), centerTitle: false, actions: [ @@ -347,9 +347,10 @@ class _SocraticVideoPlayerScreenState extends State w setState(() { _isMuted = !_isMuted; _videoController?.setVolume(_isMuted ? 0.0 : 1.0); - if (_isMuted) _tts.stop(); }); - AppLogger.log('🔊 [AUDIO_TOGGLE] User toggled audio: ${_isMuted ? "MUTED" : "UNMUTED"}', tag: 'VIDEO_PLAYER'); + AppLogger.log( + '🔊 [AUDIO_TOGGLE] User toggled audio: ${_isMuted ? "MUTED" : "UNMUTED"}', + tag: 'VIDEO_PLAYER'); }, ), Container( @@ -363,11 +364,15 @@ class _SocraticVideoPlayerScreenState extends State w child: const Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(CupertinoIcons.shield_lefthalf_fill, color: AppColors.saqelCyan, size: 14), + Icon(CupertinoIcons.shield_lefthalf_fill, + color: AppColors.saqelCyan, size: 14), SizedBox(width: 6), Text( 'محتوى محمي', - style: TextStyle(color: AppColors.saqelCyan, fontSize: 11, fontWeight: FontWeight.w700), + style: TextStyle( + color: AppColors.saqelCyan, + fontSize: 11, + fontWeight: FontWeight.w700), ), ], ), @@ -391,8 +396,12 @@ class _SocraticVideoPlayerScreenState extends State w } else { _videoController!.pause(); } - if ((state.currentPositionSeconds - _videoController!.value.position.inSeconds).abs() > 2) { - _videoController!.seekTo(Duration(seconds: state.currentPositionSeconds)); + if ((state.currentPositionSeconds - + _videoController!.value.position.inSeconds) + .abs() > + 2) { + _videoController! + .seekTo(Duration(seconds: state.currentPositionSeconds)); } } @@ -404,11 +413,17 @@ class _SocraticVideoPlayerScreenState extends State w builder: (modalContext) => SocraticCheckpointModal( checkpoint: state.activeCheckpoint!, onOptionSelected: (opt) { - final isCorrect = context.read().submitCheckpointAnswer(opt); + final isCorrect = context + .read() + .submitCheckpointAnswer(opt); if (isCorrect) { - SaqelToast.showSuccess(context, 'إجابة صحيحة، تم تسجيل تقدمك.', title: 'نقاط الفهم ✨'); + SaqelToast.showSuccess( + context, 'إجابة صحيحة، تم تسجيل تقدمك.', + title: 'نقاط الفهم ✨'); } else { - SaqelToast.showError(context, 'تم إرجاع الفيديو لإعادة الاستماع للنقطة الجوهرية 🔄', title: 'سد الثغرة العلاجية'); + SaqelToast.showError(context, + 'تم إرجاع الفيديو لإعادة الاستماع للنقطة الجوهرية 🔄', + title: 'سد الثغرة العلاجية'); } }, ), @@ -428,13 +443,21 @@ class _SocraticVideoPlayerScreenState extends State w child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(CupertinoIcons.exclamationmark_triangle_fill, color: AppColors.crimsonRed, size: 48), + const Icon(CupertinoIcons.exclamationmark_triangle_fill, + color: AppColors.crimsonRed, size: 48), const SizedBox(height: 16), - Text(state.message, style: const TextStyle(color: Colors.white)), + Text(state.message, + style: const TextStyle(color: Colors.white)), const SizedBox(height: 16), LuxuryButton( text: 'إعادة المحاولة', - onPressed: () => context.read().loadLesson(widget.lesson), + onPressed: () => context + .read() + .loadLesson( + widget.lesson, + subject: widget.subject, + selectedVideoVersionId: widget.selectedVideoVersionId, + ), ), ], ), @@ -442,9 +465,11 @@ class _SocraticVideoPlayerScreenState extends State w } if (state is VideoPlaybackReady) { - final duration = state.playbackData.durationSeconds > 0 ? state.playbackData.durationSeconds : 960; - final current = state.currentPositionSeconds; - final progress = (current / duration).clamp(0.0, 1.0); + final duration = state.playbackData.durationSeconds; + final current = + _isVideoInitialized ? state.currentPositionSeconds : 0; + final progress = + duration > 0 ? (current / duration).clamp(0.0, 1.0) : 0.0; return SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), @@ -474,136 +499,580 @@ class _SocraticVideoPlayerScreenState extends State w behavior: HitTestBehavior.translucent, onTap: _revealVideoControls, child: ClipRRect( - borderRadius: BorderRadius.circular(24), - child: Stack( - children: [ - // Video Background Scene - Stack( + borderRadius: BorderRadius.circular(24), + child: Stack( children: [ - if (_videoController != null && - _isVideoInitialized && - _videoController!.value.isInitialized && - !_videoController!.value.hasError) - Positioned.fill( - child: Center( - child: AspectRatio( - aspectRatio: _videoController!.value.aspectRatio > 0 - ? _videoController!.value.aspectRatio - : 16 / 9, - child: VideoPlayer(_videoController!), + // Video Background Scene + Stack( + children: [ + if (_videoController != null && + _isVideoInitialized && + _videoController!.value.isInitialized && + !_videoController!.value.hasError) + Positioned.fill( + child: Center( + child: AspectRatio( + aspectRatio: _videoController! + .value.aspectRatio > + 0 + ? _videoController! + .value.aspectRatio + : 16 / 9, + child: + VideoPlayer(_videoController!), + ), + ), + ) + else + Positioned.fill( + child: Container( + decoration: const BoxDecoration( + gradient: RadialGradient( + center: Alignment(0.0, -0.3), + radius: 1.3, + colors: [ + Color(0xFF0F2342), + Color(0xFF060B14), + ], + ), + ), + ), ), - ), - ) - else - Positioned.fill( - child: Container( - decoration: const BoxDecoration( - gradient: RadialGradient( - center: Alignment(0.0, -0.3), - radius: 1.3, - colors: [ - Color(0xFF0F2342), - Color(0xFF060B14), - ], + // Grid Overlay Effect + Positioned.fill( + child: Opacity( + opacity: 0.08, + child: CustomPaint( + painter: _GridPatternPainter(), ), ), ), - ), - // Grid Overlay Effect - Positioned.fill( - child: Opacity( - opacity: 0.08, - child: CustomPaint( - painter: _GridPatternPainter(), + // Center Lesson Title & Visuals / Interactive Socratic Chalkboard + if (_videoInitError != null) + Positioned.fill( + child: Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 18), + margin: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: + Colors.black.withAlpha(210), + borderRadius: + BorderRadius.circular(18), + border: Border.all( + color: AppColors.saqelCyan + .withAlpha(90)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + CupertinoIcons + .play_circle_fill, + color: AppColors.saqelCyan, + size: 50), + const SizedBox(height: 12), + Text( + widget.lesson.title, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: + FontWeight.w700), + ), + const SizedBox(height: 6), + Text( + _videoInitError!, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors + .textSecondaryDark, + fontSize: 12), + ), + const SizedBox(height: 14), + Wrap( + alignment: + WrapAlignment.center, + spacing: 12, + runSpacing: 10, + children: [ + OutlinedButton.icon( + icon: const Icon( + CupertinoIcons + .arrow_clockwise, + size: 16, + color: + Colors.white70), + label: const Text( + 'إعادة المحاولة 🔄', + style: TextStyle( + color: Colors + .white70)), + style: OutlinedButton + .styleFrom( + side: const BorderSide( + color: + Colors.white24), + padding: + const EdgeInsets + .symmetric( + horizontal: 14, + vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius + .circular( + 14)), + ), + onPressed: () { + setState(() { + _videoInitError = + null; + _isVideoInitialized = + false; + }); + _initPlayer( + state.playbackData + .videoUrl, + state + .currentPositionSeconds, + true, + ); + }, + ), + ], + ), + ], + ), + ), + ), + ) + else if (!_isVideoInitialized) + Positioned.fill( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CupertinoActivityIndicator( + color: AppColors.saqelCyan, + radius: 18), + const SizedBox(height: 14), + Text( + 'جاري تهيئة البث المباشر للشرح (${widget.lesson.title}) ⚡', + style: const TextStyle( + color: Colors.white70, + fontSize: 13, + fontWeight: + FontWeight.w600), + ), + ], + ), + ), + ) + else if (_isVideoInitialized && + _showVideoControls) + Center( + child: Column( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + // Dynamic Play/Pause Button + InkWell( + onTap: () => context + .read() + .togglePlayPause(), + borderRadius: + BorderRadius.circular(40), + child: Container( + width: 72, + height: 72, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: + _isVideoInitialized && + state.isPlaying + ? [ + AppColors + .saqelCyan + .withAlpha( + 40), + AppColors + .appleBlue + .withAlpha( + 30) + ] + : [ + AppColors + .appleBlue, + AppColors + .saqelCyan + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + shape: BoxShape.circle, + border: Border.all( + color: + _isVideoInitialized && + state.isPlaying + ? AppColors + .saqelCyan + .withAlpha(120) + : Colors.white, + width: 2, + ), + boxShadow: [ + BoxShadow( + color: AppColors.saqelCyan + .withAlpha( + (_isVideoInitialized && + state + .isPlaying) + ? 40 + : 80), + blurRadius: 24, + ), + ], + ), + child: Icon( + (_isVideoInitialized && + state.isPlaying) + ? CupertinoIcons + .pause_fill + : CupertinoIcons + .play_fill, + color: _isVideoInitialized && + state.isPlaying + ? AppColors.saqelCyan + : Colors.black, + size: 32, + ), + ), + ), + const SizedBox(height: 18), + Padding( + padding: + const EdgeInsets.symmetric( + horizontal: 32), + child: Text( + widget.lesson.title, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w800, + letterSpacing: -0.2, + ), + ), + ), + const SizedBox(height: 6), + Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: + _isVideoInitialized && + state.isPlaying + ? AppColors + .emeraldGreen + : AppColors + .guardianAmber, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Text( + (_isVideoInitialized && + state.isPlaying) + ? 'جاري الشرح التفاعلي ⚡' + : 'الفيديو متوقف مؤقتاً', + style: const TextStyle( + color: AppColors + .textSecondaryDark, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ), ), - ), - ), - // Center Lesson Title & Visuals / Interactive Socratic Chalkboard - if (_showChalkboardMode) - Positioned.fill( - child: _buildInteractiveChalkboard(state), - ) - else if (_videoInitError != null) - Positioned.fill( - child: Center( + // Top Action Badges & Mode Switcher + if (_isVideoInitialized && + _showVideoControls) ...[ + Positioned( + top: 14, + right: 14, child: Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 18), - margin: const EdgeInsets.all(20), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 5), decoration: BoxDecoration( - color: Colors.black.withAlpha(210), - borderRadius: BorderRadius.circular(18), - border: Border.all(color: AppColors.saqelCyan.withAlpha(90)), + color: Colors.black.withAlpha(140), + borderRadius: + BorderRadius.circular(10), + border: Border.all( + color: AppColors.saqelCyan + .withAlpha(60)), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(CupertinoIcons.sparkles, + color: AppColors.saqelCyan, + size: 12), + SizedBox(width: 6), + Text( + 'نقاط الفهم', + style: TextStyle( + color: AppColors.saqelCyan, + fontSize: 11, + fontWeight: + FontWeight.w700), + ), + ], + ), + ), + ), + ], + ], + ), + + // Dynamic Forensic Anti-Piracy Watermark (Moves continuously) + AnimatedBuilder( + animation: _watermarkController, + builder: (context, child) { + return Align( + alignment: Alignment( + -0.8 + + (1.6 * _watermarkController.value), + -0.7 + + (1.4 * _watermarkController.value), + ), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withAlpha(100), + borderRadius: + BorderRadius.circular(8), + ), + child: Text( + '$studentName • $studentPhone', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Colors.white.withAlpha(90), + letterSpacing: 0.5, + ), + ), + ), + ); + }, + ), + + // Video Controls Overlay (Bottom), hidden after inactivity. + if (_isVideoInitialized && + _videoController != null && + _videoController!.value.isInitialized && + !_videoController!.value.hasError) + IgnorePointer( + ignoring: !_showVideoControls, + child: AnimatedOpacity( + opacity: _showVideoControls ? 1 : 0, + duration: + const Duration(milliseconds: 220), + child: Align( + alignment: Alignment.bottomCenter, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 12), + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Color(0xE6000000) + ], + ), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Icon(CupertinoIcons.play_circle_fill, color: AppColors.saqelCyan, size: 50), - const SizedBox(height: 12), - Text( - widget.lesson.title, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w700), + LayoutBuilder( + builder: + (context, constraints) { + const double + horizontalPadding = 24.0; + final double trackWidth = + (constraints.maxWidth - + (horizontalPadding * + 2)) + .clamp(10.0, 4000.0); + + return Stack( + alignment: + Alignment.centerLeft, + children: [ + SliderTheme( + data: SliderTheme.of( + context) + .copyWith( + trackHeight: 5, + thumbShape: + const RoundSliderThumbShape( + enabledThumbRadius: + 7), + overlayShape: + const RoundSliderOverlayShape( + overlayRadius: + 14), + activeTrackColor: + AppColors + .saqelCyan, + inactiveTrackColor: + Colors.white24, + thumbColor: AppColors + .saqelCyan, + ), + child: Slider( + value: progress, + onChanged: + duration > 0 + ? (val) { + final targetSec = + (val * duration) + .toInt(); + context + .read< + VideoPlaybackCubit>() + .seekTo( + targetSec); + } + : null, + ), + ), + // Checkpoint markers on the progress track + if (duration > 0) + ...state.playbackData + .checkpoints + .map((cp) { + final posFraction = + (cp.timestampSeconds / + duration) + .clamp( + 0.0, 1.0); + final isPassed = state + .passedCheckpointIds + .contains(cp.id); + final dotLeft = + horizontalPadding + + (trackWidth * + posFraction) - + 5.0; + + return Positioned( + left: dotLeft, + child: + IgnorePointer( + child: Container( + width: 10, + height: 10, + decoration: + BoxDecoration( + color: isPassed + ? AppColors + .emeraldGreen + : AppColors + .guardianAmber, + shape: BoxShape + .circle, + border: Border.all( + color: Colors + .white, + width: + 1.5), + boxShadow: [ + BoxShadow( + color: (isPassed + ? AppColors.emeraldGreen + : AppColors.guardianAmber) + .withAlpha(120), + blurRadius: + 6, + ), + ], + ), + ), + ), + ); + }), + ], + ); + }, ), - const SizedBox(height: 6), - Text( - _videoInitError!, - textAlign: TextAlign.center, - style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 12), - ), - const SizedBox(height: 14), - Wrap( - alignment: WrapAlignment.center, - spacing: 12, - runSpacing: 10, + Row( children: [ - ElevatedButton.icon( - icon: const Icon(CupertinoIcons.mic_fill, size: 16, color: Colors.black), - label: const Text('بدء الشرح على السبورة الذكية 🎙️', style: TextStyle(color: Colors.black, fontWeight: FontWeight.w800, fontSize: 13)), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.saqelCyan, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + IconButton( + icon: Icon( + state.isPlaying + ? CupertinoIcons + .pause_fill + : CupertinoIcons + .play_fill, + color: Colors.white, + size: 20, ), - onPressed: () { - setState(() { - _showChalkboardMode = true; - _videoInitError = null; - }); - }, + onPressed: () => context + .read< + VideoPlaybackCubit>() + .togglePlayPause(), ), - ElevatedButton.icon( - icon: const Icon(CupertinoIcons.sparkles, size: 16, color: Colors.white), - label: const Text('المختبر التفاعلي للمبحث 🔬', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 13)), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.appleBlue, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + const SizedBox(width: 8), + Directionality( + textDirection: + TextDirection.ltr, + child: Text( + '${_formatTime(current)} / ${duration > 0 ? _formatTime(duration) : '--:--'}', + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + fontWeight: + FontWeight.w600, + fontFamily: + 'SF Pro Text', + ), ), - onPressed: () { - Navigator.of(context).pop(); - }, ), - OutlinedButton.icon( - icon: const Icon(CupertinoIcons.arrow_clockwise, size: 16, color: Colors.white70), - label: const Text('إعادة المحاولة 🔄', style: TextStyle(color: Colors.white70)), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: Colors.white24), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + const Spacer(), + Container( + padding: const EdgeInsets + .symmetric( + horizontal: 8, + vertical: 3), + decoration: BoxDecoration( + color: + AppColors.darkSurface, + borderRadius: + BorderRadius.circular( + 8), + ), + child: Text( + '${state.passedCheckpointIds.length}/${state.playbackData.checkpoints.length} فحص منجز', + style: const TextStyle( + color: AppColors + .saqelCyan, + fontSize: 11, + fontWeight: + FontWeight.w700), ), - onPressed: () { - setState(() { - _videoInitError = null; - _isVideoInitialized = false; - }); - _initPlayer( - state.playbackData.videoUrl, - state.currentPositionSeconds, - true, - ); - }, ), ], ), @@ -611,330 +1080,14 @@ class _SocraticVideoPlayerScreenState extends State w ), ), ), - ) - else if (!_isVideoInitialized) - Positioned.fill( - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CupertinoActivityIndicator(color: AppColors.saqelCyan, radius: 18), - const SizedBox(height: 14), - Text( - 'جاري تهيئة البث المباشر للشرح (${widget.lesson.title}) ⚡', - style: const TextStyle(color: Colors.white70, fontSize: 13, fontWeight: FontWeight.w600), - ), - ], - ), - ), - ) - else if (_showVideoControls) Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Dynamic Play/Pause Button - InkWell( - onTap: () => context.read().togglePlayPause(), - borderRadius: BorderRadius.circular(40), - child: Container( - width: 72, - height: 72, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: state.isPlaying - ? [AppColors.saqelCyan.withAlpha(40), AppColors.appleBlue.withAlpha(30)] - : [AppColors.appleBlue, AppColors.saqelCyan], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - shape: BoxShape.circle, - border: Border.all( - color: state.isPlaying ? AppColors.saqelCyan.withAlpha(120) : Colors.white, - width: 2, - ), - boxShadow: [ - BoxShadow( - color: AppColors.saqelCyan.withAlpha(state.isPlaying ? 40 : 80), - blurRadius: 24, - ), - ], - ), - child: Icon( - state.isPlaying ? CupertinoIcons.pause_fill : CupertinoIcons.play_fill, - color: state.isPlaying ? AppColors.saqelCyan : Colors.black, - size: 32, - ), - ), - ), - const SizedBox(height: 18), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Text( - widget.lesson.title, - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w800, - letterSpacing: -0.2, - ), - ), - ), - const SizedBox(height: 6), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: state.isPlaying ? AppColors.emeraldGreen : AppColors.guardianAmber, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8), - Text( - state.isPlaying ? 'جاري الشرح التفاعلي ⚡' : 'الفيديو متوقف مؤقتاً', - style: const TextStyle( - color: AppColors.textSecondaryDark, - fontSize: 12, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ], ), ), - // Top Action Badges & Mode Switcher - if (_showVideoControls) ...[ - Positioned( - top: 14, - right: 14, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: Colors.black.withAlpha(140), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppColors.saqelCyan.withAlpha(60)), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(CupertinoIcons.sparkles, color: AppColors.saqelCyan, size: 12), - SizedBox(width: 6), - Text( - 'نقاط الفهم', - style: TextStyle(color: AppColors.saqelCyan, fontSize: 11, fontWeight: FontWeight.w700), - ), - ], - ), - ), - ), - Positioned( - top: 14, - left: 14, - child: GestureDetector( - onTap: () { - setState(() => _showChalkboardMode = !_showChalkboardMode); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: Colors.black.withAlpha(160), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppColors.saqelCyan.withAlpha(90)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - _showChalkboardMode ? CupertinoIcons.film : CupertinoIcons.square_pencil, - color: AppColors.saqelCyan, - size: 13, - ), - const SizedBox(width: 6), - Text( - _showChalkboardMode ? 'عرض الفيديو 🎥' : 'لوحة الشرح 📝', - style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w700), - ), - ], - ), - ), - ), - ), - ], - ], - ), - - // Dynamic Forensic Anti-Piracy Watermark (Moves continuously) - AnimatedBuilder( - animation: _watermarkController, - builder: (context, child) { - return Align( - alignment: Alignment( - -0.8 + (1.6 * _watermarkController.value), - -0.7 + (1.4 * _watermarkController.value), - ), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: Colors.black.withAlpha(100), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - '$studentName • $studentPhone', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - color: Colors.white.withAlpha(90), - letterSpacing: 0.5, - ), - ), - ), - ); - }, + ], ), - - // Video Controls Overlay (Bottom), hidden after inactivity. - IgnorePointer( - ignoring: !_showVideoControls, - child: AnimatedOpacity( - opacity: _showVideoControls ? 1 : 0, - duration: const Duration(milliseconds: 220), - child: Align( - alignment: Alignment.bottomCenter, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.transparent, Color(0xE6000000)], - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - LayoutBuilder( - builder: (context, constraints) { - const double horizontalPadding = 24.0; - final double trackWidth = (constraints.maxWidth - (horizontalPadding * 2)).clamp(10.0, 4000.0); - - return Stack( - alignment: Alignment.centerLeft, - children: [ - SliderTheme( - data: SliderTheme.of(context).copyWith( - trackHeight: 5, - thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7), - overlayShape: const RoundSliderOverlayShape(overlayRadius: 14), - activeTrackColor: AppColors.saqelCyan, - inactiveTrackColor: Colors.white24, - thumbColor: AppColors.saqelCyan, - ), - child: Slider( - value: progress, - onChanged: (val) { - final targetSec = (val * duration).toInt(); - context.read().seekTo(targetSec); - }, - ), - ), - // Checkpoint markers on the progress track - if (duration > 0) - ...state.playbackData.checkpoints.map((cp) { - final posFraction = (cp.timestampSeconds / duration).clamp(0.0, 1.0); - final isPassed = state.passedCheckpointIds.contains(cp.id); - final dotLeft = horizontalPadding + (trackWidth * posFraction) - 5.0; - - return Positioned( - left: dotLeft, - child: IgnorePointer( - child: Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: isPassed ? AppColors.emeraldGreen : AppColors.guardianAmber, - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 1.5), - boxShadow: [ - BoxShadow( - color: (isPassed ? AppColors.emeraldGreen : AppColors.guardianAmber).withAlpha(120), - blurRadius: 6, - ), - ], - ), - ), - ), - ); - }), - ], - ); - }, - ), - Row( - children: [ - IconButton( - icon: Icon( - state.isPlaying ? CupertinoIcons.pause_fill : CupertinoIcons.play_fill, - color: Colors.white, - size: 20, - ), - onPressed: () => context.read().togglePlayPause(), - ), - const SizedBox(width: 8), - Directionality( - textDirection: TextDirection.ltr, - child: Text( - '${_formatTime(current)} / ${_formatTime(duration)}', - style: const TextStyle( - color: Colors.white70, - fontSize: 12, - fontWeight: FontWeight.w600, - fontFamily: 'SF Pro Text', - ), - ), - ), - const Spacer(), - IconButton( - tooltip: 'وقفة تفكير وبناء الحل خطوة بخطوة', - icon: const Icon(CupertinoIcons.timer, color: AppColors.guardianAmber, size: 20), - onPressed: () { - context.read().pause(); - ScaffoldedThinkingPauseSheet.show( - context, - lesson: widget.lesson, - subject: widget.subject, - ); - }, - ), - const SizedBox(width: 4), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: AppColors.darkSurface, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - '${state.passedCheckpointIds.length}/${state.playbackData.checkpoints.length} فحص منجز', - style: const TextStyle(color: AppColors.saqelCyan, fontSize: 11, fontWeight: FontWeight.w700), - ), - ), - ], - ), - ], - ), - ), - ), - ), - ), - ], ), ), ), ), - ), const SizedBox(height: 20), // Remediation Feedback Alert (if triggered) @@ -944,16 +1097,21 @@ class _SocraticVideoPlayerScreenState extends State w decoration: BoxDecoration( color: AppColors.saqelCyan.withAlpha(20), borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.saqelCyan.withAlpha(60)), + border: Border.all( + color: AppColors.saqelCyan.withAlpha(60)), ), child: Row( children: [ - const Icon(CupertinoIcons.sparkles, color: AppColors.saqelCyan, size: 20), + const Icon(CupertinoIcons.sparkles, + color: AppColors.saqelCyan, size: 20), const SizedBox(width: 12), Expanded( child: Text( state.remediationNotice!, - style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600), + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600), ), ), ], @@ -972,38 +1130,54 @@ class _SocraticVideoPlayerScreenState extends State w children: [ Text( 'نتاجات التعلم والمفاهيم الوزارية 🎯', - style: AppTypography.titleLarge.copyWith(color: Colors.white, fontSize: 16), + style: AppTypography.titleLarge.copyWith( + color: Colors.white, fontSize: 16), ), Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), decoration: BoxDecoration( color: AppColors.appleBlue.withAlpha(30), borderRadius: BorderRadius.circular(12), ), child: Text( widget.subject?.title ?? 'المنهاج الرسمي', - style: const TextStyle(color: AppColors.appleBlue, fontSize: 12, fontWeight: FontWeight.w700), + style: const TextStyle( + color: AppColors.appleBlue, + fontSize: 12, + fontWeight: FontWeight.w700), ), ), ], ), - if (widget.instructorName != null || widget.videoSourceLabel != null) ...[ + if (widget.instructorName != null || + widget.videoSourceLabel != null) ...[ const SizedBox(height: 10), Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 5), decoration: BoxDecoration( color: AppColors.saqelCyan.withAlpha(25), borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppColors.saqelCyan.withAlpha(60)), + border: Border.all( + color: AppColors.saqelCyan.withAlpha(60)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(CupertinoIcons.person_crop_circle_fill, color: AppColors.saqelCyan, size: 14), + const Icon( + CupertinoIcons.person_crop_circle_fill, + color: AppColors.saqelCyan, + size: 14), const SizedBox(width: 6), Text( - widget.instructorName ?? widget.videoSourceLabel ?? 'شرح منصة صَقِل الذكية 🤖', - style: const TextStyle(color: AppColors.saqelCyan, fontSize: 11, fontWeight: FontWeight.w700), + widget.instructorName ?? + widget.videoSourceLabel ?? + 'شرح منصة صَقِل الذكية 🤖', + style: const TextStyle( + color: AppColors.saqelCyan, + fontSize: 11, + fontWeight: FontWeight.w700), ), ], ), @@ -1017,12 +1191,18 @@ class _SocraticVideoPlayerScreenState extends State w child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(CupertinoIcons.check_mark_circled_solid, color: AppColors.saqelCyan, size: 16), + const Icon( + CupertinoIcons.check_mark_circled_solid, + color: AppColors.saqelCyan, + size: 16), const SizedBox(width: 10), Expanded( child: Text( out, - style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 13, height: 1.4), + style: const TextStyle( + color: AppColors.textSecondaryDark, + fontSize: 13, + height: 1.4), ), ), ], @@ -1031,112 +1211,14 @@ class _SocraticVideoPlayerScreenState extends State w }) else const Text( - 'شرح المفاهيم والقواعد الوزارية المقررة مع التطبيقات الحياتية ونماذج الامتحانات.', - style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 13), + 'لا توجد نتاجات تعلم منشورة لهذا الدرس بعد.', + style: TextStyle( + color: AppColors.textSecondaryDark, + fontSize: 13), ), ], ), ), - // Thinking Pause & Scaffolded Solution Construction Card - GestureDetector( - onTap: () { - context.read().pause(); - ScaffoldedThinkingPauseSheet.show( - context, - lesson: widget.lesson, - subject: widget.subject, - ); - }, - child: LuxuryCard( - borderColor: AppColors.guardianAmber.withAlpha(90), - child: Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: AppColors.guardianAmber.withAlpha(25), - borderRadius: BorderRadius.circular(12), - ), - child: const Icon(CupertinoIcons.timer, color: AppColors.guardianAmber, size: 24), - ), - const SizedBox(width: 14), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'وقفة تفكير ذهني وبناء الحل خطوة بخطوة ⏱️', - style: TextStyle(color: Colors.white, fontSize: 13.5, fontWeight: FontWeight.w800), - ), - SizedBox(height: 3), - Text( - 'تأمل المسألة لمدة 60 ثانية قبل كشف خطوات الإثبات والحل المنهجي.', - style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 11.5), - ), - ], - ), - ), - const Icon(CupertinoIcons.chevron_left, color: AppColors.guardianAmber, size: 18), - ], - ), - ), - ), - const SizedBox(height: 16), - - // Interactive Digital Chalkboard & Key Concept Breakdown - LuxuryCard( - borderColor: AppColors.appleBlue.withAlpha(40), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: AppColors.saqelCyan.withAlpha(25), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon(CupertinoIcons.square_pencil, color: AppColors.saqelCyan, size: 18), - ), - const SizedBox(width: 10), - const Text( - 'لوحة الشرح التفاعلية والملاحظات الجوهرية 📝', - style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w800), - ), - ], - ), - const SizedBox(height: 14), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFF07101E), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.darkCardBorder), - ), - child: const Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '📌 خطوات الحل المنهجية (وفق معايير التصحيح الوزاري):', - style: TextStyle(color: AppColors.saqelCyan, fontSize: 13, fontWeight: FontWeight.w700), - ), - SizedBox(height: 8), - Text( - '1. كتابة المعادلات في الصورة القياسية وتحديد نوع كل معادلة.\n' - '2. عزل أحد المتغيرين في المعادلة الخطية بالتعويض المباشر.\n' - '3. حل المعادلة الناتجة باستخدام التحليل إلى العوامل أو القانون العام.\n' - '4. التحقق من صحة الزوج المرتب (x, y) بالتعويض في كلا المعادلتين.', - style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 12.5, height: 1.6), - ), - ], - ), - ), - ], - ), - ), ], ), ), @@ -1148,314 +1230,6 @@ class _SocraticVideoPlayerScreenState extends State w ), ); } - - Widget _buildInteractiveChalkboard(VideoPlaybackReady state) { - final cp = state.playbackData.checkpoints.isNotEmpty ? state.playbackData.checkpoints.first : null; - final explanation = cp?.pedagogicalExplanation ?? - 'الدرس الوزاري المعتمد: يركز على إتقان المفاهيم الرياضية والعلمية وحل المسائل التحليلية خطوة بخطوة.'; - final hint = cp?.hint ?? 'راجع خطوات الحل ونظم المتغيرات بدقة.'; - final isMath = (widget.subject?.id ?? '').contains('math') || widget.lesson.title.contains('معادل') || widget.lesson.title.contains('رياضيات'); - - return Container( - padding: const EdgeInsets.all(20), - decoration: const BoxDecoration( - gradient: RadialGradient( - center: Alignment(0.0, -0.2), - radius: 1.2, - colors: [Color(0xFF0F2537), Color(0xFF070D18)], - ), - ), - child: Center( - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 5), - decoration: BoxDecoration( - color: AppColors.saqelCyan.withAlpha(30), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.saqelCyan.withAlpha(90)), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(CupertinoIcons.sparkles, color: AppColors.saqelCyan, size: 14), - SizedBox(width: 6), - Text( - 'لوحة الشرح والاستيعاب التفاعلية 💡', - style: TextStyle(color: AppColors.saqelCyan, fontSize: 12, fontWeight: FontWeight.w700), - ), - ], - ), - ), - const SizedBox(height: 12), - Text( - widget.lesson.title, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w800), - ), - const SizedBox(height: 10), - Container( - constraints: const BoxConstraints(maxWidth: 600), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: Colors.black.withAlpha(120), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: Colors.white12), - ), - child: Column( - children: [ - Text( - explanation, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white, fontSize: 13, height: 1.5), - ), - const SizedBox(height: 8), - Text( - '💡 إرشاد ذهني: $hint', - textAlign: TextAlign.center, - style: const TextStyle(color: AppColors.guardianAmber, fontSize: 11, fontWeight: FontWeight.w600), - ), - ], - ), - ), - if (isMath) _buildMathStepBoard(), - const SizedBox(height: 16), - Wrap( - alignment: WrapAlignment.center, - spacing: 10, - runSpacing: 8, - children: [ - ElevatedButton.icon( - icon: Icon(_isSpeakingChalkboard ? CupertinoIcons.waveform : CupertinoIcons.volume_up, size: 16), - label: Text(_isSpeakingChalkboard ? 'جاري الاستماع...' : 'استمع للشرح الصوتي 🎧'), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.saqelCyan, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - textStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - onPressed: () => _speakConcept('$explanation. $hint'), - ), - if (cp != null) - ElevatedButton.icon( - icon: const Icon(CupertinoIcons.question_circle_fill, size: 16), - label: const Text('فحص الفهم والاستيعاب ⚡'), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.appleBlue, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - textStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - onPressed: () { - showDialog( - context: context, - barrierDismissible: false, - builder: (modalContext) => SocraticCheckpointModal( - checkpoint: cp, - onOptionSelected: (opt) { - Navigator.of(modalContext).pop(); - context.read().submitCheckpointAnswer(opt); - }, - ), - ); - }, - ), - OutlinedButton.icon( - icon: const Icon(CupertinoIcons.timer, size: 16, color: AppColors.guardianAmber), - label: const Text('وقفة تفكير وبناء الحل (60 ثانية) ⏱️', style: TextStyle(color: AppColors.guardianAmber, fontSize: 12, fontWeight: FontWeight.w700)), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: AppColors.guardianAmber), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - onPressed: () { - context.read().pause(); - ScaffoldedThinkingPauseSheet.show( - context, - lesson: widget.lesson, - subject: widget.subject, - ); - }, - ), - if (isMath) - OutlinedButton.icon( - icon: const Icon(CupertinoIcons.function, size: 16, color: AppColors.saqelCyan), - label: const Text('معمل جيوجبرا للتحقق بيانياً 📐', style: TextStyle(color: AppColors.saqelCyan, fontSize: 12, fontWeight: FontWeight.w700)), - style: OutlinedButton.styleFrom( - side: const BorderSide(color: AppColors.saqelCyan), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - onPressed: () { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: AppColors.darkBackground, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - ), - builder: (ctx) => const SizedBox( - height: 600, - child: MathInteractiveLabView(), - ), - ); - }, - ), - ], - ), - const SizedBox(height: 12), - const Text( - 'فيديو المعلم قيد المزامنة الرقمية والتجهيز؛ تم تفعيل لوحة الشرح التفاعلية لضمان التعلم الفوري المستمر 🚀', - textAlign: TextAlign.center, - style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 11), - ), - ], - ), - ), - ), - ); - } - - Widget _buildMathStepBoard() { - final steps = [ - { - 'title': 'الخطوة 1: صياغة النظام الرياضي', - 'eq1': 'المعادلة الخطية: y = 2x - 2', - 'eq2': 'المعادلة التربيعية: y = x² - 4x + 3', - 'note': 'نظام مكون من معادلة خطية وأخرى تربيعية بمتغيرين (x, y).' - }, - { - 'title': 'الخطوة 2: التعويض ومساواة الطرفين', - 'eq1': 'x² - 4x + 3 = 2x - 2', - 'eq2': 'نعوض قيمة y من المعادلة الخطية في المعادلة التربيعية', - 'note': 'ينتج لدينا معادلة تربيعية بمتغير واحد فقط هو x.' - }, - { - 'title': 'الخطوة 3: التصفير والتجميع في الصورة القياسية', - 'eq1': 'x² - 4x - 2x + 3 + 2 = 0', - 'eq2': 'x² - 6x + 5 = 0', - 'note': 'المعاملات: a = 1, b = -6, c = 5 (المميز Δ = 36 - 20 = 16 > 0 له حلان حقيقيان).' - }, - { - 'title': 'الخطوة 4: التحليل إلى العوامل وإيجاد قيم x', - 'eq1': '(x - 5)(x - 1) = 0', - 'eq2': 'إما x = 5 أو x = 1', - 'note': 'أوجدنا الإحداثي السيني لنقطتي تقاطع المستقيم والقطع المكافئ.' - }, - { - 'title': 'الخطوة 5: التعويض لإيجاد y ومجموعة الحل', - 'eq1': 'عند x = 5: y = 2(5) - 2 = 8 ➔ (5, 8)', - 'eq2': 'عند x = 1: y = 2(1) - 2 = 0 ➔ (1, 0)', - 'note': 'مجموعة حل النظام: {(5, 8), (1, 0)} - تم التحقق بالتعويض.' - }, - ]; - - final cur = steps[_chalkboardStep.clamp(0, steps.length - 1)]; - - return Container( - constraints: const BoxConstraints(maxWidth: 620), - margin: const EdgeInsets.symmetric(vertical: 12), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFF0A1926), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.saqelCyan.withAlpha(120), width: 1.5), - boxShadow: [ - BoxShadow(color: AppColors.saqelCyan.withAlpha(20), blurRadius: 16, offset: const Offset(0, 4)), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: AppColors.saqelCyan.withAlpha(40), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - 'خطوة ${_chalkboardStep + 1} من ${steps.length}', - style: const TextStyle(color: AppColors.saqelCyan, fontSize: 12, fontWeight: FontWeight.w700), - ), - ), - Text( - cur['title']!, - style: const TextStyle(color: Colors.white, fontSize: 13.5, fontWeight: FontWeight.w800), - ), - ], - ), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.black.withAlpha(150), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.white10), - ), - child: Column( - children: [ - Text( - cur['eq1']!, - textAlign: TextAlign.center, - style: const TextStyle(color: Color(0xFFFFD60A), fontSize: 15, fontWeight: FontWeight.w800, letterSpacing: 0.5), - ), - if (cur['eq2']!.isNotEmpty) ...[ - const SizedBox(height: 6), - Text( - cur['eq2']!, - textAlign: TextAlign.center, - style: const TextStyle(color: Color(0xFF30D158), fontSize: 14.5, fontWeight: FontWeight.w700), - ), - ], - ], - ), - ), - const SizedBox(height: 8), - Text( - '💡 ${cur['note']!}', - style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 12, height: 1.4), - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - ElevatedButton.icon( - icon: const Icon(CupertinoIcons.arrow_right, size: 14), - label: const Text('السابقة'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.white12, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - ), - onPressed: _chalkboardStep > 0 - ? () => setState(() => _chalkboardStep--) - : null, - ), - ElevatedButton.icon( - icon: const Icon(CupertinoIcons.arrow_left, size: 14), - label: const Text('التالية'), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.saqelCyan, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - ), - onPressed: _chalkboardStep < steps.length - 1 - ? () => setState(() => _chalkboardStep++) - : null, - ), - ], - ), - ], - ), - ); - } } class _GridPatternPainter extends CustomPainter { diff --git a/apps/student_app/lib/presentation/screens/virtual_labs/labs_registry.dart b/apps/student_app/lib/presentation/screens/virtual_labs/labs_registry.dart index cab6247..a1a353c 100644 --- a/apps/student_app/lib/presentation/screens/virtual_labs/labs_registry.dart +++ b/apps/student_app/lib/presentation/screens/virtual_labs/labs_registry.dart @@ -368,7 +368,9 @@ class Grade10LabsRegistry { /// Whether to surface draft / unreleased virtual labs for local inspection and review. /// Defaults to true in development so that the reviewer/teacher can immediately test the labs. - static bool previewModeEnabled = true; + // Student builds must show released labs only. Draft previews stay available + // through explicit authoring/review tooling, never through the student flow. + static bool previewModeEnabled = false; /// Exact identity lookup strictly isolated per subject. /// When [previewModeEnabled] is active or [allowPreview] is true, returns the lab for testing. diff --git a/apps/student_app/lib/presentation/screens/virtual_labs/subject_virtual_labs_view.dart b/apps/student_app/lib/presentation/screens/virtual_labs/subject_virtual_labs_view.dart index 3f024b1..6b921e2 100644 --- a/apps/student_app/lib/presentation/screens/virtual_labs/subject_virtual_labs_view.dart +++ b/apps/student_app/lib/presentation/screens/virtual_labs/subject_virtual_labs_view.dart @@ -126,9 +126,6 @@ class _SubjectVirtualLabsViewState extends State { final labs = candidateLabs; if (labs.isEmpty) { - if (widget.specializedToolWidget != null) { - return widget.specializedToolWidget!; - } return Center( child: Padding( padding: const EdgeInsets.all(32), @@ -154,16 +151,6 @@ class _SubjectVirtualLabsViewState extends State { color: Color(0xFF94A3B8), fontSize: 11.5, height: 1.6), ), const SizedBox(height: 14), - TextButton.icon( - onPressed: () => _openGallery(context), - icon: const Icon(CupertinoIcons.square_grid_2x2, size: 16), - label: const Text('معرض المعاينة التأليفية', - style: - TextStyle(fontWeight: FontWeight.w800, fontSize: 12.5)), - style: TextButton.styleFrom( - foregroundColor: AppColors.saqelCyan, - ), - ), ], ), ), diff --git a/apps/student_app/test/scaffolded_learning_and_teacher_selection_test.dart b/apps/student_app/test/scaffolded_learning_and_teacher_selection_test.dart index 794e3dc..8534590 100644 --- a/apps/student_app/test/scaffolded_learning_and_teacher_selection_test.dart +++ b/apps/student_app/test/scaffolded_learning_and_teacher_selection_test.dart @@ -59,6 +59,7 @@ void main() { rating: 4.8, ratingCount: 95, isNew: false, + durationSeconds: 1200, ), const PublishedLessonVideoModel( videoVersionId: 'v_khalid', @@ -67,6 +68,7 @@ void main() { rating: 4.9, ratingCount: 140, isNew: false, + durationSeconds: 1200, ), ]; @@ -82,6 +84,8 @@ void main() { context, lesson: lesson, videos: videos, + nextCursor: null, + availableCount: videos.length, ); }, child: const Text('افتح اختيار المعلم'), diff --git a/apps/student_app/test/virtual_labs_smoke_test.dart b/apps/student_app/test/virtual_labs_smoke_test.dart index 733a1a2..fb1ed63 100644 --- a/apps/student_app/test/virtual_labs_smoke_test.dart +++ b/apps/student_app/test/virtual_labs_smoke_test.dart @@ -180,7 +180,7 @@ void main() { await t.pump(); expect(find.text('تجارب ومختبرات الفيزياء'), findsNothing); expect(find.text('لا مختبرات منشورة لمبحث الفيزياء بعد'), findsOneWidget); - expect(find.text('معرض المعاينة التأليفية'), findsOneWidget); + expect(find.text('معرض المعاينة التأليفية'), findsNothing); Grade10LabsRegistry.previewModeEnabled = true; }); @@ -911,4 +911,3 @@ void main() { }); } - diff --git a/apps/teacher_app/lib/core/config/app_config.dart b/apps/teacher_app/lib/core/config/app_config.dart index fa0b2f7..bf91c90 100644 --- a/apps/teacher_app/lib/core/config/app_config.dart +++ b/apps/teacher_app/lib/core/config/app_config.dart @@ -35,7 +35,7 @@ class AppConfig { static const String auditVideoEndpoint = '/api/teacher/audit-studio-video'; static const String uploadLessonEndpoint = '/api/teacher/lessons/upload'; static const String uploadVideoEndpoint = '/api/teacher/videos/upload-direct'; - static const String curriculumTreeEndpoint = '/api/curriculum/tree'; + static const String curriculumTreeEndpoint = '/api/teacher/curriculum/intake-tree'; static const String qnaEndpoint = '/api/teacher/qna'; static const String cliqPayoutEndpoint = '/api/teacher/payout/request'; diff --git a/backend/app/Controllers/AuthController.php b/backend/app/Controllers/AuthController.php index 4473ada..bb2b7f1 100644 --- a/backend/app/Controllers/AuthController.php +++ b/backend/app/Controllers/AuthController.php @@ -517,10 +517,9 @@ class AuthController if ($user) { $rawGrade = (string)($user['grade_level'] ?? 'grade_10'); $normGrade = \App\Services\StudentAccessControlService::normalizeGrade($rawGrade); - if ($rawGrade === 'tawjihi_2008' || $rawGrade !== $normGrade) { - Database::query("UPDATE students SET grade_level = ?, updated_at = NOW() WHERE id = ?", [$normGrade, $userId]); - $user['grade_level'] = $normGrade; - } + // Profile reads may normalize presentation, but must not rewrite + // a student's academic record as a side effect of GET. + $user['grade_level'] = $normGrade; $isCompleted = !empty($user['full_name']) && $user['full_name'] !== 'طالب جديد' && $user['full_name'] !== 'الطالب المتميز' && !empty($user['grade_level']); $userData = [ 'id' => $user['id'], @@ -586,11 +585,6 @@ class AuthController $student = Database::selectOne("SELECT * FROM students WHERE national_id_hash = ? LIMIT 1", [$nationalIdHash]); if ($student) { - if (($student['grade_level'] ?? '') === 'tawjihi_2008') { - Database::query("UPDATE students SET grade_level = 'grade_10', updated_at = NOW() WHERE id = ?", [$student['id']]); - $student['grade_level'] = 'grade_10'; - } - // Student exists. Verify identity linkage. if ($student['identity_id'] === null) { // Pre-registered by Guardian, link to this identity phone now @@ -736,41 +730,14 @@ class AuthController null ); } else { - // Legacy / Fallback for already logged-in students updating profile - $studentId = (int)$request->user_id; - if (!$studentId) { - $authHeader = $request->getHeader('authorization', ''); - if ($authHeader && preg_match('/Bearer\s(\S+)/i', $authHeader, $matches)) { - $payload = Security::verifyJWT($matches[1]); - if ($payload && isset($payload['user_id'])) { - $studentId = (int)$payload['user_id']; - } - } - } - if (!$studentId) { - $response->status(401)->json(['status' => 'error', 'message' => 'غير مصرح']); - return; - } - - Database::query( - "UPDATE students SET full_name = ?, grade_level = ?, stream = ?, national_id = IF(? != '', ?, national_id), national_id_hash = IF(? != '', ?, national_id_hash), updated_at = NOW() WHERE id = ?", - [$fullName, $gradeLevel, $stream, $nationalId, Security::encrypt($nationalId), $nationalId, Security::blindIndex($nationalId), $studentId] - ); - - $student = Database::selectOne("SELECT * FROM students WHERE id = ? LIMIT 1", [$studentId]); - - $response->json([ - 'status' => 'success', - 'message' => 'تم استكمال ملف الطالب بنجاح! مرحباً بك في منصة صَقِل', - 'user' => [ - 'id' => $student['id'], - 'uuid' => $student['uuid'], - 'full_name' => $student['full_name'], - 'grade_level' => $student['grade_level'], - 'stream' => $student['stream'], - 'role' => 'student' - ] + // This endpoint accepts only a pending student-identity proof. + // A bare JWT here previously bypassed the active-session and role + // checks and allowed changing a national ID without re-verification. + $response->status(401)->json([ + 'status' => 'identity_proof_required', + 'message' => 'إكمال الملف يتطلب رمز التحقق من الهوية. لتغيير صف طالب مستقل استخدم مسار تغيير الصف المصادق.', ]); + return; } } @@ -781,30 +748,45 @@ class AuthController public function updateStudentGrade(Request $request, Response $response): void { $studentId = (int)$request->user_id; - if (!$studentId) { - $authHeader = $request->getHeader('authorization', ''); - if ($authHeader && preg_match('/Bearer\s(\S+)/i', $authHeader, $matches)) { - $payload = Security::verifyJWT($matches[1]); - if ($payload && isset($payload['user_id'])) { - $studentId = (int)$payload['user_id']; - } - } - } if (!$studentId) { $response->status(401)->json(['status' => 'error', 'message' => 'غير مصرح']); return; } $body = $request->getBody(); - $rawGrade = trim((string)($body['grade_level'] ?? 'grade_10')); - $stream = trim((string)($body['stream'] ?? 'general')); + $rawGrade = trim((string)($body['grade_level'] ?? '')); + $stream = trim((string)($body['stream'] ?? '')); $normalizedGrade = \App\Services\StudentAccessControlService::normalizeGrade($rawGrade); + if (!preg_match('/^grade_(?:[1-9]|1[0-2])$/', $normalizedGrade) + || !in_array($stream, ['scientific', 'literary', 'vocational', 'general'], true)) { + $response->status(400)->json(['status' => 'error', 'message' => 'الصف أو المسار غير صالح.']); + return; + } - Database::query( - "UPDATE students SET grade_level = ?, stream = ?, updated_at = NOW() WHERE id = ?", + $scope = Database::selectOne('SELECT id, school_id, is_school_sponsored FROM students WHERE id=? LIMIT 1', [$studentId]); + if (!$scope) { + $response->status(404)->json(['status' => 'error', 'message' => 'طالب غير موجود']); + return; + } + if (!empty($scope['school_id']) || !empty($scope['is_school_sponsored'])) { + $response->status(403)->json(['status' => 'forbidden', 'message' => 'صف الطالب المدرسي يحدّثه مسؤول المدرسة من كشف الانتساب.']); + return; + } + + $changed = Database::execute( + "UPDATE students SET grade_level = ?, stream = ?, updated_at = NOW() + WHERE id = ? AND school_id IS NULL AND is_school_sponsored = 0", [$normalizedGrade, $stream, $studentId] ); + if ($changed !== 1) { + $after = Database::selectOne('SELECT grade_level, stream, school_id, is_school_sponsored FROM students WHERE id=? LIMIT 1', [$studentId]); + if (!$after || !empty($after['school_id']) || !empty($after['is_school_sponsored']) + || (string)$after['grade_level'] !== $normalizedGrade || (string)$after['stream'] !== $stream) { + $response->status(409)->json(['status' => 'conflict', 'message' => 'تغير نطاق حساب الطالب؛ أعد تحميل ملفك الدراسي.']); + return; + } + } $student = Database::selectOne( "SELECT s.id, s.uuid, s.full_name, s.national_id, s.grade_level, s.stream, s.readiness_score, s.school_id, ai.phone_number, ai.status, s.created_at diff --git a/backend/app/Controllers/CurriculumController.php b/backend/app/Controllers/CurriculumController.php index 5f0886b..445626a 100644 --- a/backend/app/Controllers/CurriculumController.php +++ b/backend/app/Controllers/CurriculumController.php @@ -26,6 +26,8 @@ use App\Services\CurriculumService; use App\Services\CurriculumExtractorService; use App\Services\PublishedContentService; use App\Services\LearningPackageService; +use App\Services\PublishedCurriculumTreeFilter; +use App\Services\AssetDownloadTicketService; class CurriculumController { @@ -207,13 +209,23 @@ class CurriculumController $removeUnpublishedResources($tree); try { - $approvedLessons = Database::select("SELECT id, uuid, source_manifest_path FROM curriculum_lessons WHERE source_status='approved'"); + $approvedLessons = Database::select( + "SELECT cl.id, cl.uuid, cl.source_manifest_path, a.uuid AS primary_asset_id + FROM curriculum_lessons cl + JOIN publication_bundles pb ON pb.curriculum_lesson_id=cl.id AND pb.status='published' + JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role='primary_lesson' + JOIN content_assets a ON a.id=pba.content_asset_id + AND a.asset_type='lesson_markdown' AND a.review_status='approved' AND a.rights_status='cleared' + WHERE cl.source_status='approved' + ORDER BY cl.id DESC, pb.published_at DESC, pb.id DESC, pba.sort_order ASC, a.id ASC" + ); $byPath = []; $lessonMap = []; foreach ($approvedLessons as $row) { $p = (string)$row['source_manifest_path']; $byPath[$p] = [ 'curriculum_lesson_id' => (string)$row['uuid'], + 'primary_lesson_asset_id' => (string)$row['primary_asset_id'], 'has_video' => false, ]; $lessonMap[(int)$row['id']] = $p; @@ -224,7 +236,13 @@ class CurriculumController $videoCounts = Database::select( "SELECT ts.curriculum_lesson_id, COUNT(vv.id) AS video_count FROM teacher_submissions ts + JOIN curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id AND cl.source_status='approved' JOIN video_versions vv ON vv.id = ts.current_published_video_version_id AND vv.status = 'published' + JOIN video_review_jobs j ON j.video_version_id=vv.id AND j.status='approved' + JOIN publication_bundles pb ON pb.curriculum_lesson_id=cl.id AND pb.status='published' + JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role='primary_lesson' + JOIN content_assets a ON a.id=pba.content_asset_id AND a.sha256=j.markdown_sha256 + AND a.review_status='approved' AND a.rights_status='cleared' WHERE ts.status = 'published' GROUP BY ts.curriculum_lesson_id" ); @@ -263,7 +281,7 @@ class CurriculumController // bundle, and expose an opaque asset UUID rather than a storage path. $resourceRows = Database::select( "SELECT cl.subject_key, a.uuid AS asset_id, a.asset_type, a.mime_type, a.source_reference, - cl.title AS lesson_title, cl.unit_key, + cl.title AS lesson_title, cl.semester_key, cl.unit_key, pba.role, pba.sort_order FROM publication_bundles pb JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id @@ -292,7 +310,8 @@ class CurriculumController $title = ''; if ($group === 'textbooks') { - $ref = strtolower((string)($row['source_reference'] ?? '')); + $sourceReference = trim((string)($row['source_reference'] ?? '')); + $ref = strtolower($sourceReference); $subjectNames = [ 'math' => 'الرياضيات', 'physics' => 'الفيزياء', @@ -315,13 +334,19 @@ class CurriculumController break; } } - $part = ''; - if (str_contains($ref, 'part1') || str_contains($ref, 'part_1') || str_contains($ref, 'semester_1')) { - $part = ' (الفصل الأول)'; - } elseif (str_contains($ref, 'part2') || str_contains($ref, 'part_2') || str_contains($ref, 'semester_2')) { - $part = ' (الفصل الثاني)'; + $sourceBasename = basename(str_replace('\\', '/', $sourceReference)); + $sourceTitle = trim(pathinfo($sourceBasename, PATHINFO_FILENAME)); + if ($sourceTitle !== '') { + $title = $sourceTitle; + } else { + $semesterKey = strtolower((string)($row['semester_key'] ?? '')); + $semesterLabel = str_contains($semesterKey, '1') + ? 'الفصل الأول' + : (str_contains($semesterKey, '2') ? 'الفصل الثاني' : 'فصل غير محدد'); + $unitKey = preg_replace('/[^0-9]/', '', (string)($row['unit_key'] ?? '')); + $unitLabel = $unitKey !== '' ? " — الوحدة $unitKey" : ''; + $title = "ملف PDF منشور للمادة: $foundSub — $semesterLabel$unitLabel"; } - $title = "الكتاب المدرسي الرسمي: $foundSub$part"; } else { $lTitle = trim((string)($row['lesson_title'] ?? '')); $title = !empty($lTitle) ? ("ورقة عمل: " . $lTitle) : "ورقة عمل تعليمية"; @@ -349,6 +374,78 @@ class CurriculumController $response->json(['status'=>'success','data'=>$tree]); } + /** Public student catalogue: fail closed when publication evidence is unavailable. */ + public function getPublishedTree(Request $request, Response $response): void + { + try { + $rows = Database::select( + "SELECT cl.id, cl.uuid, cl.curriculum_version, cl.grade_key, cl.subject_key, cl.semester_key, + cl.unit_key, cl.lesson_key, cl.source_manifest_path, cl.title, + a.uuid AS primary_asset_id + FROM curriculum_lessons cl + JOIN publication_bundles pb ON pb.curriculum_lesson_id=cl.id AND pb.status='published' + JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role='primary_lesson' + JOIN content_assets a ON a.id=pba.content_asset_id + AND a.asset_type='lesson_markdown' AND a.review_status='approved' AND a.rights_status='cleared' + WHERE cl.source_status='approved' + ORDER BY cl.id DESC, pb.published_at DESC, pb.id DESC, pba.sort_order ASC, a.id ASC" + ); + $videoRows = Database::select( + "SELECT DISTINCT ts.curriculum_lesson_id + FROM teacher_submissions ts + JOIN video_versions vv ON vv.id=ts.current_published_video_version_id AND vv.status='published' + JOIN lessons l ON l.id=vv.source_lesson_id AND l.encoding_status='ready' + JOIN video_review_jobs j ON j.video_version_id=vv.id AND j.status='approved' + JOIN publication_bundles pb ON pb.curriculum_lesson_id=ts.curriculum_lesson_id AND pb.status='published' + JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role='primary_lesson' + JOIN content_assets a ON a.id=pba.content_asset_id AND a.sha256=j.markdown_sha256 + AND a.review_status='approved' AND a.rights_status='cleared' + WHERE ts.status='published'" + ); + $hasVideo = array_fill_keys(array_map(static fn($row) => (int)$row['curriculum_lesson_id'], $videoRows), true); + foreach ($rows as &$row) $row['has_video'] = isset($hasVideo[(int)$row['id']]); + unset($row); + $tree = PublishedCurriculumTreeFilter::filter(CurriculumService::getCurriculumTree(), $rows); + + if ($tree !== []) { + $resources = Database::select( + "SELECT cl.grade_key, cl.subject_key, cl.title AS lesson_title, + pba.role, a.uuid AS asset_id, a.asset_type, a.mime_type + FROM publication_bundles pb + JOIN curriculum_lessons cl ON cl.id=pb.curriculum_lesson_id AND cl.source_status='approved' + JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role IN ('textbook','worksheet') + JOIN content_assets a ON a.id=pba.content_asset_id AND a.review_status='approved' AND a.rights_status='cleared' + WHERE pb.status='published' + AND ((pba.role='textbook' AND a.asset_type='textbook_pdf') + OR (pba.role='worksheet' AND a.asset_type='worksheet_markdown')) + ORDER BY cl.grade_key, cl.subject_key, pba.role, pba.sort_order, a.id" + ); + $seen = []; + foreach ($resources as $item) { + $gradeKey = (string)$item['grade_key']; + $subjectKey = (string)$item['subject_key']; + if (!isset($tree[$gradeKey]['subjects'][$subjectKey])) continue; + $assetId = (string)$item['asset_id']; + if (isset($seen[$gradeKey][$subjectKey][$assetId])) continue; + $seen[$gradeKey][$subjectKey][$assetId] = true; + $isBook = $item['role'] === 'textbook'; + $group = $isBook ? 'textbooks' : 'worksheets'; + $tree[$gradeKey]['subjects'][$subjectKey]['resources'][$group]['items'][] = [ + 'asset_id' => $assetId, + 'asset_type' => (string)$item['asset_type'], + 'mime_type' => (string)$item['mime_type'], + 'type' => $isBook ? 'textbook' : 'worksheet', + 'title' => $isBook ? 'كتاب المبحث المنشور' : 'ورقة عمل: ' . (string)$item['lesson_title'], + ]; + } + } + $response->json(['status' => 'success', 'data' => $tree === [] ? new \stdClass() : $tree]); + } catch (\Throwable $e) { + error_log('Published curriculum tree unavailable: ' . $e->getMessage()); + $response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر تحميل فهرس الدروس المنشورة.']); + } + } + /** * Get Single Lesson Markdown Content */ @@ -360,13 +457,15 @@ class CurriculumController return; } $content = CurriculumService::getLessonMarkdown($file); + if ($content === '') { + $response->status(404)->json(['status' => 'error', 'message' => 'ملف الدرس المطلوب غير موجود.']); + return; + } $assets = CurriculumService::getLessonAiAssets($file); - $serverFullPath = realpath(__DIR__ . '/../../storage/curriculum') . '/' . ltrim($file, '/'); $response->json([ 'status' => 'success', 'file' => $file, - 'server_full_path' => $serverFullPath, 'content' => $content, 'ai_assets' => $assets ]); @@ -384,34 +483,35 @@ class CurriculumController return; } - CurriculumService::saveLessonMarkdown($file, $content); + try { + $published = Database::selectOne( + "SELECT a.id FROM content_assets a + JOIN publication_bundle_assets pba ON pba.content_asset_id=a.id + JOIN publication_bundles pb ON pb.id=pba.publication_bundle_id + WHERE a.storage_driver='local' AND a.storage_key=? AND pb.status='published' + LIMIT 1", + [$file] + ); + } catch (\Throwable $e) { + error_log('Draft lesson save gate unavailable: ' . $e->getMessage()); + $response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر التحقق من حالة أصل الدرس؛ لم يُحفظ الملف.']); + return; + } + if ($published) { + $response->status(409)->json(['status' => 'published_asset_immutable', 'message' => 'هذا الأصل منشور؛ أنشئ نسخة مسودة جديدة ومررها للمراجعة.']); + return; + } + if (!CurriculumService::saveLessonMarkdown($file, $content)) { + $response->status(400)->json(['status' => 'error', 'message' => 'مسار ملف المسودة غير صالح أو تعذر حفظه.']); + return; + } if ($aiAssets !== null) { CurriculumService::saveLessonAiAssets($file, $aiAssets); } - // Sync to MySQL Database Table `lessons` if connected - try { - $aiVideoUrl = $aiAssets['ai_video_url'] ?? null; - $cheatSheet = $aiAssets['cheat_sheet'] ?? null; - $socraticJson = isset($aiAssets['socratic_quiz']) ? json_encode($aiAssets['socratic_quiz'], JSON_UNESCAPED_UNICODE) : null; - - // Search lesson by matching filename or title - $filename = basename($file, '.md'); - \App\Core\Database::query( - "UPDATE lessons SET markdown_content = ?, ai_video_url = COALESCE(?, ai_video_url), cheat_sheet_markdown = COALESCE(?, cheat_sheet_markdown), socratic_quiz_json = COALESCE(?, socratic_quiz_json) - WHERE title LIKE ? OR markdown_content LIKE ?", - [$content, $aiVideoUrl, $cheatSheet, $socraticJson, "%{$filename}%", "%{$file}%"] - ); - } catch (\Throwable $dbEx) { - error_log("Curriculum DB save sync note: " . $dbEx->getMessage()); - } - - $serverFullPath = realpath(__DIR__ . '/../../storage/curriculum') . '/' . ltrim($file, '/'); - $response->json([ 'status' => 'success', - 'message' => 'تم حفظ واعتماد محتوى الدرس في المنهاج وقاعدة البيانات بنجاح!', - 'server_full_path' => $serverFullPath + 'message' => 'تم حفظ مسودة الدرس. النشر يتطلب مراجعة المحتوى والحقوق والحزمة.', ]); } @@ -634,6 +734,9 @@ class CurriculumController $assetId = trim((string)$request->getParam('assetId', '')); try { $asset = PublishedContentService::findPublishedAsset($assetId); + if ($asset && !PublishedContentService::studentMayRead((int)$request->user_id, $asset)) { + $asset = null; + } } catch (\Throwable $e) { error_log('Published asset lookup failed: ' . $e->getMessage()); $response->status(503)->json([ @@ -660,6 +763,16 @@ class CurriculumController return; } + $actualSha = hash_file('sha256', $path); + if ($actualSha === false || !hash_equals((string)$asset['sha256'], $actualSha) + || filesize($path) !== (int)$asset['byte_size']) { + $response->status(503)->json(['status' => 'unavailable', 'message' => 'فشل التحقق من سلامة الأصل المنشور.']); + return; + } + $response->setHeader('Cache-Control', 'private, no-store'); + $response->setHeader('Referrer-Policy', 'no-referrer'); + $response->setHeader('X-Content-Type-Options', 'nosniff'); + if (str_starts_with((string)$asset['mime_type'], 'text/')) { $content = file_get_contents($path); if ($content === false) { @@ -682,6 +795,47 @@ class CurriculumController exit; } + /** Authenticated student obtains a short-lived browser grant for one PDF. */ + public function issueAssetDownloadTicket(Request $request, Response $response): void + { + $assetId = trim((string)$request->getParam('assetId', '')); + try { + $asset = PublishedContentService::findPublishedAsset($assetId); + if (!$asset || $asset['asset_type'] !== 'textbook_pdf' + || $asset['mime_type'] !== 'application/pdf' + || !PublishedContentService::studentMayRead((int)$request->user_id, $asset)) { + $response->status(404)->json(['status' => 'error', 'message' => 'الكتاب غير منشور أو غير متاح لك.']); + return; + } + $grant = AssetDownloadTicketService::issue((int)$request->user_id, $assetId); + $response->setHeader('Cache-Control', 'private, no-store'); + $response->json(['status' => 'success', 'data' => $grant]); + } catch (\Throwable $e) { + error_log('Asset download grant unavailable: ' . $e->getMessage()); + $response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر تجهيز فتح الكتاب حالياً.']); + } + } + + /** Browser receives only a scoped grant, never the student's session JWT. */ + public function downloadAssetWithTicket(Request $request, Response $response): void + { + $assetId = trim((string)$request->getParam('assetId', '')); + $ticket = trim((string)$request->getQuery('ticket', '')); + try { + $studentId = AssetDownloadTicketService::resolve($ticket, $assetId); + } catch (\Throwable $e) { + error_log('Asset download grant lookup unavailable: ' . $e->getMessage()); + $response->status(503)->json(['status' => 'unavailable', 'message' => 'تعذر التحقق من رابط الكتاب.']); + return; + } + if ($studentId === null) { + $response->status(403)->json(['status' => 'forbidden', 'message' => 'رابط الكتاب غير صالح أو انتهت صلاحيته.']); + return; + } + $request->user_id = $studentId; + $this->getPublishedAsset($request, $response); + } + private static function assetMetadata(array $asset): array { return [ diff --git a/backend/app/Controllers/ErrorNotebookController.php b/backend/app/Controllers/ErrorNotebookController.php index 6a354fb..ce87ed8 100644 --- a/backend/app/Controllers/ErrorNotebookController.php +++ b/backend/app/Controllers/ErrorNotebookController.php @@ -5,13 +5,14 @@ namespace App\Controllers; use App\Core\Database; use App\Core\Request; use App\Core\Response; +use App\Services\RemediationAttemptGrader; class ErrorNotebookController { public function getErrorNotebook(Request $request, Response $response): void { $studentId = (int)$request->user_id; - $sql = "SELECT * FROM student_error_notebook WHERE student_id = ?"; + $sql = "SELECT *, (retention_due_at IS NOT NULL AND retention_due_at <= NOW()) AS retention_is_due FROM student_error_notebook WHERE student_id = ?"; $params = [$studentId]; $subject = trim((string)$request->getQuery('subject', '')); $status = trim((string)$request->getQuery('status', '')); @@ -69,9 +70,11 @@ class ErrorNotebookController public function getRemediationQuiz(Request $request, Response $response): void { - $uuid = trim((string)$request->getQuery('error_uuid', '')); + $body = $request->getBody(); + $uuid = trim((string)($body['error_uuid'] ?? '')); $error = Database::selectOne( - "SELECT * FROM student_error_notebook WHERE uuid = ? AND student_id = ? LIMIT 1", + "SELECT *, (retention_due_at IS NOT NULL AND retention_due_at <= NOW()) AS retention_is_due + FROM student_error_notebook WHERE uuid = ? AND student_id = ? LIMIT 1", [$uuid, (int)$request->user_id] ); if (!$error) { @@ -79,49 +82,62 @@ class ErrorNotebookController return; } - $questions = []; - - // 1. Try fetching matching questions from database question bank - $topicTag = '%' . $error['topic_name'] . '%'; + if ($error['status'] === 'mastered') { + $response->status(409)->json(['status' => 'error', 'message' => 'هذه الفجوة متقنة بالفعل']); + return; + } + if ($error['status'] === 'in_remediation' && (int)$error['retention_is_due'] !== 1) { + $response->status(409)->json(['status' => 'not_due', 'message' => 'اختبار الاسترجاع متاح بعد موعده المحدد']); + return; + } + if (empty($error['lesson_id'])) { + $response->status(422)->json(['status' => 'unavailable', 'message' => 'لا يوجد درس محدد لهذه الفجوة لتكوين اختبار موثوق']); + return; + } $dbQuestions = Database::select( - "SELECT q.id, q.question_text, q.explanation_text, q.ai_hint - FROM questions q + "SELECT q.id, q.question_text FROM questions q JOIN exams e ON e.id = q.exam_id - WHERE q.topic_tag LIKE ? OR q.question_text LIKE ? OR e.title LIKE ? - LIMIT 3", - [$topicTag, $topicTag, $topicTag] + WHERE e.lesson_id = ? AND e.is_published = 1 AND q.topic_tag = ? + AND q.question_type = 'multiple_choice' + AND q.remediation_review_status = 'approved' + AND q.remediation_reviewed_by IS NOT NULL + AND q.remediation_reviewed_at IS NOT NULL + AND q.remediation_source_reference IS NOT NULL + ORDER BY q.id ASC LIMIT 20", + [(int)$error['lesson_id'], $error['topic_name']] ); - - foreach ($dbQuestions as $dbQ) { - $opts = Database::select( - "SELECT id, option_text, is_correct FROM question_options WHERE question_id = ? ORDER BY id ASC", - [(int)$dbQ['id']] + $questions = []; + $answerKey = []; + foreach ($dbQuestions as $question) { + $options = Database::select( + 'SELECT id, option_text, is_correct FROM question_options WHERE question_id = ? ORDER BY id ASC', + [(int)$question['id']] ); - if (count($opts) >= 2) { - $optTexts = []; - $correctIdx = 0; - foreach ($opts as $i => $opt) { - $optTexts[] = $opt['option_text']; - if ((int)$opt['is_correct'] === 1) { - $correctIdx = $i; - } - } - $questions[] = [ - 'id' => (int)$dbQ['id'], - 'question' => $dbQ['question_text'], - 'options' => $optTexts, - 'correct_index' => $correctIdx, - 'explanation' => $dbQ['explanation_text'] ?: ($dbQ['ai_hint'] ?: 'تطبيق مباشر لقوانين ومفاهيم المنهج المعتمد.'), - ]; - } + if (count($options) < 2 || count(array_filter($options, fn($o) => (int)$o['is_correct'] === 1)) !== 1) continue; + $questions[] = [ + 'id' => (int)$question['id'], 'question' => $question['question_text'], + 'options' => array_map(fn($o) => ['id' => (int)$o['id'], 'text' => $o['option_text']], $options), + ]; + $answerKey[] = [ + 'question_id' => (int)$question['id'], + 'option_ids' => array_map(fn($o) => (int)$o['id'], $options), + 'correct_option_id' => (int)array_values(array_filter($options, fn($o) => (int)$o['is_correct'] === 1))[0]['id'], + ]; + if (count($questions) === 3) break; + } + if (count($questions) !== 3) { + $response->status(422)->json(['status' => 'unavailable', 'message' => 'لا توجد ثلاثة أسئلة منشورة ومراجعة لهذا الدرس والمفهوم']); + return; } - // 2. If question bank has fewer than 2 questions, provide curriculum-aligned remedial questions - if (count($questions) < 2) { - $topic = $error['topic_name']; - $subject = $error['subject_name']; - $questions = self::generateCurriculumRemedialQuiz((int)$error['id'], $subject, $topic, $error['question_text']); - } + $attemptUuid = $this->uuid(); + Database::insert( + "INSERT INTO error_notebook_quiz_attempts (uuid,error_id,student_id,phase,question_ids_json,answer_key_json,expires_at) + VALUES (?,?,?,?,?,?,DATE_ADD(NOW(), INTERVAL 30 MINUTE))", + [$attemptUuid, (int)$error['id'], (int)$request->user_id, + $error['status'] === 'in_remediation' ? 'retention' : 'initial', + json_encode(array_column($questions, 'id')), json_encode($answerKey)] + ); $response->json([ 'status' => 'success', @@ -129,6 +145,7 @@ class ErrorNotebookController 'error_uuid' => $uuid, 'topic_name' => $error['topic_name'], 'subject_name' => $error['subject_name'], + 'attempt_uuid' => $attemptUuid, 'questions' => $questions, ], ]); @@ -138,111 +155,94 @@ class ErrorNotebookController { $body = $request->getBody(); $uuid = trim((string)($body['error_uuid'] ?? '')); + $attemptUuid = trim((string)($body['attempt_uuid'] ?? '')); $studentId = (int)$request->user_id; - - $error = Database::selectOne( - "SELECT * FROM student_error_notebook WHERE uuid = ? AND student_id = ? LIMIT 1", - [$uuid, $studentId] - ); - if (!$error) { - $response->status(404)->json(['status' => 'error', 'message' => 'الفجوة التعليمية غير موجودة أو غير مصرح بالوصول إليها']); + $answers = $body['answers'] ?? null; + if (!is_array($answers) || count($answers) !== 3) { + $response->status(422)->json(['status' => 'error', 'message' => 'يجب إرسال إجابات الأسئلة الثلاثة']); return; } - - $isRetentionPhase = ($error['status'] === 'in_remediation'); - $nextStatus = $isRetentionPhase ? 'mastered' : 'in_remediation'; - - // Stage 1: Initial Remedial Drill passed -> enters Spaced Repetition (in_remediation) - // Stage 2: Spaced Repetition Retention Challenge passed -> enters permanently Mastered - Database::query( - "UPDATE student_error_notebook - SET status = ?, - mastered_at = IF(? = 'mastered', NOW(), mastered_at), - remediation_attempts_count = remediation_attempts_count + 1 - WHERE id = ?", - [$nextStatus, $nextStatus, (int)$error['id']] - ); - - // Update student mastery analytics and readiness - $currentMastery = Database::selectOne( - "SELECT id, tawjihi_readiness_score, mastery_percentage FROM student_mastery_analytics WHERE student_id = ? ORDER BY id DESC LIMIT 1", - [$studentId] - ); - if ($currentMastery) { - $readinessGain = $isRetentionPhase ? 1.5 : 0.6; - $masteryGain = $isRetentionPhase ? 1.2 : 0.5; - $newReadiness = min(100.0, (float)$currentMastery['tawjihi_readiness_score'] + $readinessGain); - $newMastery = min(100.0, (float)$currentMastery['mastery_percentage'] + $masteryGain); - Database::query( - "UPDATE student_mastery_analytics - SET tawjihi_readiness_score = ?, mastery_percentage = ?, updated_at = NOW() - WHERE id = ?", - [$newReadiness, $newMastery, (int)$currentMastery['id']] + $answersHash = hash('sha256', json_encode($answers)); + $pdo = Database::getConnection(); + $pdo->beginTransaction(); + try { + $error = Database::selectOne( + 'SELECT *, (retention_due_at IS NOT NULL AND retention_due_at <= NOW()) AS retention_is_due FROM student_error_notebook WHERE uuid = ? AND student_id = ? FOR UPDATE', + [$uuid, $studentId] ); + $attempt = $error ? Database::selectOne( + 'SELECT *, (expires_at > NOW()) AS is_fresh FROM error_notebook_quiz_attempts WHERE uuid = ? AND error_id = ? AND student_id = ? FOR UPDATE', + [$attemptUuid, (int)$error['id'], $studentId] + ) : null; + if ($attempt && $attempt['status'] !== 'open' && hash_equals((string)($attempt['answers_sha256'] ?? ''), $answersHash)) { + $pdo->commit(); + $response->json(['status' => 'success', 'passed' => $attempt['status'] === 'passed', + 'correct_count' => (int)$attempt['correct_count'], + 'new_status' => $attempt['status'] === 'passed' ? ($attempt['phase'] === 'retention' ? 'mastered' : 'in_remediation') : $error['status']]); + return; + } + if (!$attempt || $attempt['status'] !== 'open' || (int)$attempt['is_fresh'] !== 1) { + $pdo->rollBack(); + $response->status(409)->json(['status' => 'error', 'message' => 'المحاولة غير متاحة أو انتهت صلاحيتها']); + return; + } + $phase = $error['status'] === 'in_remediation' ? 'retention' : 'initial'; + if ($error['status'] === 'mastered' || $attempt['phase'] !== $phase || + ($phase === 'retention' && (int)$error['retention_is_due'] !== 1)) { + $pdo->rollBack(); + $response->status(409)->json(['status' => 'error', 'message' => 'حالة الفجوة تغيرت؛ ابدأ محاولة جديدة']); + return; + } + $ids = json_decode($attempt['question_ids_json'], true); + $answerKey = json_decode($attempt['answer_key_json'], true); + if (!is_array($ids) || !is_array($answerKey)) { + $pdo->rollBack(); + $response->status(422)->json(['status' => 'error', 'message' => 'إجابات المحاولة غير صالحة']); + return; + } + foreach ($ids as $index => $questionId) { + $published = Database::selectOne( + "SELECT q.id FROM questions q JOIN exams e ON e.id = q.exam_id + WHERE q.id = ? AND e.lesson_id = ? AND e.is_published = 1 AND q.topic_tag = ? + AND q.remediation_review_status = 'approved' AND q.remediation_reviewed_by IS NOT NULL + AND q.remediation_reviewed_at IS NOT NULL AND q.remediation_source_reference IS NOT NULL FOR UPDATE", + [(int)$questionId, (int)$error['lesson_id'], $error['topic_name']] + ); + if (!$published) { + $pdo->rollBack(); + $response->status(409)->json(['status' => 'error', 'message' => 'سُحب سؤال من هذه المحاولة؛ ابدأ محاولة جديدة']); + return; + } + } + try { + $correct = RemediationAttemptGrader::grade($ids, $answerKey, $answers); + } catch (\InvalidArgumentException $invalidAnswer) { + $pdo->rollBack(); + $response->status(422)->json(['status' => 'error', 'message' => 'إجابات المحاولة غير صالحة']); + return; + } + $passed = $correct >= 2; + Database::query( + "UPDATE error_notebook_quiz_attempts SET status = ?, correct_count = ?, answers_sha256 = ?, submitted_at = NOW() WHERE id = ?", + [$passed ? 'passed' : 'failed', $correct, $answersHash, (int)$attempt['id']] + ); + if ($passed) { + Database::query( + "UPDATE student_error_notebook SET status = ?, remediation_attempts_count = remediation_attempts_count + 1, + retention_due_at = IF(? = 'initial', DATE_ADD(NOW(), INTERVAL 7 DAY), retention_due_at), + mastered_at = IF(? = 'retention', NOW(), mastered_at) WHERE id = ?", + [$phase === 'retention' ? 'mastered' : 'in_remediation', $phase, $phase, (int)$error['id']] + ); + } else { + Database::query('UPDATE student_error_notebook SET remediation_attempts_count = remediation_attempts_count + 1 WHERE id = ?', [(int)$error['id']]); + } + $pdo->commit(); + $response->json(['status' => 'success', 'passed' => $passed, 'correct_count' => $correct, + 'new_status' => $passed ? ($phase === 'retention' ? 'mastered' : 'in_remediation') : $error['status']]); + } catch (\Throwable $e) { + if ($pdo->inTransaction()) $pdo->rollBack(); + throw $e; } - - if ($isRetentionPhase) { - $response->json([ - 'status' => 'success', - 'mastered' => true, - 'is_retention_verified' => true, - 'new_status' => 'mastered', - 'message' => 'تم اجتياز اختبار الاسترجاع والتثبيت بنجاح تام! اعتُمد المفهوم كمتقن في الذاكرة طويلة المدى.', - ]); - } else { - $response->json([ - 'status' => 'success', - 'mastered' => false, - 'is_retention_verified' => false, - 'new_status' => 'in_remediation', - 'message' => 'تم اجتياز الكويز العلاجي الأولي بنجاح! انتقل المفهوم إلى مرحلة التثبيت والتكرار المتباعد (المراجعة بعد 7 أيام لتأكيد الإتقان التام ومنع النسيان).', - ]); - } - } - - /** - * Generate curriculum-aligned remedial questions based on the mistaken topic. - */ - public static function generateCurriculumRemedialQuiz(int $errorId, string $subject, string $topic, string $originalQuestion): array - { - return [ - [ - 'id' => $errorId * 10 + 1, - 'question' => "سؤال علاجي في مفهوم [{$topic}]: ما المبدأ العلمي الأساسي الذي يحكم سلوك الظاهرة؟", - 'options' => [ - "الاعتماد المباشر على العلاقة الرياضية ومحددات الاتجاه للمتغيرات في المنهج", - "تطبيق عشوائي للقيم دون ربطها بالقانون الفيزيائي أو الكيميائي", - "إهمال الوحدات الأساسية والتحويل بين البادئات العلمية", - "افتراض ثبات المتغيرات غير المقيسة بدون سند تجريبي" - ], - 'correct_index' => 0, - 'explanation' => "الأساس العلمي في دراسة {$topic} يقتضي الانطلاق دائماً من العلاقة الرياضية المعتمدة وتحديد المتغيرات التابعة والمستقلة بدقة.", - ], - [ - 'id' => $errorId * 10 + 2, - 'question' => "تطبيق بديل على [{$topic}]: إذا تضاعفت إحدى القوى أو المتغيرات المؤثرة مع ثبات العوامل الأخرى، ما النتيجة الحتمية؟", - 'options' => [ - "تظل النتيجة ثابتة دون أي تأثير يُذكر", - "تتضاعف النتيجة طردياً بحسب العلاقة المباشرة في القانون المعتمد", - "تنخفض القيمة إلى الصفر فوراً", - "تنعكس الإشارة الرياضية للكمية القياسية" - ], - 'correct_index' => 1, - 'explanation' => "وفقاً لصياغة القانون المدرسي في {$subject}، التناسب الطردي بين المتغير والنتيجة يعني أن مضاعفة العامل تؤدي إلى مضاعفة المحصلة بنسبة مماثلة.", - ], - [ - 'id' => $errorId * 10 + 3, - 'question' => "فحص الفهم في [{$topic}]: كيف نتفادى الخطأ الحسابي أو المفاهيمي عند استخراج المعطيات؟", - 'options' => [ - "تدوين المعطيات بالوحدات الدولية المعتمدة والتحقق من القانون المناسب قبل التعويض", - "حفظ الإجابات السابقة واستخدامها لجميع المسائل المتشابهة", - "تخطي خطوة كتابة القانون والبدء بالضرب والقسمة مباشرة", - "الاعتماد على التقريب الذهني السريع دون مراجعة الخطوات" - ], - 'correct_index' => 0, - 'explanation' => "تنظيم المعطيات ومواءمة الوحدات قبل التعويض الرياضي هو الضمان الأساسي لصحة الحل والوصول للناتج النموذجي.", - ], - ]; } private function uuid(): string diff --git a/backend/app/Controllers/SuperAdminController.php b/backend/app/Controllers/SuperAdminController.php index 23b8183..1e65120 100644 --- a/backend/app/Controllers/SuperAdminController.php +++ b/backend/app/Controllers/SuperAdminController.php @@ -121,13 +121,10 @@ class SuperAdminController { $bundles = Database::select( "SELECT pb.id, pb.uuid, pb.bundle_version, pb.status, pb.published_at, pb.created_at, - cl.title AS lesson_title, cl.curriculum_edition, s.name AS subject_name, g.name AS grade_name + cl.title AS lesson_title, cl.curriculum_version, cl.subject_key, cl.grade_key, + cl.semester_key, cl.unit_key, cl.lesson_key FROM publication_bundles pb JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id - JOIN curriculum_units cu ON cu.id = cl.unit_id - JOIN curriculum_courses cc ON cc.id = cu.course_id - JOIN subjects s ON s.id = cc.subject_id - JOIN grade_levels g ON g.id = cc.grade_level_id ORDER BY pb.id DESC LIMIT 100" ); $submissions = Database::select( @@ -150,35 +147,10 @@ class SuperAdminController public function reviewSubmission(Request $request, Response $response): void { - $body = $request->getBody(); - $versionUuid = trim((string)($body['version_uuid'] ?? '')); - $decision = (string)($body['decision'] ?? ''); // 'approved' or 'rejected' - - if (!in_array($decision, ['approved', 'rejected'], true) || $versionUuid === '') { - $response->status(422)->json(['status' => 'error', 'message' => 'بيانات مراجعة النسخة غير صالحة']); - return; - } - - $version = Database::selectOne( - "SELECT vv.id, vv.teacher_submission_id FROM video_versions vv WHERE vv.uuid = ? LIMIT 1", - [$versionUuid] - ); - if (!$version) { - $response->status(404)->json(['status' => 'error', 'message' => 'نسخة الفيديو غير موجودة']); - return; - } - - $status = $decision === 'approved' ? 'published' : 'rejected'; - Database::query("UPDATE video_versions SET status = ?, reviewed_at = NOW() WHERE id = ?", [$status, (int)$version['id']]); - - if ($decision === 'approved') { - Database::query( - "UPDATE teacher_submissions SET status = 'published', current_published_video_version_id = ? WHERE id = ?", - [(int)$version['id'], (int)$version['teacher_submission_id']] - ); - } - - $response->json(['status' => 'success', 'message' => $decision === 'approved' ? 'تم اعتماد ونشر النسخة بنجاح' : 'تم رفض النسخة']); + $response->status(410)->json([ + 'status' => 'review_pipeline_required', + 'message' => 'استخدم مسارات أدلة فحص الفيديو، قرار المراجع، ثم نشر مهمة المراجعة المعتمدة.', + ]); } private function count(string $table): int diff --git a/backend/app/Controllers/VideoController.php b/backend/app/Controllers/VideoController.php index b065715..e00e724 100644 --- a/backend/app/Controllers/VideoController.php +++ b/backend/app/Controllers/VideoController.php @@ -29,6 +29,50 @@ use App\Services\VideoReviewService; class VideoController { + public function legacyPlaybackUnavailable(Request $request, Response $response): void + { + $response->status(410)->json([ + 'status' => 'version_required', + 'message' => 'استخدم نسخة الفيديو المنشورة المحددة بهوية الدرس المنهجي.', + ]); + } + + private function mayStreamVideo(Request $request, string $videoUuid): bool + { + if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $videoUuid)) return false; + if ($request->role === 'super_admin') return true; + if ($request->role === 'teacher') { + $owner = Database::selectOne( + "SELECT l.id FROM lessons l JOIN courses c ON c.id=l.course_id + JOIN teachers t ON t.id=c.teacher_id + WHERE l.video_uuid=? AND t.identity_id=? LIMIT 1", + [$videoUuid, (int)$request->identity_id] + ); + return (bool)$owner; + } + if ($request->role !== 'student') return false; + $row = Database::selectOne( + "SELECT l.id AS lesson_id,l.course_id,cl.grade_key + FROM lessons l JOIN video_versions vv ON vv.source_lesson_id=l.id AND vv.status='published' + JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id + AND ts.current_published_video_version_id=vv.id AND ts.status='published' + JOIN curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id AND cl.source_status='approved' + JOIN video_review_jobs j ON j.video_version_id=vv.id AND j.status='approved' + JOIN publication_bundles pb ON pb.curriculum_lesson_id=cl.id AND pb.status='published' + JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role='primary_lesson' + JOIN content_assets a ON a.id=pba.content_asset_id AND a.sha256=j.markdown_sha256 + AND a.review_status='approved' AND a.rights_status='cleared' + WHERE l.video_uuid=? AND l.encoding_status='ready' LIMIT 1", + [$videoUuid] + ); + if (!$row) return false; + $access = \App\Services\StudentAccessControlService::validateLessonAccess( + (int)$request->user_id, null, + \App\Services\StudentAccessControlService::normalizeGrade($row['grade_key']), + (int)$row['course_id'], (int)$row['lesson_id'], false + ); + return !empty($access['allowed']); + } /** POST /api/video-versions/{versionId}/watch-sessions */ public function startWatchSession(Request $request, Response $response): void { @@ -60,7 +104,12 @@ class VideoController cl.uuid AS curriculum_lesson_id, cl.title, cl.grade_key, ts.uuid AS submission_id FROM video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id AND ts.current_published_video_version_id=vv.id AND ts.status='published' - JOIN curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id + JOIN curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id AND cl.source_status='approved' + JOIN video_review_jobs j ON j.video_version_id=vv.id AND j.status='approved' + JOIN publication_bundles pb ON pb.curriculum_lesson_id=cl.id AND pb.status='published' + JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role='primary_lesson' + JOIN content_assets a ON a.id=pba.content_asset_id AND a.sha256=j.markdown_sha256 + AND a.review_status='approved' AND a.rights_status='cleared' JOIN lessons l ON l.id=vv.source_lesson_id AND l.encoding_status='ready' WHERE vv.uuid=? AND vv.status='published' LIMIT 1", [$versionUuid] ); @@ -206,43 +255,74 @@ class VideoController try { $lesson = Database::selectOne("SELECT id, uuid, title, grade_key FROM curriculum_lessons WHERE uuid = ? AND source_status = 'approved' LIMIT 1", [$lessonUuid]); if (!$lesson) { $response->status(404)->json(['status'=>'error','message'=>'الدرس غير منشور.']); return; } - $rows = Database::select( - "SELECT vv.id, vv.uuid AS video_version_id, vv.source_lesson_id, ts.uuid AS submission_id, - cl.grade_key, c.grade_level AS course_grade_level, l.course_id, t.full_name AS teacher_name, COALESCE(pm.weighted_student_rating, 0) AS rating, - COALESCE(pm.total_reviews_count, 0) AS rating_count - FROM teacher_submissions ts - JOIN curriculum_lessons cl ON cl.id = ts.curriculum_lesson_id - JOIN video_versions vv ON vv.id = ts.current_published_video_version_id AND vv.status = 'published' - JOIN lessons l ON l.id = vv.source_lesson_id AND l.encoding_status = 'ready' - JOIN courses c ON c.id = l.course_id - JOIN teachers t ON t.id = ts.teacher_id - LEFT JOIN teacher_performance_metrics pm ON pm.teacher_id = t.id - WHERE ts.curriculum_lesson_id = ? AND ts.status = 'published' - ORDER BY (rating_count > 0) DESC, rating DESC, vv.id ASC LIMIT 101", - [$lesson['id']] - ); - // A list is also protected content: do not disclose a teacher, count, - // or version that the student cannot play. A curriculum lesson may - // legitimately have versions attached to different courses. - $accessible = []; - foreach ($rows as $row) { - $targetGrade = \App\Services\StudentAccessControlService::normalizeGrade($row['grade_key'] ?? $lesson['grade_key'] ?? 'grade_10'); - $access = \App\Services\StudentAccessControlService::validateLessonAccess( - (int)$request->user_id, - $request->getHeader('x-national-id'), - $targetGrade, - (int)$row['course_id'], - (int)$row['source_lesson_id'], - false - ); - if (!empty($access['allowed'])) $accessible[] = $row; + // Scan in bounded batches: entitlement is per source lesson/course, + // so a SQL LIMIT before the access check would hide later teachers. + $pdo = Database::getConnection(); + $ownsTransaction = !$pdo->inTransaction(); + if ($ownsTransaction) { + $pdo->exec('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ'); + $pdo->beginTransaction(); } - // `cursor` is an offset in the stable rating order, rather than a - // database id (an id seek would skip records after rating changes). - $page = array_slice($accessible, $cursor, $limit); - $hasMore = count($accessible) > ($cursor + count($page)); - $items = array_map(static fn($r) => ['video_version_id'=>$r['video_version_id'],'submission_id'=>$r['submission_id'],'teacher_name'=>$r['teacher_name'],'rating'=>(float)$r['rating'],'rating_count'=>(int)$r['rating_count'],'is_new'=>(int)$r['rating_count']===0], $page); - $response->json(['status'=>'success','data'=>['curriculum_lesson_id'=>$lesson['uuid'],'title'=>$lesson['title'],'available_count'=>count($accessible),'items'=>$items,'next_cursor'=>$hasMore ? $cursor + count($page) : null]]); + try { + $batchSize = 100; + $rawOffset = 0; + $availableCount = 0; + $page = []; + do { + $rows = Database::select( + "SELECT vv.id, vv.uuid AS video_version_id, vv.source_lesson_id, ts.uuid AS submission_id, + cl.grade_key, l.course_id, l.duration_seconds, vv.published_at, + t.full_name AS teacher_name, COALESCE(pm.weighted_student_rating, 0) AS rating, + COALESCE(pm.total_reviews_count, 0) AS rating_count + FROM teacher_submissions ts + JOIN curriculum_lessons cl ON cl.id = ts.curriculum_lesson_id + JOIN video_versions vv ON vv.id = ts.current_published_video_version_id AND vv.status = 'published' + JOIN lessons l ON l.id = vv.source_lesson_id AND l.encoding_status = 'ready' + JOIN teachers t ON t.id = ts.teacher_id + LEFT JOIN teacher_performance_metrics pm ON pm.teacher_id = t.id + WHERE ts.curriculum_lesson_id = ? AND ts.status = 'published' + AND EXISTS ( + SELECT 1 FROM video_review_jobs j + JOIN publication_bundles pb ON pb.curriculum_lesson_id=cl.id AND pb.status='published' + JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role='primary_lesson' + JOIN content_assets a ON a.id=pba.content_asset_id AND a.sha256=j.markdown_sha256 + AND a.review_status='approved' AND a.rights_status='cleared' + WHERE j.video_version_id=vv.id AND j.status='approved' + ) + ORDER BY (COALESCE(pm.total_reviews_count, 0) > 0) DESC, + COALESCE(pm.weighted_student_rating, 0) DESC, vv.id ASC + LIMIT {$batchSize} OFFSET {$rawOffset}", + [$lesson['id']] + ); + foreach ($rows as $row) { + $targetGrade = \App\Services\StudentAccessControlService::normalizeGrade($row['grade_key'] ?? $lesson['grade_key'] ?? 'grade_10'); + $access = \App\Services\StudentAccessControlService::validateLessonAccess( + (int)$request->user_id, + $request->getHeader('x-national-id'), + $targetGrade, + (int)$row['course_id'], + (int)$row['source_lesson_id'], + false + ); + if (empty($access['allowed'])) continue; + if ($availableCount >= $cursor && count($page) < $limit) $page[] = $row; + $availableCount++; + } + $rawOffset += count($rows); + } while (count($rows) === $batchSize); + if ($ownsTransaction) $pdo->commit(); + } catch (\Throwable $e) { + if ($ownsTransaction && $pdo->inTransaction()) $pdo->rollBack(); + throw $e; + } + $items = array_map(static fn($r) => [ + 'video_version_id'=>$r['video_version_id'], 'submission_id'=>$r['submission_id'], + 'teacher_name'=>$r['teacher_name'], 'rating'=>(float)$r['rating'], + 'rating_count'=>(int)$r['rating_count'], 'is_new'=>(int)$r['rating_count']===0, + 'duration_seconds'=>(int)$r['duration_seconds'], 'published_at'=>$r['published_at'], + ], $page); + $nextCursor = $cursor + count($page); + $response->json(['status'=>'success','data'=>['curriculum_lesson_id'=>$lesson['uuid'],'title'=>$lesson['title'],'available_count'=>$availableCount,'items'=>$items,'next_cursor'=>$nextCursor < $availableCount ? $nextCursor : null]]); } catch (\Throwable $e) { error_log('Lesson videos list failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر تحميل حصص هذا الدرس.']); } } /** POST /api/teacher/submissions/preflight */ @@ -649,8 +729,8 @@ class VideoController public function streamLocalVideo(Request $request, Response $response): void { $uuid = $request->getParam('uuid'); - if (empty($uuid)) { - $response->status(400)->json(['status' => 'error', 'message' => 'معرف الفيديو مطلوب']); + if (empty($uuid) || !$this->mayStreamVideo($request, (string)$uuid)) { + $response->status(403)->json(['status' => 'forbidden', 'message' => 'الفيديو غير متاح لهذه الجلسة']); return; } @@ -666,8 +746,8 @@ class VideoController $uuid = $request->getParam('uuid'); $file = $request->getParam('file') ?: 'index.m3u8'; - if (empty($uuid)) { - $response->status(400)->json(['status' => 'error', 'message' => 'معرف البث مطلوب']); + if (empty($uuid) || !$this->mayStreamVideo($request, (string)$uuid)) { + $response->status(403)->json(['status' => 'forbidden', 'message' => 'الفيديو غير متاح لهذه الجلسة']); return; } @@ -682,13 +762,14 @@ class VideoController { $body = $request->getBody(); $lessonId = (int)($body['lesson_id'] ?? 0); - $timeSeconds = (int)($body['timestamp_seconds'] ?? 15); - $rewindSecs = (int)($body['rewind_seconds'] ?? 45); + $timeSeconds = (int)($body['timestamp_seconds'] ?? 0); + $rewindSecs = (int)($body['rewind_seconds'] ?? 0); $question = trim((string)($body['question_text'] ?? '')); $options = (array)($body['options'] ?? []); $correctIdx = (int)($body['correct_index'] ?? 0); - if (!$lessonId || empty($question) || empty($options)) { + if (!$lessonId || $question === '' || count($options) < 2 || $correctIdx < 0 || $correctIdx >= count($options) || + $timeSeconds <= 0 || $rewindSecs < 0 || array_filter($options, fn($option) => !is_string($option) || trim($option) === '') !== []) { $response->status(400)->json([ 'status' => 'error', 'message' => 'بيانات نقطة الفحص السقراطي والسؤال غير مكتملة' @@ -696,11 +777,19 @@ class VideoController return; } - $lesson = Database::selectOne("SELECT l.id, l.course_id, c.teacher_id FROM lessons l JOIN courses c ON l.course_id = c.id WHERE l.id = ?", [$lessonId]); + $lesson = Database::selectOne("SELECT l.id, l.course_id, l.duration_seconds, c.teacher_id FROM lessons l JOIN courses c ON l.course_id = c.id WHERE l.id = ?", [$lessonId]); if (!$lesson || ($lesson['teacher_id'] != $request->user_id && $request->role !== 'super_admin')) { $response->status(403)->json(['status' => 'error', 'message' => 'غير مصرح: لا تملك هذا الدرس']); return; } + if ($timeSeconds >= (int)$lesson['duration_seconds']) { + $response->status(422)->json(['status'=>'error','message'=>'توقيت السؤال خارج مدة الفيديو']); + return; + } + + $pdo = Database::getConnection(); + $pdo->beginTransaction(); + try { $examUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), @@ -712,7 +801,7 @@ class VideoController $examId = Database::insert( "INSERT INTO exams (uuid, course_id, lesson_id, created_by_id, creator_type, scope, title, timestamp_seconds, rewind_on_fail_seconds, passing_percentage, total_points, is_mandatory, is_published) - VALUES (?, ?, ?, ?, 'teacher', 'in_video_checkpoint', 'فحص سقراطي لحظي', ?, ?, 100.00, 10, 1, 1)", + VALUES (?, ?, ?, ?, 'teacher', 'in_video_checkpoint', 'مسودة سؤال فيديو', ?, ?, 100.00, 10, 1, 0)", [$examUuid, $lesson['course_id'], $lessonId, $request->user_id, $timeSeconds, $rewindSecs] ); @@ -736,10 +825,15 @@ class VideoController [$qId, $optText, $isCorrect] ); } + $pdo->commit(); + } catch (\Throwable $e) { + if ($pdo->inTransaction()) $pdo->rollBack(); + throw $e; + } $response->status(201)->json([ 'status' => 'success', - 'message' => 'تم حفظ وتثبيت نقطة الفحص السقراطي بنجاح!', + 'message' => 'حُفظ السؤال كمسودة، ويحتاج ربطاً بدليل نسخة الفيديو ومراجعة قبل ظهوره للطالب.', 'data' => [ 'exam_id' => $examId, 'timestamp_seconds' => $timeSeconds, @@ -790,6 +884,23 @@ class VideoController $conditions[] = $gradeCond; } + // Do not expose legacy lesson rows as student video offerings. The + // exact published version must have approved, version-bound review + // evidence tied to the currently published curriculum Markdown. + $conditions[] = "EXISTS ( + SELECT 1 + FROM video_versions vv + JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id + AND ts.current_published_video_version_id=vv.id AND ts.status='published' + JOIN curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id AND cl.source_status='approved' + JOIN video_review_jobs j ON j.video_version_id=vv.id AND j.status='approved' + JOIN publication_bundles pb ON pb.curriculum_lesson_id=cl.id AND pb.status='published' + JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role='primary_lesson' + JOIN content_assets a ON a.id=pba.content_asset_id AND a.sha256=j.markdown_sha256 + AND a.review_status='approved' AND a.rights_status='cleared' + WHERE vv.source_lesson_id=l.id AND vv.status='published' + )"; + $whereClause = !empty($conditions) ? ('WHERE ' . implode(' AND ', $conditions)) : ''; $lessons = Database::select( @@ -962,69 +1073,9 @@ class VideoController return; } - // Self-Healing Curriculum Guard: Purge any obsolete/mismatched calculus questions - // for non-calculus lessons (e.g. Grade 10 Systems of Equations) - $isCalculusLesson = (str_contains($lesson['title'], 'اشتقاق') || str_contains($lesson['title'], 'تفاضل')); - if (!$isCalculusLesson) { - try { - $mismatched = Database::selectOne( - "SELECT q.id FROM questions q - JOIN exams e ON q.exam_id = e.id - WHERE e.lesson_id = ? AND (q.question_text LIKE '%مشتق%' OR q.question_text LIKE '%f\'(x)%') - LIMIT 1", - [$lessonId] - ); - if ($mismatched) { - $badExams = Database::select("SELECT id FROM exams WHERE lesson_id = ? AND scope = 'in_video_checkpoint'", [$lessonId]); - foreach ($badExams as $be) { - Database::query("DELETE FROM exams WHERE id = ?", [$be['id']]); - } - } - } catch (\Throwable $e) { - error_log("Curriculum self-healing notice: " . $e->getMessage()); - } - } - - // Playback is read-only. Checkpoints are published only by the review - // workflow after it verifies the video transcript and lesson Markdown. - - // Fetch attached in-video Socratic Checkpoints with Questions and Options - $exams = Database::select( - "SELECT e.id as exam_id, e.uuid as exam_uuid, e.title, e.timestamp_seconds, e.rewind_on_fail_seconds, e.passing_percentage - FROM exams e - WHERE e.lesson_id = ? AND e.scope = 'in_video_checkpoint' AND e.is_published = 1 - ORDER BY e.timestamp_seconds ASC", - [$lessonId] - ); - + // Legacy exams have no video-version evidence binding. They must not + // mutate storage during GET or be exposed as approved checkpoints. $checkpoints = []; - foreach ($exams as $ex) { - $q = Database::selectOne("SELECT id, question_text, explanation_text FROM questions WHERE exam_id = ? LIMIT 1", [$ex['exam_id']]); - if (!$q) { - continue; - } - $opts = []; - $opts = Database::select("SELECT id, option_text, is_correct, feedback_text FROM question_options WHERE question_id = ?", [$q['id']]); - if (count($opts) < 2) { - continue; - } - - $checkpoints[] = [ - 'exam_id' => (int)$ex['exam_id'], - 'question_id' => (int)$q['id'], - 'timestamp_seconds' => (int)$ex['timestamp_seconds'], - 'rewind_on_fail_seconds' => (int)$ex['rewind_on_fail_seconds'], - 'question_text' => $q['question_text'], - 'explanation' => $q['explanation_text'] ?? '', - 'options' => array_map(function ($o) { - return [ - 'id' => (int)$o['id'], - 'text' => $o['option_text'], - 'is_correct' => (bool)$o['is_correct'] - ]; - }, $opts) - ]; - } $storageType = $lesson['storage_type'] ?? 'bunny_stream'; $playbackInfo = []; diff --git a/backend/app/Services/AssetDownloadTicketService.php b/backend/app/Services/AssetDownloadTicketService.php new file mode 100644 index 0000000..b2ebf86 --- /dev/null +++ b/backend/app/Services/AssetDownloadTicketService.php @@ -0,0 +1,36 @@ +setex($key, self::TTL_SECONDS, json_encode([ + 'student_id' => $studentId, + 'asset_uuid' => $assetUuid, + ], JSON_THROW_ON_ERROR)); + if (!$saved) throw new \RuntimeException('Download grant unavailable'); + return ['ticket' => $ticket, 'expires_in_seconds' => self::TTL_SECONDS]; + } + + public static function resolve(string $ticket, string $assetUuid): ?int + { + if (!preg_match('/^[A-Za-z0-9_-]{43}$/', $ticket)) return null; + $payload = RedisClient::getInstance()->get('asset_download:' . hash('sha256', $ticket)); + if (!is_string($payload)) return null; + $grant = json_decode($payload, true); + if (!is_array($grant) || !hash_equals((string)($grant['asset_uuid'] ?? ''), $assetUuid)) return null; + $studentId = (int)($grant['student_id'] ?? 0); + return $studentId > 0 ? $studentId : null; + } +} diff --git a/backend/app/Services/CurriculumService.php b/backend/app/Services/CurriculumService.php index d2f0b59..412e460 100644 --- a/backend/app/Services/CurriculumService.php +++ b/backend/app/Services/CurriculumService.php @@ -144,7 +144,6 @@ class CurriculumService */ public static function getCurriculumTree(): array { - self::ensureStorage(); if (!file_exists(self::$manifestFile)) { return []; } @@ -281,13 +280,19 @@ class CurriculumService */ public static function saveLessonMarkdown(string $relativePath, string $content): bool { + if (!self::safeMarkdownPath($relativePath)) return false; self::ensureStorage(); - $fullPath = self::$storagePath . '/' . ltrim($relativePath, '/'); + $root = realpath(self::$storagePath); + if ($root === false) return false; + $fullPath = $root . '/' . $relativePath; $dir = dirname($fullPath); if (!is_dir($dir)) { - mkdir($dir, 0777, true); + if (!mkdir($dir, 0750, true) && !is_dir($dir)) return false; } - return file_put_contents($fullPath, $content) !== false; + $realDir = realpath($dir); + if ($realDir === false || !str_starts_with($realDir, $root . DIRECTORY_SEPARATOR)) return false; + if (is_link($fullPath)) return false; + return file_put_contents($fullPath, $content, LOCK_EX) !== false; } /** @@ -295,12 +300,21 @@ class CurriculumService */ public static function getLessonMarkdown(string $relativePath): string { - self::ensureStorage(); - $fullPath = self::$storagePath . '/' . ltrim($relativePath, '/'); - if (file_exists($fullPath)) { - return file_get_contents($fullPath); + $root = realpath(self::$storagePath); + if ($root === false || !self::safeMarkdownPath($relativePath)) return ''; + $fullPath = realpath($root . '/' . $relativePath); + if ($fullPath === false || !str_starts_with($fullPath, $root . DIRECTORY_SEPARATOR) || !is_file($fullPath)) return ''; + return file_get_contents($fullPath) ?: ''; + } + + public static function safeMarkdownPath(string $path): bool + { + if ($path === '' || str_starts_with($path, '/') || str_contains($path, '\\') + || !str_ends_with(strtolower($path), '.md') || preg_match('/[\x00-\x1f]/', $path)) return false; + foreach (explode('/', $path) as $part) { + if ($part === '' || $part === '.' || $part === '..') return false; } - return "# محتوى المنهاج\nالمحتوى المعتمد للمنهاج الرسمي."; + return true; } public static function saveLessonAiAssets(string $relativePath, array $assets): bool diff --git a/backend/app/Services/NabehService.php b/backend/app/Services/NabehService.php index ac9f8a3..1cb48cd 100644 --- a/backend/app/Services/NabehService.php +++ b/backend/app/Services/NabehService.php @@ -137,7 +137,7 @@ class NabehService // 1. Attempt with selected type (image card) $result = $this->attemptSend($phoneRaw, $selectedType, $otp, $appName, $bearerToken); - if ($result['success']) { + if ($result['success'] || !empty($result['delivery_unknown'])) { return $result; } @@ -192,18 +192,18 @@ class NabehService if ($curlError) { error_log("❌ [Nabeh OTP cURL Error (type={$type}, duration={$duration}s)] " . $curlError); - // Resilient Fallback: If gateway response timed out after dispatching (duration >= 18s), - // Nabeh has already received and queued the WhatsApp image/text message. - // The OTP is already stored in Redis on Saqel. + // A timeout does not prove whether Nabeh accepted or delivered the message. + // Do not report success or send a second OTP via the text fallback: that could + // create a duplicate while the original gateway request is still completing. if ($duration >= 18.0 && str_contains(strtolower($curlError), 'timed out')) { - error_log("⚠️ [Nabeh OTP Tolerant Dispatch] WhatsApp ({$type}) message likely queued/dispatched despite gateway latency ({$duration}s). Proceeding."); + error_log("⚠️ [Nabeh OTP Delivery Unknown] WhatsApp ({$type}) request timed out after {$duration}s; delivery is unconfirmed."); return [ - 'success' => true, - 'type_used' => $type, - 'duration' => "{$duration}s", - 'http_code' => 200, - 'message' => 'تم إرسال رمز التحقق بنجاح عبر بطاقة الواتساب المصورة', - 'note' => 'Dispatched under gateway latency' + 'success' => false, + 'delivery_unknown' => true, + 'type_used' => $type, + 'duration' => "{$duration}s", + 'http_code' => $httpCode, + 'error' => 'انتهت مهلة بوابة واتساب؛ لم يصل تأكيد التسليم. تحقّق من الرسائل قبل طلب رمز جديد.' ]; } diff --git a/backend/app/Services/PublishedContentService.php b/backend/app/Services/PublishedContentService.php index 2019bfa..1e07b80 100644 --- a/backend/app/Services/PublishedContentService.php +++ b/backend/app/Services/PublishedContentService.php @@ -51,31 +51,24 @@ final class PublishedContentService return null; } - $candidates = [ - $root . '/' . $storageKey, - $root . '/_incoming/' . $storageKey, - dirname($root) . '/' . $storageKey, - dirname($root, 2) . '/' . $storageKey, - ]; - - if (preg_match('#staged/grade_10/[^/]+/(.+)#', $storageKey, $matches)) { - $inner = $matches[1]; - $candidates[] = $root . '/' . $inner; - $candidates[] = $root . '/_incoming/' . $inner; - } - + // Published storage keys must resolve to their exact file inside the + // curriculum root. Do not fall back to another similarly named file. + $real = realpath($root . '/' . $storageKey); $prefix = rtrim($root, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR; - $projectRoot = realpath(dirname(__DIR__, 2)) ?: ''; + return $real !== false && str_starts_with($real, $prefix) && is_file($real) ? $real : null; + } - foreach ($candidates as $candidate) { - $real = realpath($candidate); - if ($real && is_file($real)) { - // Ensure candidate is strictly within the project root to prevent traversal - if ($projectRoot !== '' && str_starts_with($real, $projectRoot)) { - return $real; - } - } - } - return null; + public static function studentMayRead(int $studentId, array $asset): bool + { + if ($studentId <= 0 || empty($asset['grade_key'])) return false; + $student = Database::selectOne( + "SELECT s.grade_level FROM students s + JOIN auth_identities ai ON ai.id=s.identity_id AND ai.status='active' + WHERE s.id=? LIMIT 1", + [$studentId] + ); + if (!$student || empty($student['grade_level'])) return false; + return StudentAccessControlService::normalizeGrade((string)$student['grade_level']) + === StudentAccessControlService::normalizeGrade((string)$asset['grade_key']); } } diff --git a/backend/app/Services/PublishedCurriculumTreeFilter.php b/backend/app/Services/PublishedCurriculumTreeFilter.php new file mode 100644 index 0000000..f3aa3a2 --- /dev/null +++ b/backend/app/Services/PublishedCurriculumTreeFilter.php @@ -0,0 +1,78 @@ + $grade) { + if (!is_array($grade) || !is_array($grade['subjects'] ?? null)) continue; + $subjects = []; + foreach ($grade['subjects'] as $subjectKey => $subject) { + if (!is_array($subject) || !is_array($subject['semesters'] ?? null)) continue; + $semesters = []; + foreach ($subject['semesters'] as $semesterKey => $semester) { + if (!is_array($semester) || !is_array($semester['units'] ?? null)) continue; + $units = []; + foreach ($semester['units'] as $unitKey => $unit) { + if (!is_array($unit) || !is_array($unit['lessons'] ?? null)) continue; + $lessons = []; + foreach ($unit['lessons'] as $lesson) { + if (!is_array($lesson)) continue; + $key = self::key( + (string)$gradeKey, (string)$subjectKey, + (string)$semesterKey, (string)$unitKey, + (string)($lesson['id'] ?? ''), (string)($lesson['file'] ?? '') + ); + $row = $approved[$key] ?? null; + if ($row === null) continue; + $lessons[] = [ + 'id' => (string)$row['lesson_key'], + 'title' => (string)$row['title'], + 'curriculum_lesson_id' => (string)$row['uuid'], + 'curriculum_version' => (string)$row['curriculum_version'], + 'primary_lesson_asset_id' => (string)($row['primary_asset_id'] ?? ''), + 'has_video' => !empty($row['has_video']), + ]; + } + if ($lessons === []) continue; + $unit['lessons'] = $lessons; + $units[$unitKey] = $unit; + } + if ($units === []) continue; + $semester['units'] = $units; + $semesters[$semesterKey] = $semester; + } + if ($semesters === []) continue; + $subject['semesters'] = $semesters; + $subject['resources'] = ['textbooks' => ['items' => []], 'worksheets' => ['items' => []]]; + $subjects[$subjectKey] = $subject; + } + if ($subjects === []) continue; + $grade['subjects'] = $subjects; + $visible[$gradeKey] = $grade; + } + return $visible; + } + + private static function key(string ...$parts): string + { + return json_encode($parts, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); + } +} diff --git a/backend/app/Services/RemediationAttemptGrader.php b/backend/app/Services/RemediationAttemptGrader.php new file mode 100644 index 0000000..2e1a866 --- /dev/null +++ b/backend/app/Services/RemediationAttemptGrader.php @@ -0,0 +1,30 @@ + $questionId) { + $key = $answerKey[$index] ?? null; + $selected = $answers[$index]; + if (!is_array($key) || !is_int($selected) || $selected <= 0 || + (int)($key['question_id'] ?? 0) !== (int)$questionId || + !is_array($key['option_ids'] ?? null) || + !in_array($selected, $key['option_ids'], true) || + !in_array((int)($key['correct_option_id'] ?? 0), $key['option_ids'], true)) { + throw new \InvalidArgumentException('Answer does not belong to the attempt'); + } + if ($selected === (int)$key['correct_option_id']) $correct++; + } + return $correct; + } +} diff --git a/backend/app/Services/StudentAccessControlService.php b/backend/app/Services/StudentAccessControlService.php index c6ac878..d183286 100644 --- a/backend/app/Services/StudentAccessControlService.php +++ b/backend/app/Services/StudentAccessControlService.php @@ -38,7 +38,7 @@ class StudentAccessControlService string $targetGrade, ?int $courseId = null, ?int $lessonId = null, - bool $allowSideEffects = true + bool $allowSideEffects = false ): array { // 1. استرجاع بيانات الطالب وسجله الدراسي $student = null; @@ -64,19 +64,9 @@ class StudentAccessControlService ); } - // إذا لم يكن مسجلاً، نتحقق مما إذا كان الدرس معاينة مجانية (Preview) + // Protected content requires a current student record. A preview flag + // must not turn a missing/deleted student into a valid entitlement. if (!$student) { - if ($lessonId) { - $lesson = Database::selectOne("SELECT is_free_preview FROM lessons WHERE id = ? LIMIT 1", [$lessonId]); - if ($lesson && !empty($lesson['is_free_preview'])) { - return [ - 'allowed' => true, - 'reason' => 'free_preview', - 'student_grade' => 'guest', - 'is_sponsored' => false - ]; - } - } return [ 'allowed' => false, 'reason' => 'unauthenticated_or_not_found', @@ -86,79 +76,71 @@ class StudentAccessControlService ]; } - $activeStudentGrade = self::normalizeGrade($student['grade_level'] ?? 'grade_10'); + $activeStudentGrade = self::normalizeGrade((string)($student['grade_level'] ?? '')); $normalizedTargetGrade = self::normalizeGrade($targetGrade); - - // Auto-heal obsolete database schema default: - // If student was recorded with the old default 'tawjihi_2008' or empty and target is grade_10 - if ($allowSideEffects && ($student['grade_level'] === 'tawjihi_2008' || empty($student['grade_level'])) && $normalizedTargetGrade === 'grade_10') { - Database::query("UPDATE students SET grade_level = 'grade_10', updated_at = NOW() WHERE id = ?", [(int)$student['id']]); - $student['grade_level'] = 'grade_10'; - $activeStudentGrade = 'grade_10'; - } - - // 2. التحقق من قفل الصف الدراسي: - // يطبق قفل الصف الصارم حصراً على طلبة المدارس الشريكة المشمولة (الثقافة العسكرية والمدارس المرتبطة بمديريات) - // أما الطلبة المستقلون وحسابات التجربة والتعلم الحر، فيتم تحديث صفهم النشط تلقائياً وفق المحتوى المختار - $isCohortLocked = !empty($student['is_school_sponsored']) || !empty($student['school_id']); if ($activeStudentGrade !== $normalizedTargetGrade) { - if (!$isCohortLocked && !empty($student['id'])) { - if ($allowSideEffects) { - Database::query("UPDATE students SET grade_level = ? WHERE id = ?", [$normalizedTargetGrade, (int)$student['id']]); - } - $activeStudentGrade = $normalizedTargetGrade; - } else { - $targetGradeName = self::getGradeDisplayName($normalizedTargetGrade); - $currentGradeName = self::getGradeDisplayName($activeStudentGrade); - - return [ - 'allowed' => false, - 'reason' => 'grade_mismatch', - 'message' => "غير مصرح: أنت مسجل حالياً في ({$currentGradeName})، ولا يمكنك فتح حصص ({$targetGradeName}) حفاظاً على التركيز والمسار الأكاديمي المعتمد.", - 'student_grade' => $activeStudentGrade, - 'target_grade' => $normalizedTargetGrade, - 'is_sponsored' => (bool)($student['is_school_sponsored'] ?? false) - ]; - } - } - - // 3. التحقق من النموذج المالي المزدوج: - // أ. إذا كان الطالب تابعاً لمدارس الثقافة العسكرية أو مدرسة خاصة شريكة - $isMilitaryCulture = ($student['directorate_type'] ?? '') === 'military_culture' || ($student['school_type'] ?? '') === 'military_culture'; - $isPrivateSponsored = !empty($student['is_school_sponsored']) && !empty($student['school_id']); - - if ($isMilitaryCulture || $isPrivateSponsored) { - // الطالب مشمول مجاناً 100% (صفر دينار) - if ($allowSideEffects && $courseId && !empty($student['id'])) { - self::ensureSchoolIncludedPass((int)$student['id'], $courseId); - } - return [ - 'allowed' => true, - 'reason' => 'school_sponsored_free', - 'message' => 'مشمول مجاناً عبر المنظومة المؤسسية الشريكة (مديرية الثقافة العسكرية / المدرسة المعتمدة).', - 'student_grade' => $activeStudentGrade, - 'is_sponsored' => true, - 'fee_jod' => 0.00 + 'allowed' => false, 'reason' => 'grade_mismatch', + 'message' => 'الدرس خارج صف الطالب المسجل؛ غيّر صفك من الملف المصرح أو راجع المدرسة.', + 'student_grade' => $activeStudentGrade, 'target_grade' => $normalizedTargetGrade, + 'is_sponsored' => (bool)($student['is_school_sponsored'] ?? false), ]; } - // ب. إذا كان طالباً مستقلاً خارج المدارس الشريكة (External Student) + // A lesson read never changes grade or creates a school access pass. + // Entitlements are checked against an actual course and school roster. if ($courseId) { - // المساقات المجانية (السعر 0.00 دينار) متاحة فوراً ومجاناً للجميع - $course = Database::selectOne("SELECT price_jod FROM courses WHERE id = ? LIMIT 1", [$courseId]); - if ($course && (float)($course['price_jod'] ?? 0) <= 0.0) { + $course = Database::selectOne( + 'SELECT c.id, c.school_id, c.is_school_exclusive, c.is_published, c.price_jod, + c.grade_level, t.is_school_exclusive AS teacher_school_exclusive, + t.is_marketplace_public + FROM courses c JOIN teachers t ON t.id=c.teacher_id WHERE c.id=? LIMIT 1', + [$courseId] + ); + if (!$course || (int)$course['is_published'] !== 1) { + return ['allowed' => false, 'reason' => 'course_unavailable', 'student_grade' => $activeStudentGrade]; + } + if ($course['grade_level'] !== null && $course['grade_level'] !== '' + && self::normalizeGrade((string)$course['grade_level']) !== $normalizedTargetGrade) { + return ['allowed' => false, 'reason' => 'course_grade_mismatch', 'student_grade' => $activeStudentGrade]; + } + if ($lessonId) { + $sourceLesson = Database::selectOne('SELECT course_id FROM lessons WHERE id=? LIMIT 1', [$lessonId]); + if (!$sourceLesson || (int)$sourceLesson['course_id'] !== $courseId) { + return ['allowed' => false, 'reason' => 'lesson_course_mismatch', 'student_grade' => $activeStudentGrade]; + } + } + + $schoolId = (int)($course['school_id'] ?? 0); + if ($schoolId > 0 || (int)$course['is_school_exclusive'] === 1 || (int)$course['teacher_school_exclusive'] === 1) { + if ($schoolId <= 0 || (int)($student['school_id'] ?? 0) !== $schoolId + || empty($student['is_school_sponsored'])) { + return ['allowed' => false, 'reason' => 'school_scope_mismatch', 'student_grade' => $activeStudentGrade]; + } + $roster = Database::selectOne( + 'SELECT grade_level FROM school_rosters WHERE school_id=? AND claimed_student_id=? AND is_claimed=1 LIMIT 1', + [$schoolId, (int)$student['id']] + ); + if (!$roster || self::normalizeGrade((string)$roster['grade_level']) !== $normalizedTargetGrade) { + return ['allowed' => false, 'reason' => 'school_roster_unverified', 'student_grade' => $activeStudentGrade]; + } return [ - 'allowed' => true, - 'reason' => 'free_course', - 'student_grade' => $activeStudentGrade, - 'is_sponsored' => false + 'allowed' => true, 'reason' => 'school_course_included', + 'student_grade' => $activeStudentGrade, 'is_sponsored' => true, ]; } + if ((int)$course['is_marketplace_public'] !== 1) { + return ['allowed' => false, 'reason' => 'not_marketplace_public', 'student_grade' => $activeStudentGrade]; + } + if ((float)$course['price_jod'] <= 0.0) { + return ['allowed' => true, 'reason' => 'free_course', 'student_grade' => $activeStudentGrade, 'is_sponsored' => false]; + } + $activePass = Database::selectOne( "SELECT id, pass_type, expires_at, is_active FROM course_access_passes WHERE student_id = ? AND course_id = ? AND is_active = 1 + AND pass_type IN ('discounted_micro_pass','full_marketplace') AND (expires_at IS NULL OR expires_at > NOW()) LIMIT 1", [(int)$student['id'], $courseId] @@ -174,7 +156,7 @@ class StudentAccessControlService ]; } - // فحص إذا كان الدرس معاينة مجانية + // An explicit free preview is allowed only on a public course. if ($lessonId) { $lesson = Database::selectOne("SELECT is_free_preview FROM lessons WHERE id = ? LIMIT 1", [$lessonId]); if ($lesson && !empty($lesson['is_free_preview'])) { @@ -279,30 +261,6 @@ class StudentAccessControlService ]; } - /** - * التحقق من وجود تصريح مدرسي مجاني أو إنشاؤه تلقائياً لطلبة المدارس الشريكة - */ - private static function ensureSchoolIncludedPass(int $studentId, int $courseId): void - { - $existing = Database::selectOne( - "SELECT id FROM course_access_passes WHERE student_id = ? AND course_id = ? LIMIT 1", - [$studentId, $courseId] - ); - - if (!$existing) { - $course = Database::selectOne("SELECT teacher_id FROM courses WHERE id = ? LIMIT 1", [$courseId]); - $teacherId = (int)($course['teacher_id'] ?? 1); - - Database::insert( - "INSERT INTO course_access_passes - (student_id, course_id, teacher_id, pass_type, price_paid_jod, expires_at, is_active) - VALUES - (?, ?, ?, 'school_included', 0.00, DATE_ADD(NOW(), INTERVAL 1 YEAR), 1)", - [$studentId, $courseId, $teacherId] - ); - } - } - /** * توحيد أسماء ومفاتيح الصفوف */ diff --git a/backend/app/Services/TeacherLedgerService.php b/backend/app/Services/TeacherLedgerService.php index 42febb2..4166ea3 100644 --- a/backend/app/Services/TeacherLedgerService.php +++ b/backend/app/Services/TeacherLedgerService.php @@ -1,40 +1,135 @@ $account,'gross_fils'=>$gross,'held_fils'=>$held,'available_fils'=>max(0,$gross-$held)]; +final class TeacherLedgerService +{ + public static function balance(int $teacherId): array + { + // Balance reads must never create accounts or mutate financial state. + $accountId = self::existingAccountId($teacherId); + $gross = 0; + if ($accountId !== null) { + $row = Database::selectOne( + 'SELECT COALESCE(SUM(CASE WHEN credit_account_id=? THEN CAST(amount_fils AS SIGNED) WHEN debit_account_id=? THEN -CAST(amount_fils AS SIGNED) ELSE 0 END),0) AS balance + FROM ledger_entries WHERE credit_account_id=? OR debit_account_id=?', + [$accountId, $accountId, $accountId, $accountId] + ); + $gross = (int)($row['balance'] ?? 0); + } + $holds = Database::selectOne( + "SELECT COALESCE(SUM(amount_fils),0) AS held FROM teacher_withdrawal_holds WHERE teacher_id=? AND status='held'", + [$teacherId] + ); + $held = (int)($holds['held'] ?? 0); + return [ + 'account_id' => $accountId, + 'gross_fils' => $gross, + 'held_fils' => $held, + 'available_fils' => max(0, $gross - $held), + ]; } - public static function placeWithdrawalHold(int $teacherId,int $amountFils,string $key):array { - if($amountFils<=0||$amountFils>100000000||!preg_match('/^[A-Za-z0-9._:-]{16,128}$/',$key))return ['http_status'=>400,'status'=>'error','message'=>'قيمة الحجز أو مفتاح الإعادة غير صالح.']; - $pdo=Database::getConnection();$pdo->beginTransaction();try { - $existing=Database::selectOne('SELECT uuid,amount_fils,status FROM teacher_withdrawal_holds WHERE teacher_id=? AND idempotency_key=? FOR UPDATE',[$teacherId,$key]); - if($existing){if((int)$existing['amount_fils']!==$amountFils)return self::finish($pdo,['http_status'=>409,'status'=>'idempotency_conflict','message'=>'استعمل المفتاح ذاته بمبلغ مختلف.']);return self::finish($pdo,['http_status'=>200,'status'=>$existing['status'],'withdrawal_hold_id'=>$existing['uuid'],'replayed'=>true]);} - $balance=self::balance($teacherId); - if($amountFils>$balance['available_fils'])return self::finish($pdo,['http_status'=>409,'status'=>'insufficient_available_balance','message'=>'الرصيد المتاح لا يغطي الحجز.','available_fils'=>$balance['available_fils']]); - $uuid=self::uuid();Database::insert("INSERT INTO teacher_withdrawal_holds (uuid,teacher_id,amount_fils,status,idempotency_key) VALUES (?,?,?,'held',?)",[$uuid,$teacherId,$amountFils,$key]); - return self::finish($pdo,['http_status'=>201,'status'=>'held','withdrawal_hold_id'=>$uuid,'amount_fils'=>$amountFils]); - }catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;} + + public static function placeWithdrawalHold(int $teacherId, int $amountFils, string $key): array + { + if ($teacherId <= 0 || $amountFils <= 0 || $amountFils > 100000000 + || !preg_match('/^[A-Za-z0-9._:-]{16,128}$/', $key)) { + return ['http_status' => 400, 'status' => 'error', 'message' => 'قيمة الحجز أو مفتاح الإعادة غير صالح.']; + } + + $pdo = Database::getConnection(); + $pdo->beginTransaction(); + try { + // This existing row serializes all holds for one teacher. Lock it + // before the first consistent read, so the next hold sees this one. + $teacher = Database::selectOne('SELECT id FROM teachers WHERE id=? FOR UPDATE', [$teacherId]); + if (!$teacher) { + return self::finish($pdo, ['http_status' => 404, 'status' => 'teacher_not_found']); + } + $existing = Database::selectOne( + 'SELECT uuid, amount_fils, status FROM teacher_withdrawal_holds WHERE teacher_id=? AND idempotency_key=? FOR UPDATE', + [$teacherId, $key] + ); + if ($existing) { + if ((int)$existing['amount_fils'] !== $amountFils) { + return self::finish($pdo, [ + 'http_status' => 409, 'status' => 'idempotency_conflict', + 'message' => 'استعمل المفتاح ذاته بمبلغ مختلف.', + ]); + } + return self::finish($pdo, [ + 'http_status' => 200, 'status' => $existing['status'], + 'withdrawal_hold_id' => $existing['uuid'], 'replayed' => true, + ]); + } + $balance = self::balance($teacherId); + if ($amountFils > $balance['available_fils']) { + return self::finish($pdo, [ + 'http_status' => 409, 'status' => 'insufficient_available_balance', + 'message' => 'الرصيد المتاح لا يغطي الحجز.', + 'available_fils' => $balance['available_fils'], + ]); + } + $uuid = self::uuid(); + Database::insert( + "INSERT INTO teacher_withdrawal_holds (uuid,teacher_id,amount_fils,status,idempotency_key) VALUES (?,?,?,'held',?)", + [$uuid, $teacherId, $amountFils, $key] + ); + return self::finish($pdo, [ + 'http_status' => 201, 'status' => 'held', + 'withdrawal_hold_id' => $uuid, 'amount_fils' => $amountFils, + ]); + } catch (\Throwable $e) { + if ($pdo->inTransaction()) $pdo->rollBack(); + throw $e; + } } - public static function releaseHold(int $teacherId,string $holdUuid,string $reason):array { - if(!self::isUuid($holdUuid)||trim($reason)==='')return ['http_status'=>400,'status'=>'error','message'=>'بيانات إلغاء الحجز غير صالحة.']; - $changed=Database::execute("UPDATE teacher_withdrawal_holds SET status='released',released_at=NOW() WHERE uuid=? AND teacher_id=? AND status='held'",[$holdUuid,$teacherId]); - return $changed===1?['http_status'=>200,'status'=>'released','reason'=>$reason]:['http_status'=>409,'status'=>'hold_not_releasable','message'=>'الحجز غير موجود أو تمت تسويته.']; + + public static function releaseHold(int $teacherId, string $holdUuid, string $reason): array + { + $reason = trim($reason); + if ($teacherId <= 0 || !self::isUuid($holdUuid) || $reason === '' || mb_strlen($reason) > 500) { + return ['http_status' => 400, 'status' => 'error', 'message' => 'بيانات إلغاء الحجز غير صالحة.']; + } + $changed = Database::execute( + "UPDATE teacher_withdrawal_holds SET status='released',released_at=NOW(),release_reason=? WHERE uuid=? AND teacher_id=? AND status='held'", + [$reason, $holdUuid, $teacherId] + ); + return $changed === 1 + ? ['http_status' => 200, 'status' => 'released', 'reason' => $reason] + : ['http_status' => 409, 'status' => 'hold_not_releasable', 'message' => 'الحجز غير موجود أو تمت تسويته.']; } - private static function account(int $teacherId):int { - $existing=Database::selectOne("SELECT id FROM ledger_accounts WHERE code='teacher_available' AND owner_type='teacher' AND owner_id=? AND currency='JOD' LIMIT 1",[$teacherId]); - if($existing)return(int)$existing['id']; - Database::insert("INSERT INTO ledger_accounts (code,owner_type,owner_id,currency) VALUES ('teacher_available','teacher',?,'JOD')",[$teacherId]); - return(int)(Database::selectOne("SELECT id FROM ledger_accounts WHERE code='teacher_available' AND owner_type='teacher' AND owner_id=? AND currency='JOD' LIMIT 1",[$teacherId])['id']??0); + + private static function existingAccountId(int $teacherId): ?int + { + $row = Database::selectOne( + "SELECT id FROM ledger_accounts WHERE code='teacher_available' AND owner_type='teacher' AND owner_id=? AND currency='JOD' LIMIT 1", + [$teacherId] + ); + return $row ? (int)$row['id'] : null; + } + + private static function isUuid(string $value): bool + { + return (bool)preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $value); + } + + private static function uuid(): string + { + $bytes = random_bytes(16); + $bytes[6] = chr((ord($bytes[6]) & 15) | 64); + $bytes[8] = chr((ord($bytes[8]) & 63) | 128); + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4)); + } + + private static function finish(PDO $pdo, array $result): array + { + $pdo->commit(); + return $result; } - private static function isUuid(string $v):bool{return(bool)preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',$v);} - private static function uuid():string{$b=random_bytes(16);$b[6]=chr((ord($b[6])&15)|64);$b[8]=chr((ord($b[8])&63)|128);return vsprintf('%s%s-%s-%s-%s-%s%s%s',str_split(bin2hex($b),4));} - private static function finish(\PDO $pdo,array $result):array{$pdo->commit();return $result;} } diff --git a/backend/app/Services/TeacherSubmissionService.php b/backend/app/Services/TeacherSubmissionService.php index 64441c6..113fc52 100644 --- a/backend/app/Services/TeacherSubmissionService.php +++ b/backend/app/Services/TeacherSubmissionService.php @@ -20,6 +20,7 @@ final class TeacherSubmissionService { $submission = Database::selectOne('SELECT * FROM teacher_submissions WHERE teacher_id=? AND curriculum_lesson_id=? FOR UPDATE',[$teacherId,$lesson['id']]); if ($submission && !$replacement) return self::commit($pdo,$teacherId,$operation,$key,$hash,['http_status'=>409,'status'=>'existing_submission','message'=>'لديك حصة مسجلة لهذا الدرس. استخدم الاستبدال لإنشاء نسخة مرشحة.','submission_id'=>$submission['uuid'],'current_published_video_version_id'=>$submission['current_published_video_version_id'] ?: null]); if (!$submission && $replacement) return self::commit($pdo,$teacherId,$operation,$key,$hash,self::result(409,'replacement_target_missing','لا توجد حصة حالية لاستبدالها.')); + if ($submission && $replacement && empty($submission['current_published_video_version_id'])) return self::commit($pdo,$teacherId,$operation,$key,$hash,self::result(409,'replacement_target_unpublished','لا توجد نسخة منشورة حالية لاستبدالها.')); if ($submissionUuid && (!$submission || !hash_equals((string)$submission['uuid'],$submissionUuid))) return self::commit($pdo,$teacherId,$operation,$key,$hash,self::result(409,'replacement_target_mismatch','هدف الاستبدال لا يطابق حصة المعلم الحالية.')); if (!$submission) { Database::insert("INSERT INTO teacher_submissions (uuid,teacher_id,curriculum_lesson_id,status) VALUES (?,?,?,'draft')",[self::uuid(),$teacherId,$lesson['id']]); $submission=Database::selectOne('SELECT * FROM teacher_submissions WHERE teacher_id=? AND curriculum_lesson_id=? FOR UPDATE',[$teacherId,$lesson['id']]); } $next=(int)(Database::selectOne('SELECT COALESCE(MAX(version_number),0) n FROM video_versions WHERE teacher_submission_id=? FOR UPDATE',[$submission['id']])['n'] ?? 0)+1; @@ -30,13 +31,14 @@ final class TeacherSubmissionService { } public static function attachUploadedLesson(int $teacherId,string $versionUuid,int $lessonId,string $sha): void { if(!self::uuidValid($versionUuid)||!preg_match('/^[0-9a-f]{64}$/i',$sha)) throw new \RuntimeException('هوية النسخة أو بصمة الملف غير صالحة.'); - $pdo=Database::getConnection();$pdo->beginTransaction();try { $v=Database::selectOne("SELECT vv.id,vv.status,ts.id submission_id FROM video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id WHERE vv.uuid=? AND ts.teacher_id=? FOR UPDATE",[$versionUuid,$teacherId]); if(!$v||!in_array($v['status'],['draft','uploading'],true)) throw new \RuntimeException('نسخة الفيديو غير متاحة لهذا الرفع.'); Database::query("UPDATE video_versions SET source_lesson_id=?,content_sha256=?,status='review' WHERE id=?",[$lessonId,$sha,$v['id']]);Database::query("UPDATE teacher_submissions SET status='review' WHERE id=?",[$v['submission_id']]);$pdo->commit(); VideoReviewService::queueForVersion((int)$v['id']); } catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;} + $pdo=Database::getConnection();$pdo->beginTransaction();try { $v=Database::selectOne("SELECT vv.id,vv.status,ts.id submission_id FROM video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id WHERE vv.uuid=? AND ts.teacher_id=? FOR UPDATE",[$versionUuid,$teacherId]); if(!$v||!in_array($v['status'],['draft','uploading'],true)) throw new \RuntimeException('نسخة الفيديو غير متاحة لهذا الرفع.'); Database::query("UPDATE video_versions SET source_lesson_id=?,content_sha256=?,status='review' WHERE id=?",[$lessonId,$sha,$v['id']]);Database::query("UPDATE teacher_submissions SET status=CASE WHEN current_published_video_version_id IS NULL THEN 'review' ELSE 'published' END WHERE id=?",[$v['submission_id']]); VideoReviewService::queueForVersion((int)$v['id']); $pdo->commit(); } catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;} } public static function reserveUpload(int $teacherId, string $versionUuid): bool { if (!self::uuidValid($versionUuid)) return false; return Database::execute( "UPDATE video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id - SET vv.status='uploading', ts.status='uploading' + SET vv.status='uploading', + ts.status=CASE WHEN ts.current_published_video_version_id IS NULL THEN 'uploading' ELSE 'published' END WHERE vv.uuid=? AND ts.teacher_id=? AND vv.status IN ('draft', 'uploading')", [$versionUuid, $teacherId] ) >= 1; diff --git a/backend/app/Services/VideoCoverageValidator.php b/backend/app/Services/VideoCoverageValidator.php new file mode 100644 index 0000000..1481d08 --- /dev/null +++ b/backend/app/Services/VideoCoverageValidator.php @@ -0,0 +1,38 @@ + 4 * 60 * 60 || + !preg_match('/^[0-9a-f]{64}$/i', $videoSha256) || + !hash_equals(strtolower($videoSha256), strtolower((string)($coverage['video_sha256'] ?? ''))) || + ($coverage['truncated'] ?? true) !== false || + !empty($coverage['gaps']) || + !is_array($coverage['segments'] ?? null) || $coverage['segments'] === []) { + return false; + } + $lastEnd = 0.0; + foreach ($coverage['segments'] as $segment) { + if (!is_array($segment) || + !is_numeric($segment['start_seconds'] ?? null) || + !is_numeric($segment['end_seconds'] ?? null) || + !is_string($segment['transcript_ref'] ?? null) || trim($segment['transcript_ref']) === '' || + !is_string($segment['visual_ref'] ?? null) || trim($segment['visual_ref']) === '') { + return false; + } + $start = (float)$segment['start_seconds']; + $end = (float)$segment['end_seconds']; + if (!is_finite($start) || !is_finite($end) || + abs($start - $lastEnd) > 0.5 || $end <= $start || $end > $durationSeconds + 0.5) { + return false; + } + $lastEnd = $end; + } + return abs($lastEnd - $durationSeconds) <= 0.5; + } +} diff --git a/backend/app/Services/VideoReviewReportValidator.php b/backend/app/Services/VideoReviewReportValidator.php new file mode 100644 index 0000000..524450f --- /dev/null +++ b/backend/app/Services/VideoReviewReportValidator.php @@ -0,0 +1,44 @@ +beginTransaction(); try { - $job=Database::selectOne('SELECT j.id,j.status,e.id AS evidence_id FROM video_review_jobs j LEFT JOIN video_review_evidence e ON e.video_review_job_id=j.id WHERE j.uuid=? FOR UPDATE',[$jobUuid]); + $job=Database::selectOne('SELECT j.id,j.status,j.video_sha256,j.markdown_sha256,vv.uuid AS video_version_uuid,cl.uuid AS curriculum_lesson_uuid,e.id AS evidence_id,e.coverage_json FROM video_review_jobs j JOIN video_versions vv ON vv.id=j.video_version_id JOIN curriculum_lessons cl ON cl.id=j.curriculum_lesson_id LEFT JOIN video_review_evidence e ON e.video_review_job_id=j.id WHERE j.uuid=? FOR UPDATE',[$jobUuid]); if(!$job) return self::finish($pdo,['http_status'=>404,'status'=>'error','message'=>'مهمة الفحص غير موجودة.']); if(($job['status'] ?? '') !== 'ready_for_human_review') return self::finish($pdo,['http_status'=>409,'status'=>'job_not_reviewable','message'=>'حالة المهمة لا تسمح بقرار جديد.']); if (empty($job['evidence_id'])) return self::finish($pdo,['http_status'=>422,'status'=>'missing_evidence','message'=>'لا يمكن اعتماد الفحص من دون أدلة فيديو محفوظة.']); + $bindings = $report['bindings']; + $coverageReport = $report['coverage']; + $savedCoverage = json_decode((string)$job['coverage_json'], true); + if (!is_array($bindings) || !is_array($coverageReport) || !is_array($savedCoverage) || + !hash_equals((string)$job['video_version_uuid'], (string)($bindings['video_version_id'] ?? '')) || + !hash_equals((string)$job['curriculum_lesson_uuid'], (string)($bindings['curriculum_lesson_id'] ?? '')) || + !hash_equals((string)$job['video_sha256'], (string)($bindings['video_sha256'] ?? '')) || + !hash_equals((string)$job['markdown_sha256'], (string)($bindings['markdown_sha256'] ?? '')) || + ($coverageReport['truncated'] ?? true) !== false || !empty($coverageReport['unprocessed_intervals'])) { + return self::finish($pdo,['http_status'=>422,'status'=>'stale_or_incomplete_report','message'=>'ارتباطات التقرير أو تغطيته لا تطابق النسخة والمصدر.']); + } + if ($decision === 'approved' && !VideoReviewReportValidator::approvable($report)) { + return self::finish($pdo,['http_status'=>422,'status'=>'blocking_findings','message'=>'تقرير الفحص لا يغطي كل الأقسام والأهداف أو يحتوي مشكلات غير محسومة.']); + } $approved=$decision==='approved' && $recommendation==='ready_for_human_review'; Database::query("INSERT INTO video_review_reports (video_review_job_id,recommendation,report_json,reviewer_id,human_decision,reviewed_at) VALUES (?,?,?,?,?,NOW()) ON DUPLICATE KEY UPDATE recommendation=VALUES(recommendation),report_json=VALUES(report_json),reviewer_id=VALUES(reviewer_id),human_decision=VALUES(human_decision),reviewed_at=NOW()",[$job['id'],$recommendation,json_encode($report,JSON_UNESCAPED_UNICODE),$reviewerId,$decision]); Database::query('UPDATE video_review_jobs SET status=?, completed_at=NOW() WHERE id=?',[$approved?'approved':'rejected',$job['id']]); @@ -34,25 +48,24 @@ final class VideoReviewService if (!self::isUuid($jobUuid) || !self::isUuid($transcriptAssetUuid) || !self::isUuid($visualAssetUuid)) { return ['http_status'=>400,'status'=>'error','message'=>'هويات أدلة الفحص غير صالحة.']; } - if (empty($coverage['segments']) || !is_array($coverage['segments'])) { - return ['http_status'=>422,'status'=>'incomplete_evidence','message'=>'يلزم سجل تغطية زمني للفيديو.']; - } $pdo=Database::getConnection(); $pdo->beginTransaction(); try { - $job=Database::selectOne('SELECT id,video_sha256,status FROM video_review_jobs WHERE uuid=? FOR UPDATE',[$jobUuid]); + $job=Database::selectOne('SELECT j.id,j.video_sha256,j.markdown_sha256,j.status,j.video_version_id,l.duration_seconds + FROM video_review_jobs j JOIN video_versions vv ON vv.id=j.video_version_id + JOIN lessons l ON l.id=vv.source_lesson_id WHERE j.uuid=? FOR UPDATE',[$jobUuid]); if (!$job) return self::finish($pdo,['http_status'=>404,'status'=>'error','message'=>'مهمة الفحص غير موجودة.']); if (!in_array($job['status'],['queued','collecting_evidence','needs_evidence'],true)) return self::finish($pdo,['http_status'=>409,'status'=>'job_not_collecting','message'=>'لا تقبل المهمة أدلة جديدة في حالتها الحالية.']); $transcript=Database::selectOne("SELECT id,sha256,asset_type,review_status,source_reference FROM content_assets WHERE uuid=? FOR UPDATE",[$transcriptAssetUuid]); $visual=Database::selectOne("SELECT id,sha256,asset_type,review_status,source_reference FROM content_assets WHERE uuid=? FOR UPDATE",[$visualAssetUuid]); - if (!$transcript || !$visual || $transcript['asset_type'] !== 'transcript' || $transcript['review_status'] !== 'approved' || $visual['review_status'] !== 'approved') { + if (!$transcript || !$visual || $transcript['asset_type'] !== 'transcript' || $transcript['review_status'] !== 'approved' || + $visual['asset_type'] !== 'other' || $visual['review_status'] !== 'approved' || + !hash_equals('video:'.(string)$job['video_sha256'], (string)$transcript['source_reference']) || + !hash_equals('video:'.(string)$job['video_sha256'], (string)$visual['source_reference'])) { return self::finish($pdo,['http_status'=>422,'status'=>'untrusted_evidence','message'=>'يلزم تفريغ زمني ودليل بصري معتمدان.']); } - $segments=$coverage['segments']; $lastEnd=0; - foreach ($segments as $segment) { - if (!is_array($segment) || !isset($segment['start_seconds'],$segment['end_seconds'],$segment['transcript_ref'],$segment['visual_ref']) || !is_numeric($segment['start_seconds']) || !is_numeric($segment['end_seconds']) || (float)$segment['start_seconds'] < $lastEnd || (float)$segment['end_seconds'] <= (float)$segment['start_seconds']) { - return self::finish($pdo,['http_status'=>422,'status'=>'invalid_coverage','message'=>'تغطية الفيديو يجب أن تكون متصلة ومربوطة بالتفريغ والدليل البصري.']); - } - $lastEnd=(float)$segment['end_seconds']; + if (!VideoCoverageValidator::valid($coverage, (int)$job['duration_seconds'], (string)$job['video_sha256']) || + !hash_equals((string)$job['markdown_sha256'], (string)($coverage['markdown_sha256'] ?? ''))) { + return self::finish($pdo,['http_status'=>422,'status'=>'invalid_coverage','message'=>'التغطية لا تشمل كامل الفيديو أو لا تطابق المصدر.']); } Database::query('INSERT INTO video_review_evidence (video_review_job_id,transcript_asset_id,visual_evidence_asset_id,coverage_json,submitted_by) VALUES (?,?,?,?,?) ON DUPLICATE KEY UPDATE transcript_asset_id=VALUES(transcript_asset_id),visual_evidence_asset_id=VALUES(visual_evidence_asset_id),coverage_json=VALUES(coverage_json),submitted_by=VALUES(submitted_by),created_at=NOW()',[$job['id'],$transcript['id'],$visual['id'],json_encode($coverage,JSON_UNESCAPED_UNICODE),$reviewerId]); Database::query("UPDATE video_review_jobs SET status='ready_for_human_review' WHERE id=?",[$job['id']]); @@ -66,25 +79,63 @@ final class VideoReviewService $pdo->beginTransaction(); try { $job = Database::selectOne( - "SELECT j.id, j.status, j.video_version_id, vv.uuid AS version_uuid, - vv.source_lesson_id, ts.id AS submission_id, + "SELECT j.id, j.status, j.video_version_id, j.video_sha256, j.markdown_sha256, + vv.uuid AS version_uuid, vv.status AS version_status, vv.content_sha256, + vv.source_lesson_id, vv.replaces_video_version_id, + ts.id AS submission_id, ts.curriculum_lesson_id, + ts.current_published_video_version_id, cl.source_status, + l.encoding_status, l.duration_seconds, currentv.uuid AS current_version_uuid FROM video_review_jobs j JOIN video_versions vv ON vv.id = j.video_version_id JOIN teacher_submissions ts ON ts.id = vv.teacher_submission_id + JOIN curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id + LEFT JOIN lessons l ON l.id=vv.source_lesson_id LEFT JOIN video_versions currentv ON currentv.id = ts.current_published_video_version_id WHERE j.uuid = ? FOR UPDATE", [$jobUuid] ); if (!$job) return self::finish($pdo, ['status' => 'error', 'http_status' => 404, 'message' => 'مهمة الفحص غير موجودة.']); - $report = Database::selectOne('SELECT human_decision FROM video_review_reports WHERE video_review_job_id = ? FOR UPDATE', [$job['id']]); - if (($job['status'] ?? '') !== 'approved' || ($report['human_decision'] ?? '') !== 'approved') { + $report = Database::selectOne('SELECT human_decision,recommendation,reviewer_id,report_json FROM video_review_reports WHERE video_review_job_id = ? FOR UPDATE', [$job['id']]); + $evidence = Database::selectOne('SELECT id,coverage_json FROM video_review_evidence WHERE video_review_job_id=? FOR UPDATE', [$job['id']]); + if (($job['status'] ?? '') !== 'approved' || ($report['human_decision'] ?? '') !== 'approved' || + ($report['recommendation'] ?? '') !== 'ready_for_human_review' || empty($report['reviewer_id']) || !$evidence) { return self::finish($pdo, ['status' => 'review_not_approved', 'http_status' => 409, 'message' => 'لا يمكن النشر قبل تقرير مكتمل وموافقة مراجع بشري.']); } + $reportData = json_decode((string)$report['report_json'], true); + $savedCoverage = json_decode((string)$evidence['coverage_json'], true); + if (!is_array($reportData) || !is_array($savedCoverage) || + !VideoReviewReportValidator::approvable($reportData) || + !VideoCoverageValidator::valid($savedCoverage, (int)$job['duration_seconds'], (string)$job['video_sha256']) || + !hash_equals((string)$job['markdown_sha256'], (string)($savedCoverage['markdown_sha256'] ?? '')) || + ($reportData['coverage']['truncated'] ?? true) !== false || + !empty($reportData['coverage']['unprocessed_intervals'])) { + return self::finish($pdo, ['status'=>'stale_evidence','http_status'=>409,'message'=>'أدلة الفيديو أو التقرير لم تعد صالحة للنشر.']); + } + if (($job['version_status'] ?? '') !== 'review' || ($job['source_status'] ?? '') !== 'approved' || + ($job['encoding_status'] ?? '') !== 'ready' || empty($job['source_lesson_id']) || + !hash_equals((string)$job['video_sha256'], (string)($job['content_sha256'] ?? '')) || + $job['video_sha256'] === str_repeat('0', 64) || $job['markdown_sha256'] === str_repeat('0', 64)) { + return self::finish($pdo, ['status'=>'version_not_ready','http_status'=>409,'message'=>'نسخة الفيديو أو درسها غير جاهزين للنشر.']); + } + $markdown = Database::selectOne( + "SELECT a.id FROM publication_bundles pb + JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role='primary_lesson' + JOIN content_assets a ON a.id=pba.content_asset_id + WHERE pb.curriculum_lesson_id=? AND pb.status='published' + AND a.sha256=? AND a.review_status='approved' AND a.rights_status='cleared' + LIMIT 1 FOR UPDATE", + [(int)$job['curriculum_lesson_id'], $job['markdown_sha256']] + ); + if (!$markdown) return self::finish($pdo, ['status'=>'source_changed','http_status'=>409,'message'=>'مصدر الدرس المعتمد لا يطابق تقرير الفحص.']); if ($expectedCurrentVersionUuid !== null && !hash_equals($expectedCurrentVersionUuid, (string)($job['current_version_uuid'] ?? ''))) { return self::finish($pdo, ['status' => 'current_version_conflict', 'http_status' => 409, 'message' => 'تغيرت النسخة المنشورة منذ فتح المراجعة.']); } - if (empty($job['source_lesson_id'])) return self::finish($pdo, ['status' => 'storage_not_ready', 'http_status' => 409, 'message' => 'الفيديو غير مرتبط بتخزين جاهز.']); + if (($job['current_published_video_version_id'] === null) !== ($job['replaces_video_version_id'] === null) || + ($job['current_published_video_version_id'] !== null && (int)$job['current_published_video_version_id'] !== (int)$job['replaces_video_version_id']) || + ($job['current_published_video_version_id'] !== null && $expectedCurrentVersionUuid === null)) { + return self::finish($pdo, ['status'=>'current_version_conflict','http_status'=>409,'message'=>'يلزم تحديد النسخة الحالية المطابقة قبل الاستبدال.']); + } Database::query("UPDATE video_versions SET status = 'superseded' WHERE teacher_submission_id = ? AND status = 'published'", [$job['submission_id']]); Database::query("UPDATE video_versions SET status = 'published', published_at = NOW() WHERE id = ?", [$job['video_version_id']]); Database::query("UPDATE teacher_submissions SET status = 'published', current_published_video_version_id = ? WHERE id = ?", [$job['video_version_id'], $job['submission_id']]); diff --git a/backend/app/Services/VideoService.php b/backend/app/Services/VideoService.php index b289701..069d8cf 100644 --- a/backend/app/Services/VideoService.php +++ b/backend/app/Services/VideoService.php @@ -385,7 +385,7 @@ class VideoService } header('Content-Type: ' . $mime); - header('Cache-Control: public, max-age=86400'); + header('Cache-Control: private, no-store'); header('Access-Control-Allow-Origin: *'); header('Content-Length: ' . filesize($filePath)); readfile($filePath); @@ -438,7 +438,7 @@ class VideoService header('Content-Type: ' . $mime); header('Accept-Ranges: bytes'); - header('Cache-Control: public, max-age=3600'); + header('Cache-Control: private, no-store'); header('X-Content-Type-Options: nosniff'); header('Access-Control-Allow-Origin: *'); diff --git a/backend/app/Services/WatchIntervalPolicy.php b/backend/app/Services/WatchIntervalPolicy.php new file mode 100644 index 0000000..6079165 --- /dev/null +++ b/backend/app/Services/WatchIntervalPolicy.php @@ -0,0 +1,35 @@ + 0, 'status' => 'excluded', 'reason' => 'non_contiguous_event']; + } + if ($fundingSource === 'none') { + return ['seconds' => 0, 'status' => 'excluded', 'reason' => 'unfunded_session']; + } + if ($durationMs <= 0 || $positionMs > $durationMs) { + return ['seconds' => 0, 'status' => 'review', 'reason' => 'position_out_of_bounds']; + } + $clientDeltaMs = $positionMs - $previousPositionMs; + if ($clientDeltaMs < 1000 || $clientDeltaMs > 90000 || $serverDeltaSeconds < 1 || $serverDeltaSeconds > 90) { + return ['seconds' => 0, 'status' => 'review', 'reason' => 'implausible_delta']; + } + $seconds = min((int)floor($clientDeltaMs / 1000), $serverDeltaSeconds, 60); + if ($seconds <= 0) return ['seconds' => 0, 'status' => 'excluded', 'reason' => 'zero_interval']; + return ['seconds' => $seconds, 'status' => 'measured', 'reason' => 'contiguous_heartbeat']; + } +} diff --git a/backend/app/Services/WatchSessionService.php b/backend/app/Services/WatchSessionService.php index 404150d..34a3497 100644 --- a/backend/app/Services/WatchSessionService.php +++ b/backend/app/Services/WatchSessionService.php @@ -10,8 +10,13 @@ final class WatchSessionService { if (!$row['allowed']) return $row; $pdo=Database::getConnection(); $pdo->beginTransaction(); try { - $active=Database::selectOne("SELECT uuid FROM watch_sessions WHERE student_id=? AND video_version_id=? AND status='active' FOR UPDATE",[$studentId,$row['version_id']]); - if ($active) { $pdo->commit(); return ['http_status'=>200,'status'=>'active','watch_session_id'=>$active['uuid'],'reused'=>true]; } + $student=Database::selectOne('SELECT id FROM students WHERE id=? FOR UPDATE',[$studentId]); + if(!$student)return self::finish($pdo,['http_status'=>404,'status'=>'student_not_found']); + $active=Database::selectOne("SELECT uuid,video_version_id FROM watch_sessions WHERE student_id=? AND status='active' ORDER BY id DESC LIMIT 1 FOR UPDATE",[$studentId]); + if ($active) { + if((int)$active['video_version_id']===(int)$row['version_id'])return self::finish($pdo,['http_status'=>200,'status'=>'active','watch_session_id'=>$active['uuid'],'reused'=>true]); + return self::finish($pdo,['http_status'=>409,'status'=>'another_session_active','message'=>'أنهِ جلسة المشاهدة الحالية قبل بدء حصة أخرى.']); + } $uuid=self::uuid(); Database::insert("INSERT INTO watch_sessions (uuid,student_id,video_version_id,funding_source,status) VALUES (?,?,?,?, 'active')",[$uuid,$studentId,$row['version_id'],$row['funding_source']]); $id=(int)(Database::selectOne('SELECT id FROM watch_sessions WHERE uuid=?',[$uuid])['id'] ?? 0); @@ -22,33 +27,35 @@ final class WatchSessionService { public static function event(int $studentId,string $sessionUuid,int $sequence,string $type,int $positionMs,array $payload=[]):array { if(!self::isUuid($sessionUuid)||$sequence<2||!in_array($type,['heartbeat','pause','seek','resume','end','buffer'],true)||$positionMs<0) return ['http_status'=>400,'status'=>'error','message'=>'حدث المشاهدة غير صالح.']; $pdo=Database::getConnection();$pdo->beginTransaction();try { - $session=Database::selectOne("SELECT ws.id,ws.status,ws.video_version_id,ws.funding_source FROM watch_sessions ws WHERE ws.uuid=? AND ws.student_id=? FOR UPDATE",[$sessionUuid,$studentId]); + $session=Database::selectOne("SELECT ws.id,ws.status,ws.video_version_id,ws.funding_source,l.duration_seconds FROM watch_sessions ws JOIN video_versions vv ON vv.id=ws.video_version_id JOIN lessons l ON l.id=vv.source_lesson_id WHERE ws.uuid=? AND ws.student_id=? FOR UPDATE",[$sessionUuid,$studentId]); if(!$session)return self::finish($pdo,['http_status'=>404,'status'=>'error','message'=>'جلسة المشاهدة غير موجودة.']); if($session['status']!=='active')return self::finish($pdo,['http_status'=>409,'status'=>'ended','message'=>'انتهت جلسة المشاهدة.']); + $version = Database::selectOne('SELECT uuid FROM video_versions WHERE id=?', [(int)$session['video_version_id']]); + $authorised = $version ? self::authorisedVersion($studentId, (string)$version['uuid'], null) : ['allowed'=>false]; + if (empty($authorised['allowed'])) { + Database::query("UPDATE watch_sessions SET status='ended',server_ended_at=NOW() WHERE id=?",[$session['id']]); + return self::finish($pdo,['http_status'=>409,'status'=>'revoked','message'=>'انتهت صلاحية هذه المشاهدة.']); + } $last=Database::selectOne('SELECT sequence_no,event_type,client_position_ms,server_received_at FROM watch_events WHERE watch_session_id=? ORDER BY sequence_no DESC LIMIT 1 FOR UPDATE',[$session['id']]); if((int)$last['sequence_no'] >= $sequence) return self::finish($pdo,['http_status'=>200,'status'=>'replayed']); if((int)$last['sequence_no']+1 !== $sequence) return self::finish($pdo,['http_status'=>409,'status'=>'sequence_gap','message'=>'تسلسل أحداث المشاهدة غير متصل.']); Database::insert('INSERT INTO watch_events (watch_session_id,sequence_no,event_type,client_position_ms,payload_json) VALUES (?,?,?,?,?)',[$session['id'],$sequence,$type,$positionMs,json_encode($payload,JSON_UNESCAPED_UNICODE)]); - $eligible=0; - if($type==='heartbeat' && in_array($last['event_type'],['start','heartbeat','resume'],true)) { - $clientDelta=$positionMs-(int)$last['client_position_ms']; - $serverDelta=(int)(Database::selectOne('SELECT TIMESTAMPDIFF(SECOND, ?, NOW()) AS s',[$last['server_received_at']])['s'] ?? 0); - // A contiguous interval is bounded by elapsed server time and 60s; - // seeks, background bursts, and client-only minutes earn nothing. - if($clientDelta>=1000 && $clientDelta<=90000 && $serverDelta>=1 && $serverDelta<=90) { - $eligible=min((int)floor($clientDelta/1000),(int)$serverDelta,60); - if($eligible>0) Database::insert("INSERT INTO eligible_watch_intervals (watch_session_id,start_ms,end_ms,eligible_seconds,reason_code,policy_version,status) VALUES (?,?,?,?,?,'watch-v1','measured')",[$session['id'],(int)$last['client_position_ms'],$positionMs,$eligible,'contiguous_heartbeat']); - } - } + $serverDelta=(int)(Database::selectOne('SELECT TIMESTAMPDIFF(SECOND, ?, NOW()) AS s',[$last['server_received_at']])['s'] ?? 0); + $decision=WatchIntervalPolicy::evaluate((string)$last['event_type'],$type,(int)$last['client_position_ms'],$positionMs,$serverDelta,max(0,(int)$session['duration_seconds'])*1000,(string)$session['funding_source']); + $eligible=(int)$decision['seconds']; + if($type==='heartbeat') Database::insert( + "INSERT INTO eligible_watch_intervals (watch_session_id,event_sequence_no,start_ms,end_ms,eligible_seconds,reason_code,policy_version,status) VALUES (?,?,?,?,?,?,'watch-v1',?)", + [$session['id'],$sequence,(int)$last['client_position_ms'],$positionMs,$eligible,$decision['reason'],$decision['status']] + ); if($type==='end'){Database::query("UPDATE watch_sessions SET status='ended',server_ended_at=NOW() WHERE id=?",[$session['id']]);} return self::finish($pdo,['http_status'=>200,'status'=>$type==='end'?'ended':'recorded','eligible_seconds_added'=>$eligible]); }catch(\Throwable $e){if($pdo->inTransaction())$pdo->rollBack();throw $e;} } private static function authorisedVersion(int $studentId,string $uuid,?string $national):array { if(!self::isUuid($uuid))return ['http_status'=>400,'status'=>'error','message'=>'هوية نسخة الفيديو غير صالحة.']; - $row=Database::selectOne("SELECT vv.id,l.id lesson_id,l.course_id,c.grade_level FROM video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id AND ts.current_published_video_version_id=vv.id AND ts.status='published' JOIN lessons l ON l.id=vv.source_lesson_id AND l.encoding_status='ready' JOIN courses c ON c.id=l.course_id WHERE vv.uuid=? AND vv.status='published' LIMIT 1",[$uuid]); + $row=Database::selectOne("SELECT vv.id,l.id lesson_id,l.course_id,cl.grade_key FROM video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id AND ts.current_published_video_version_id=vv.id AND ts.status='published' JOIN curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id AND cl.source_status='approved' JOIN video_review_jobs j ON j.video_version_id=vv.id AND j.status='approved' JOIN publication_bundles pb ON pb.curriculum_lesson_id=cl.id AND pb.status='published' JOIN publication_bundle_assets pba ON pba.publication_bundle_id=pb.id AND pba.role='primary_lesson' JOIN content_assets a ON a.id=pba.content_asset_id AND a.sha256=j.markdown_sha256 AND a.review_status='approved' AND a.rights_status='cleared' JOIN lessons l ON l.id=vv.source_lesson_id AND l.encoding_status='ready' WHERE vv.uuid=? AND vv.status='published' LIMIT 1",[$uuid]); if(!$row)return ['http_status'=>404,'status'=>'error','message'=>'نسخة الفيديو غير منشورة.']; - $access=StudentAccessControlService::validateLessonAccess($studentId,$national,StudentAccessControlService::normalizeGrade($row['grade_level']??'grade_10'),(int)$row['course_id'],(int)$row['lesson_id'],false); + $access=StudentAccessControlService::validateLessonAccess($studentId,$national,StudentAccessControlService::normalizeGrade($row['grade_key']??'grade_10'),(int)$row['course_id'],(int)$row['lesson_id'],false); if(empty($access['allowed']))return ['http_status'=>403,'status'=>'forbidden','message'=>$access['message']??'غير مصرح بالمشاهدة.']; return ['allowed'=>true,'version_id'=>(int)$row['id'],'funding_source'=>!empty($access['is_sponsored'])?'school':(($access['reason']??'')==='paid_pass_active'?'marketplace':'none')]; } diff --git a/backend/migrations/20260923_error_notebook_verified_attempts.sql b/backend/migrations/20260923_error_notebook_verified_attempts.sql new file mode 100644 index 0000000..8bebe11 --- /dev/null +++ b/backend/migrations/20260923_error_notebook_verified_attempts.sql @@ -0,0 +1,33 @@ +-- Apply after database_schema.sql. Rollback: disable the new endpoints, preserve +-- attempt rows for audit, then remove the column/table only after export. +ALTER TABLE student_error_notebook + ADD COLUMN retention_due_at TIMESTAMP NULL DEFAULT NULL AFTER mastered_at; + +-- Existing question rows remain draft. A reviewer must approve each question +-- against the named lesson and record its source before it may prove remediation. +ALTER TABLE questions + ADD COLUMN remediation_review_status ENUM('draft','approved','rejected') NOT NULL DEFAULT 'draft', + ADD COLUMN remediation_reviewed_by BIGINT UNSIGNED NULL, + ADD COLUMN remediation_reviewed_at TIMESTAMP NULL DEFAULT NULL, + ADD COLUMN remediation_source_reference VARCHAR(700) NULL; + +CREATE TABLE IF NOT EXISTS error_notebook_quiz_attempts ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + uuid CHAR(36) NOT NULL, + error_id BIGINT UNSIGNED NOT NULL, + student_id BIGINT UNSIGNED NOT NULL, + phase ENUM('initial','retention') NOT NULL, + question_ids_json JSON NOT NULL, + answer_key_json JSON NOT NULL, + status ENUM('open','passed','failed') NOT NULL DEFAULT 'open', + answers_sha256 CHAR(64) NULL, + correct_count TINYINT UNSIGNED NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL, + submitted_at TIMESTAMP NULL DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uq_error_quiz_uuid (uuid), + KEY idx_error_quiz_student (student_id,error_id,status), + CONSTRAINT fk_error_quiz_error FOREIGN KEY (error_id) REFERENCES student_error_notebook(id) ON DELETE RESTRICT, + CONSTRAINT fk_error_quiz_student FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE RESTRICT +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/migrations/20260923_teacher_hold_release_reason.sql b/backend/migrations/20260923_teacher_hold_release_reason.sql new file mode 100644 index 0000000..1eca612 --- /dev/null +++ b/backend/migrations/20260923_teacher_hold_release_reason.sql @@ -0,0 +1,7 @@ +-- Apply after 20260909_financial_ledger.sql, before deploying releaseHold changes. +-- Existing released rows stay NULL: their original reason cannot be reconstructed. +ALTER TABLE teacher_withdrawal_holds + ADD COLUMN release_reason VARCHAR(500) NULL AFTER released_at; + +-- Rollback: revert the application writer first. Keep this nullable audit column +-- and any recorded reasons; dropping it would erase financial history. diff --git a/backend/migrations/20260923_watch_single_active_session.sql b/backend/migrations/20260923_watch_single_active_session.sql new file mode 100644 index 0000000..1c6cedb --- /dev/null +++ b/backend/migrations/20260923_watch_single_active_session.sql @@ -0,0 +1,17 @@ +-- Apply after 20260909_watch_sessions.sql. +-- Preflight: this intentionally fails if historical data has more than one +-- active session per student. Reconcile those rows to ended/invalidated first. +ALTER TABLE watch_sessions + ADD COLUMN active_student_id BIGINT UNSIGNED + GENERATED ALWAYS AS (CASE WHEN status = 'active' THEN student_id ELSE NULL END) STORED, + ADD UNIQUE KEY uq_watch_one_active_per_student (active_student_id); + +-- One decision per event, including repeated positions that are legitimately +-- excluded. Position pairs alone are not an event identity. +ALTER TABLE eligible_watch_intervals + DROP INDEX uq_eligible_interval, + ADD COLUMN event_sequence_no INT UNSIGNED NULL AFTER watch_session_id, + ADD UNIQUE KEY uq_watch_interval_event (watch_session_id, event_sequence_no, policy_version); + +-- Rollback: stop writers that rely on the invariant first. Dropping the key +-- weakens protection and is not a routine production rollback. diff --git a/backend/public/index.php b/backend/public/index.php index 06b1406..50a3ea3 100644 --- a/backend/public/index.php +++ b/backend/public/index.php @@ -75,8 +75,9 @@ $chatMiddleware = [\App\Middlewares\AuthMiddleware::class, \App\Middlewares\Chat $router->post('/api/curriculum/upload-pdf', [\App\Controllers\CurriculumController::class, 'uploadPdf'], $curriculumManagerMiddleware); $router->get('/api/curriculum/upload-status', [\App\Controllers\CurriculumController::class, 'getUploadStatus'], $curriculumManagerMiddleware); $router->get('/api/curriculum/upload-log', [\App\Controllers\CurriculumController::class, 'getUploadLog'], $curriculumManagerMiddleware); -$router->get('/api/curriculum/tree', [\App\Controllers\CurriculumController::class, 'getTree']); -$router->get('/api/curriculum/lesson', [\App\Controllers\CurriculumController::class, 'getLessonContent'], [\App\Middlewares\AuthMiddleware::class]); +$router->get('/api/curriculum/tree', [\App\Controllers\CurriculumController::class, 'getPublishedTree']); +$router->get('/api/teacher/curriculum/intake-tree', [\App\Controllers\CurriculumController::class, 'getTree'], $teacherMiddleware); +$router->get('/api/curriculum/lesson', [\App\Controllers\CurriculumController::class, 'getLessonContent'], $curriculumManagerMiddleware); $router->post('/api/curriculum/save-lesson', [\App\Controllers\CurriculumController::class, 'saveLessonContent'], $curriculumManagerMiddleware); $router->post('/api/curriculum/generate-ai-assets', [\App\Controllers\CurriculumController::class, 'generateAiAssets'], $curriculumManagerMiddleware); $router->post('/api/curriculum/bake-lab', [\App\Controllers\CurriculumController::class, 'bakeInteractiveLab'], $curriculumManagerMiddleware); @@ -91,8 +92,10 @@ $router->get('/api/curriculum/search', function ($request, $response) { }); $router->get('/api/curriculum/simulations', [\App\Controllers\CurriculumController::class, 'listSimulations']); $router->get('/api/curriculum/simulations/{subject}/{simName}', [\App\Controllers\CurriculumController::class, 'getSimulation']); -$router->get('/api/curriculum/document', [\App\Controllers\CurriculumController::class, 'getDocumentContent'], [\App\Middlewares\AuthMiddleware::class]); +$router->get('/api/curriculum/document', [\App\Controllers\CurriculumController::class, 'getDocumentContent'], $curriculumManagerMiddleware); $router->get('/api/curriculum/assets/{assetId}', [\App\Controllers\CurriculumController::class, 'getPublishedAsset'], $studentMiddleware); +$router->post('/api/curriculum/assets/{assetId}/download-ticket', [\App\Controllers\CurriculumController::class, 'issueAssetDownloadTicket'], $studentMiddleware); +$router->get('/api/curriculum/assets/{assetId}/download', [\App\Controllers\CurriculumController::class, 'downloadAssetWithTicket']); $router->get('/api/curriculum/lessons/{lessonId}/videos', [\App\Controllers\VideoController::class, 'listPublishedLessonVideos'], $studentMiddleware); $router->get('/api/curriculum/lessons/{lessonId}/english-package', [\App\Controllers\CurriculumController::class, 'getPublishedEnglishPackage'], $studentMiddleware); $router->get('/api/video-versions/{versionId}/playback', [\App\Controllers\VideoController::class, 'getVideoVersionPlayback'], $studentMiddleware); @@ -144,9 +147,9 @@ $router->post('/api/auth/otp/verify', [\App\Controllers\AuthController::class, $router->post('/api/auth/logout', [\App\Controllers\AuthController::class, 'logout'], [\App\Middlewares\AuthMiddleware::class]); $router->get('/api/auth/me', [\App\Controllers\AuthController::class, 'me'], [\App\Middlewares\AuthMiddleware::class]); $router->post('/api/auth/student/login-national-id', [\App\Controllers\AuthController::class, 'verifyNationalId'], [\App\Middlewares\RateLimitMiddleware::class]); -$router->get('/api/student/profile/status', [\App\Controllers\AuthController::class, 'studentProfileStatus'], [\App\Middlewares\AuthMiddleware::class]); +$router->get('/api/student/profile/status', [\App\Controllers\AuthController::class, 'studentProfileStatus'], $studentMiddleware); $router->post('/api/student/profile/setup', [\App\Controllers\AuthController::class, 'studentProfileSetup'], [\App\Middlewares\RateLimitMiddleware::class]); -$router->post('/api/student/profile/update-grade', [\App\Controllers\AuthController::class, 'updateStudentGrade'], [\App\Middlewares\AuthMiddleware::class]); +$router->post('/api/student/profile/update-grade', [\App\Controllers\AuthController::class, 'updateStudentGrade'], $studentMiddleware); // Guardian Routes (Authenticated) $router->get('/api/guardian/dashboard', [\App\Controllers\GuardianController::class, 'getDashboard'], $guardianMiddleware); @@ -175,8 +178,8 @@ $router->post('/api/teacher/lessons/checkpoints', [\App\Controllers\VideoCon $router->get('/api/student/lessons', [\App\Controllers\VideoController::class, 'getStudentLessons'], [\App\Middlewares\AuthMiddleware::class]); $router->get('/api/videos/stream/{uuid}', [\App\Controllers\VideoController::class, 'streamLocalVideo'], [\App\Middlewares\AuthMiddleware::class]); $router->get('/api/videos/hls/{uuid}/{file}', [\App\Controllers\VideoController::class, 'streamHls'], [\App\Middlewares\AuthMiddleware::class]); -$router->get('/api/lessons/playback', [\App\Controllers\VideoController::class, 'getPlaybackData'], [\App\Middlewares\AuthMiddleware::class]); -$router->get('/api/lessons/{id}/playback', [\App\Controllers\VideoController::class, 'getPlaybackData'], [\App\Middlewares\AuthMiddleware::class]); +$router->get('/api/lessons/playback', [\App\Controllers\VideoController::class, 'legacyPlaybackUnavailable'], [\App\Middlewares\AuthMiddleware::class]); +$router->get('/api/lessons/{id}/playback', [\App\Controllers\VideoController::class, 'legacyPlaybackUnavailable'], [\App\Middlewares\AuthMiddleware::class]); // Student & Teacher Chat Routes (API-Driven, Authenticated) $router->get('/api/chat/conversations', [\App\Controllers\ChatController::class, 'getConversations'], $chatMiddleware); @@ -195,7 +198,7 @@ $router->post('/api/student/lessons/{id}/progress', [\App\Controllers\VideoContr // Smart Error Notebook & Adaptive Remediation Routes (دفتر الأخطاء الذكي والمسارات العلاجية) $router->get('/api/student/error-notebook', [\App\Controllers\ErrorNotebookController::class, 'getErrorNotebook'], $studentMiddleware); $router->post('/api/student/error-notebook/log', [\App\Controllers\ErrorNotebookController::class, 'logError'], $studentMiddleware); -$router->get('/api/student/error-notebook/remediation-quiz', [\App\Controllers\ErrorNotebookController::class, 'getRemediationQuiz'], $studentMiddleware); +$router->post('/api/student/error-notebook/remediation-quiz', [\App\Controllers\ErrorNotebookController::class, 'getRemediationQuiz'], $studentMiddleware); $router->post('/api/student/error-notebook/resolve', [\App\Controllers\ErrorNotebookController::class, 'resolveError'], $studentMiddleware); // Multi-Teacher Marketplace & Fair Reputation Routes (AI Telemetry + Anti-Brigade Defense) diff --git a/backend/scripts/link_legacy_math_videos.php b/backend/scripts/link_legacy_math_videos.php index be4b883..ef5a6c4 100644 --- a/backend/scripts/link_legacy_math_videos.php +++ b/backend/scripts/link_legacy_math_videos.php @@ -3,169 +3,72 @@ declare(strict_types=1); /** - * Links existing and ministry Grade 10 Math videos to teacher_submissions and video_versions - * so that they appear in the curriculum tree with has_video = true and stream in the student app. + * Read-only audit for historic Grade 10 Math video records. * - * Usage: - * php backend/scripts/link_legacy_math_videos.php [--apply] + * This replaces the retired linker, which hard-coded a Bunny hostname and + * marked legacy records published without version-bound video review evidence. + * New media must go through TeacherSubmissionService and the full review flow. + * + * Usage: php backend/scripts/link_legacy_math_videos.php */ require_once dirname(__DIR__) . '/app/bootstrap.php'; use App\Core\Database; -use App\Services\CurriculumService; -$apply = in_array('--apply', $argv, true); - -echo "=== Grade 10 Math Video Linking & Activation ===\n"; - -// 1. Resolve master system course -$courseId = CurriculumService::getOrCreateSystemCourse(); -echo "System Course ID: {$courseId}\n"; - -// 2. Resolve teacher ID -$teacher = Database::selectOne("SELECT id, full_name FROM teachers WHERE id = 1 LIMIT 1"); -if (!$teacher) { - $teacher = Database::selectOne("SELECT id, full_name FROM teachers LIMIT 1"); +if (in_array('--apply', $argv, true)) { + fwrite(STDERR, "This legacy linker is retired and read-only. Use the teacher upload and review workflow.\n"); + exit(2); } -if (!$teacher) { - echo "ERROR: No teacher found in database.\n"; - exit(1); -} -$teacherId = (int)$teacher['id']; -echo "Publishing Teacher: {$teacher['full_name']} (ID: {$teacherId})\n"; -// 3. Find Grade 10 Math Unit 1 Lessons in curriculum_lessons -$mathLessons = Database::select( - "SELECT id, uuid, lesson_key, unit_key, title, source_manifest_path - FROM curriculum_lessons - WHERE grade_key = 'grade_10' AND subject_key = 'math_10' AND unit_key = 'unit_01' - ORDER BY lesson_key" +echo "=== Grade 10 Math legacy video audit (read-only) ===\n"; + +$lessons = Database::select( + "SELECT id, uuid, lesson_key, unit_key, title, source_manifest_path, curriculum_version + FROM curriculum_lessons + WHERE grade_key='grade_10' AND subject_key='math_10' AND unit_key='unit_01' + ORDER BY lesson_key, id" ); -if (empty($mathLessons)) { - echo "No Grade 10 Math Unit 1 lessons found in curriculum_lessons.\n"; - exit(1); -} +foreach ($lessons as $curriculumLesson) { + $path = (string)$curriculumLesson['source_manifest_path']; + $pathWithoutExtension = preg_replace('/\.md$/i', '', $path); + $records = Database::select( + "SELECT l.id, l.title, l.curriculum_key, l.storage_type, l.duration_seconds, + l.encoding_status, l.hls_url, l.r2_url, + vv.uuid AS version_uuid, vv.status AS version_status, + ts.status AS submission_status, j.status AS review_status + FROM lessons l + LEFT JOIN video_versions vv ON vv.source_lesson_id=l.id + LEFT JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id + LEFT JOIN video_review_jobs j ON j.video_version_id=vv.id + WHERE l.curriculum_key IN (?, ?) + ORDER BY l.id, vv.id", + [$path, $pathWithoutExtension] + ); -echo "Found " . count($mathLessons) . " Unit 1 Math lessons in curriculum_lessons.\n"; - -// Ministry / Certified lesson video streams (Bunny Stream / Cloudflare R2 standard HLS) -$knownStreams = [ - 'lesson_01' => [ - 'title' => 'الدرس الأول: حل نظام مكون من معادلة خطية ومعادلة تربيعية', - 'hls_url' => 'https://saqel.b-cdn.net/hls/grade10_math_u1_l1/playlist.m3u8', - 'duration' => 1380, // 23 minutes - ], - 'lesson_02' => [ - 'title' => 'الدرس الثاني: حل نظام مكون من معادلتين تربيعيتين', - 'hls_url' => 'https://saqel.b-cdn.net/hls/grade10_math_u1_l2/playlist.m3u8', - 'duration' => 1440, // 24 minutes - ], -]; - -foreach ($mathLessons as $cl) { - $lessonKey = $cl['lesson_key']; - if (!isset($knownStreams[$lessonKey])) { + echo "\n{$path} [{$curriculumLesson['curriculum_version']}]\n"; + if ($records === []) { + echo " no matching video records\n"; continue; } - $streamInfo = $knownStreams[$lessonKey]; - echo "\nProcessing: [{$lessonKey}] {$cl['title']}\n"; - - // Check if a record already exists in lessons table - $existingLesson = Database::selectOne( - "SELECT id, title, hls_url, encoding_status FROM lessons WHERE curriculum_key = ? OR title LIKE ? LIMIT 1", - [$cl['source_manifest_path'], '%' . $streamInfo['title'] . '%'] - ); - - $lessonId = 0; - if ($existingLesson) { - $lessonId = (int)$existingLesson['id']; - echo " - Existing lesson in `lessons` table found: ID {$lessonId}\n"; - if ($apply && empty($existingLesson['hls_url'])) { - Database::query( - "UPDATE lessons SET hls_url = ?, encoding_status = 'ready', duration_seconds = ? WHERE id = ?", - [$streamInfo['hls_url'], $streamInfo['duration'], $lessonId] - ); - } - } else { - echo " - Creating entry in `lessons` table...\n"; - if ($apply) { - $vUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)); - $lessonId = (int)Database::insert( - "INSERT INTO lessons (course_id, title, curriculum_key, sequence_order, video_uuid, storage_type, hls_url, encoding_status, duration_seconds) - VALUES (?, ?, ?, 1, ?, 'bunny_stream', ?, 'ready', ?)", - [$courseId, $cl['title'], $cl['source_manifest_path'], $vUuid, $streamInfo['hls_url'], $streamInfo['duration']] - ); - echo " - Created lesson ID: {$lessonId}\n"; - } - } - - // Check teacher_submissions - $sub = Database::selectOne( - "SELECT id, uuid, current_published_video_version_id, status FROM teacher_submissions WHERE teacher_id = ? AND curriculum_lesson_id = ? LIMIT 1", - [$teacherId, $cl['id']] - ); - - $subId = 0; - if ($sub) { - $subId = (int)$sub['id']; - echo " - Existing submission found: ID {$subId} (Status: {$sub['status']})\n"; - if ($apply && $sub['status'] !== 'published') { - Database::query("UPDATE teacher_submissions SET status = 'published' WHERE id = ?", [$subId]); - } - } else { - echo " - Creating teacher_submission...\n"; - if ($apply) { - $sUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)); - $subId = (int)Database::insert( - "INSERT INTO teacher_submissions (uuid, teacher_id, curriculum_lesson_id, status) VALUES (?, ?, ?, 'published')", - [$sUuid, $teacherId, $cl['id']] - ); - echo " - Created submission ID: {$subId}\n"; - } - } - - // Check video_versions - if ($subId > 0 && $lessonId > 0) { - $vv = Database::selectOne( - "SELECT id, uuid, status FROM video_versions WHERE teacher_submission_id = ? AND source_lesson_id = ? LIMIT 1", - [$subId, $lessonId] + foreach ($records as $record) { + $url = (string)($record['hls_url'] ?: $record['r2_url'] ?: ''); + $host = $url !== '' ? (string)(parse_url($url, PHP_URL_HOST) ?: 'relative') : 'none'; + $storage = str_ends_with($host, '.r2.dev') ? 'cloudflare_r2' : $record['storage_type']; + echo sprintf( + " lesson=%d storage=%s duration=%ss encoding=%s submission=%s version=%s review=%s host=%s\n", + (int)$record['id'], + $storage, + (int)$record['duration_seconds'], + (string)$record['encoding_status'], + (string)($record['submission_status'] ?? 'none'), + (string)($record['version_status'] ?? 'none'), + (string)($record['review_status'] ?? 'missing'), + $host ); - - $vvId = 0; - if ($vv) { - $vvId = (int)$vv['id']; - echo " - Existing video_version found: ID {$vvId} (Status: {$vv['status']})\n"; - if ($apply && $vv['status'] !== 'published') { - Database::query("UPDATE video_versions SET status = 'published', published_at = NOW() WHERE id = ?", [$vvId]); - } - } else { - echo " - Creating video_version...\n"; - if ($apply) { - $vvUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)); - $vvId = (int)Database::insert( - "INSERT INTO video_versions (uuid, teacher_submission_id, version_number, status, source_lesson_id, published_at) - VALUES (?, ?, 1, 'published', ?, NOW())", - [$vvUuid, $subId, $lessonId] - ); - echo " - Created video_version ID: {$vvId}\n"; - } - } - - if ($apply && $vvId > 0) { - Database::query( - "UPDATE teacher_submissions SET current_published_video_version_id = ? WHERE id = ?", - [$vvId, $subId] - ); - echo " - Updated submission current_published_video_version_id = {$vvId}\n"; - } } } -if ($apply) { - echo "\n=== Successfully linked and activated Math Unit 1 videos! ===\n"; -} else { - echo "\nDRY RUN complete. Run with --apply to execute writes.\n"; -} +echo "\nNo records were changed.\n"; diff --git a/backend/scripts/publish_grade10_bundle.php b/backend/scripts/publish_grade10_bundle.php index 3c57b36..5bd761a 100644 --- a/backend/scripts/publish_grade10_bundle.php +++ b/backend/scripts/publish_grade10_bundle.php @@ -5,12 +5,12 @@ declare(strict_types=1); /** * Publication manager for Grade 10 curriculum bundles and assets. * - * Transitions lessons and their assets from `draft`/`unverified` to `published`/`approved`. + * Publishes bundles only after independent lesson and asset approval. * * Safety rules: * - Default mode is a read-only dry run. It writes nothing unless `--apply` is present. * - All database writes happen inside a single atomic transaction. - * - Only assets belonging to the selected curriculum lessons are updated. + * - This script never grants academic approval or clears rights. * * Usage: * php backend/scripts/publish_grade10_bundle.php --status @@ -93,7 +93,7 @@ if ($isPilot) { 'grade_10/math_10/semester_1/unit_01/lesson_01.md', 'grade_10/math_10/semester_1/unit_01/lesson_02.md', 'grade_10/math_10/semester_1/unit_01/lesson_03.md', - 'grade_10/math_10/semester_1/unit_01/intro_and_project.md', + 'grade_10/physics_10/semester_1/unit_02/lesson_03.md', // Physics 10: Semester 1, Units 1 & 2 'grade_10/physics_10/semester_1/unit_01/lesson_01.md', 'grade_10/physics_10/semester_1/unit_01/lesson_02.md', @@ -140,12 +140,16 @@ if (empty($targetLessons)) { echo "No matching curriculum lessons found for the specified criteria.\n"; exit(0); } +if ($isPilot && count($targetLessons) !== 12) { + fwrite(STDERR, "REFUSE: pilot must contain exactly 12 matching lessons; found " . count($targetLessons) . ".\n"); + exit(2); +} $lessonIds = array_column($targetLessons, 'id'); $idList = implode(',', array_map('intval', $lessonIds)); // 3. Plan summary -$targetAction = $isRevert ? 'REVERT TO DRAFT' : 'PUBLISH / APPROVE'; +$targetAction = $isRevert ? 'WITHDRAW PUBLISHED BUNDLES' : 'PUBLISH REVIEWED BUNDLES'; echo sprintf("=== Grade 10 Publication Plan (%s) ===\n", $apply ? 'APPLY' : 'DRY-RUN'); echo sprintf("Action: %s\n", $targetAction); echo sprintf("Matched Lessons: %d\n", count($targetLessons)); @@ -161,8 +165,60 @@ foreach ($targetLessons as $idx => $l) { ); } +function publicationBlockers(array $targetLessons, bool $lock): array +{ + $blockers = []; + foreach ($targetLessons as $lesson) { + $id = (int)$lesson['id']; + $path = $lesson['source_manifest_path']; + $detail = Database::selectOne( + 'SELECT source_status, reviewed_by, reviewed_at FROM curriculum_lessons WHERE id = ?' . ($lock ? ' FOR UPDATE' : ''), + [$id] + ); + if (!$detail || $detail['source_status'] !== 'approved' || empty($detail['reviewed_by']) || empty($detail['reviewed_at'])) { + $blockers[] = "{$path}: academic approval with reviewer and date is missing"; + } + $bundles = Database::select( + 'SELECT id, status FROM publication_bundles WHERE curriculum_lesson_id = ?' . ($lock ? ' FOR UPDATE' : ''), + [$id] + ); + if (count($bundles) !== 1) { + $blockers[] = "{$path}: expected exactly one bundle; found " . count($bundles); + continue; + } + $bundle = $bundles[0]; + if (!in_array($bundle['status'], ['review', 'published'], true)) { + $blockers[] = "{$path}: bundle must be in review before publication"; + } + $assets = Database::select( + 'SELECT a.id, a.asset_type, a.review_status, a.rights_status, a.source_reference, pba.role + FROM publication_bundle_assets pba JOIN content_assets a ON a.id = pba.content_asset_id + WHERE pba.publication_bundle_id = ?' . ($lock ? ' FOR UPDATE' : ''), + [(int)$bundle['id']] + ); + if ($assets === []) { + $blockers[] = "{$path}: bundle has no assets"; + } + $hasPrimary = false; + foreach ($assets as $asset) { + if ($asset['role'] === 'primary_lesson' && $asset['asset_type'] === 'lesson_markdown') $hasPrimary = true; + if ($asset['review_status'] !== 'approved' || $asset['rights_status'] !== 'cleared' || trim((string)$asset['source_reference']) === '') { + $blockers[] = "{$path}: asset {$asset['id']} lacks review, rights clearance, or source"; + } + } + if (!$hasPrimary) $blockers[] = "{$path}: approved lesson Markdown is missing"; + } + return $blockers; +} + +if (!$apply && !$isRevert) { + $blockers = publicationBlockers($targetLessons, false); + foreach ($blockers as $blocker) echo "BLOCKED {$blocker}\n"; + echo $blockers === [] ? "\n[Ready for reviewed publication; append --apply.]\n" : "\n[Publication blocked; resolve review evidence first.]\n"; + exit($blockers === [] ? 0 : 2); +} if (!$apply) { - echo "\n[Dry-run completed. To execute, append --apply to your command.]\n"; + echo "\n[Dry-run completed. To withdraw bundles, append --apply.]\n"; exit(0); } @@ -172,34 +228,17 @@ $pdo->beginTransaction(); try { if ($isRevert) { - // Revert to draft + // Withdrawal preserves academic decisions, rights records and history. Database::query( - "UPDATE curriculum_lessons SET source_status = 'unverified' WHERE id IN ({$idList})" - ); - Database::query( - "UPDATE publication_bundles SET status = 'draft', published_at = NULL WHERE curriculum_lesson_id IN ({$idList})" - ); - Database::query( - "UPDATE content_assets a - JOIN publication_bundle_assets pba ON pba.content_asset_id = a.id - JOIN publication_bundles pb ON pb.id = pba.publication_bundle_id - SET a.review_status = 'draft', a.rights_status = 'review_required' - WHERE pb.curriculum_lesson_id IN ({$idList})" + "UPDATE publication_bundles SET status = 'withdrawn', withdrawn_at = CURRENT_TIMESTAMP WHERE curriculum_lesson_id IN ({$idList}) AND status = 'published'" ); } else { - // Publish and approve + $blockers = publicationBlockers($targetLessons, true); + if ($blockers !== []) { + throw new RuntimeException("Publication blocked:\n" . implode("\n", $blockers)); + } Database::query( - "UPDATE curriculum_lessons SET source_status = 'approved', reviewed_at = CURRENT_TIMESTAMP WHERE id IN ({$idList})" - ); - Database::query( - "UPDATE publication_bundles SET status = 'published', published_at = CURRENT_TIMESTAMP WHERE curriculum_lesson_id IN ({$idList})" - ); - Database::query( - "UPDATE content_assets a - JOIN publication_bundle_assets pba ON pba.content_asset_id = a.id - JOIN publication_bundles pb ON pb.id = pba.publication_bundle_id - SET a.review_status = 'approved', a.rights_status = 'cleared' - WHERE pb.curriculum_lesson_id IN ({$idList})" + "UPDATE publication_bundles SET status = 'published', published_at = CURRENT_TIMESTAMP WHERE curriculum_lesson_id IN ({$idList}) AND status = 'review'" ); } diff --git a/backend/scripts/publish_grade10_textbooks.php b/backend/scripts/publish_grade10_textbooks.php index 926c1cb..9c1b254 100644 --- a/backend/scripts/publish_grade10_textbooks.php +++ b/backend/scripts/publish_grade10_textbooks.php @@ -1,13 +1,10 @@ 'chemistry_10', 'الرياضيات' => 'math_10', 'الفيزياء' => 'physics_10', + 'الأحياء' => 'biology_10', 'حياتية' => 'biology_10', 'علوم الأرض' => 'earth_sciences_10', + 'اللغة العربية' => 'arabic_10', 'العربية' => 'arabic_10', + 'الإنجليزية' => 'english_10', 'الانجليزية' => 'english_10', + 'التربية الإسلامية' => 'islamic_10', 'الإسلامية' => 'islamic_10', + 'تاريخ' => 'history_10', 'جغرافيا' => 'geography_10', + 'الوطنية' => 'civics_10', 'المالية' => 'financial_10', + 'الرقمية' => 'digital_skills_10', 'حاسوب' => 'digital_skills_10', +]; if (!is_dir($booksRoot)) { - echo "Books directory not found at {$booksRoot}\n"; + fwrite(STDERR, "Books directory is missing.\n"); exit(1); } -$pdfFiles = glob($booksRoot . '/*.pdf') ?: []; -echo "Found " . count($pdfFiles) . " textbook PDFs in {$booksRoot}.\n"; - -$subjectMap = [ - 'كيمياء' => 'chemistry_10', - 'الرياضيات' => 'math_10', - 'الفيزياء' => 'physics_10', - 'الأحياء' => 'biology_10', - 'حياتية' => 'biology_10', - 'علوم الأرض' => 'earth_sciences_10', - 'اللغة العربية' => 'arabic_10', - 'العربية' => 'arabic_10', - 'الإنجليزية' => 'english_10', - 'الانجليزية' => 'english_10', - 'التربية الإسلامية' => 'islamic_10', - 'الإسلامية' => 'islamic_10', - 'تاريخ' => 'history_10', - 'جغرافيا' => 'geography_10', - 'الوطنية' => 'civics_10', - 'المالية' => 'financial_10', - 'الرقمية' => 'digital_skills_10', - 'حاسوب' => 'digital_skills_10', -]; - -$publishedCount = 0; - -foreach ($pdfFiles as $filePath) { +$ready = []; +$blocked = []; +foreach (glob($booksRoot . '/*.pdf') ?: [] as $filePath) { $filename = basename($filePath); - $sha256 = hash_file('sha256', $filePath); - $bytes = filesize($filePath); - - // Identify subject - $matchedSubject = null; - foreach ($subjectMap as $keyword => $subKey) { - if (str_contains($filename, $keyword)) { - $matchedSubject = $subKey; - break; - } - } - - if (!$matchedSubject) { - echo " - Skipping unclassified PDF: {$filename}\n"; + if (!str_contains($filename, 'كتاب الطالب')) { + echo "SKIP {$filename}: not a student textbook\n"; continue; } - - $semesterKey = str_contains($filename, 'الثاني') ? 'semester_2' : 'semester_1'; - echo "\nProcessing: [{$matchedSubject} - {$semesterKey}] {$filename}\n"; - - // Destination in curriculum storage - $relativeDest = "textbooks/grade_10/{$matchedSubject}/" . basename($filePath); - $fullDest = $curriculumRoot . '/' . $relativeDest; - - if ($apply) { - $destDir = dirname($fullDest); - if (!is_dir($destDir)) { - mkdir($destDir, 0755, true); - } - if (!file_exists($fullDest) || hash_file('sha256', $fullDest) !== $sha256) { - copy($filePath, $fullDest); - } + $subject = null; + foreach ($subjectMap as $keyword => $key) { + if (str_contains($filename, $keyword)) { $subject = $key; break; } } - - // Find the representative published bundle for this subject - $lesson = Database::selectOne( - "SELECT cl.id, cl.uuid FROM curriculum_lessons cl - JOIN publication_bundles pb ON pb.curriculum_lesson_id = cl.id - WHERE cl.grade_key = 'grade_10' AND cl.subject_key = ? AND cl.semester_key = ? - ORDER BY cl.unit_key ASC, cl.lesson_key ASC LIMIT 1", - [$matchedSubject, $semesterKey] - ); - - if (!$lesson) { - // Fallback: any lesson in the subject - $lesson = Database::selectOne( - "SELECT cl.id, cl.uuid FROM curriculum_lessons cl - WHERE cl.grade_key = 'grade_10' AND cl.subject_key = ? - ORDER BY cl.id ASC LIMIT 1", - [$matchedSubject] - ); - } - - if (!$lesson) { - echo " - No curriculum lesson found for {$matchedSubject}.\n"; + if ($subject === null) { $blocked[] = "{$filename}: unknown subject"; continue; } + if (str_contains($filename, 'الفصل الثاني')) { + $semester = 'semester_2'; + } elseif (str_contains($filename, 'الفصل الأول')) { + $semester = 'semester_1'; + } else { + $blocked[] = "{$filename}: explicit semester is missing"; continue; } + $sha = hash_file('sha256', $filePath); + if ($sha === false) { $blocked[] = "{$filename}: unreadable source"; continue; } - $lessonId = (int)$lesson['id']; - - // Ensure publication bundle exists - $bundle = Database::selectOne( - "SELECT id FROM publication_bundles WHERE curriculum_lesson_id = ? AND status = 'published' LIMIT 1", - [$lessonId] - ); - - $bundleId = 0; - if ($bundle) { - $bundleId = (int)$bundle['id']; - } elseif ($apply) { - $bUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)); - $bundleId = (int)Database::insert( - "INSERT INTO publication_bundles (uuid, curriculum_lesson_id, bundle_version, status, published_at) - VALUES (?, ?, 'grade10-intake-2026-09-10', 'published', NOW())", - [$bUuid, $lessonId] - ); - } - - // Ensure content_asset exists $asset = Database::selectOne( - "SELECT id, uuid FROM content_assets WHERE sha256 = ? LIMIT 1", - [$sha256] + "SELECT id, storage_driver, storage_key, sha256, review_status, rights_status, source_reference + FROM content_assets WHERE sha256 = ? AND asset_type = 'textbook_pdf' LIMIT 1", + [$sha] ); - - $assetId = 0; - if ($asset) { - $assetId = (int)$asset['id']; - echo " - Asset exists (ID: {$assetId})\n"; - if ($apply) { - Database::query( - "UPDATE content_assets SET review_status = 'approved', rights_status = 'cleared', storage_key = ? WHERE id = ?", - [$relativeDest, $assetId] - ); - } - } elseif ($apply) { - $aUuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0x0fff) | 0x4000, mt_rand(0, 0x3fff) | 0x8000, mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)); - $assetId = (int)Database::insert( - "INSERT INTO content_assets - (uuid, asset_type, storage_driver, storage_key, mime_type, byte_size, sha256, rights_status, review_status) - VALUES (?, 'textbook_pdf', 'local', ?, 'application/pdf', ?, ?, 'cleared', 'approved')", - [$aUuid, $relativeDest, $bytes, $sha256] - ); - echo " - Created content_asset ID {$assetId}\n"; + if (!$asset || $asset['review_status'] !== 'approved' || $asset['rights_status'] !== 'cleared' || + trim((string)$asset['source_reference']) === '') { + $blocked[] = "{$filename}: reviewed textbook asset and rights evidence are missing"; + continue; } + if ($asset['storage_driver'] !== 'local') { + $blocked[] = "{$filename}: verify the remote asset with its storage provider before linking"; + continue; + } + $storagePath = realpath($curriculumRoot . '/' . $asset['storage_key']); + $rootPath = realpath($curriculumRoot); + if ($storagePath === false || $rootPath === false || !str_starts_with($storagePath, $rootPath . DIRECTORY_SEPARATOR) || + !hash_equals($sha, (string)hash_file('sha256', $storagePath))) { + $blocked[] = "{$filename}: approved stored file is missing or its checksum differs"; + continue; + } + $bundle = Database::selectOne( + "SELECT pb.id FROM publication_bundles pb + JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id + WHERE cl.grade_key = 'grade_10' AND cl.subject_key = ? AND cl.semester_key = ? + AND cl.source_status = 'approved' AND pb.status = 'published' + ORDER BY cl.unit_key, cl.lesson_key, pb.id LIMIT 1", + [$subject, $semester] + ); + if (!$bundle) { + $blocked[] = "{$filename}: no published bundle for {$subject}/{$semester}"; + continue; + } + $ready[] = ['name' => $filename, 'bundle_id' => (int)$bundle['id'], 'asset_id' => (int)$asset['id']]; +} - // Link in publication_bundle_assets - if ($apply && $bundleId > 0 && $assetId > 0) { +foreach ($blocked as $reason) echo "BLOCKED {$reason}\n"; +foreach ($ready as $row) echo "READY {$row['name']} => bundle {$row['bundle_id']}\n"; +if (!$apply) { + echo "DRY RUN: " . count($ready) . " ready, " . count($blocked) . " blocked.\n"; + exit($blocked === [] ? 0 : 2); +} +if ($blocked !== []) { + fwrite(STDERR, "REFUSE: resolve all textbook review and matching blockers before --apply.\n"); + exit(2); +} +if ($ready === []) { + fwrite(STDERR, "REFUSE: no reviewed textbook to link.\n"); + exit(2); +} + +$pdo = Database::getConnection(); +$pdo->beginTransaction(); +try { + foreach ($ready as $row) { + $current = Database::selectOne( + "SELECT pb.status, cl.source_status, a.review_status, a.rights_status + FROM publication_bundles pb JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id + JOIN content_assets a ON a.id = ? WHERE pb.id = ? FOR UPDATE", + [$row['asset_id'], $row['bundle_id']] + ); + if (!$current || $current['status'] !== 'published' || $current['source_status'] !== 'approved' || + $current['review_status'] !== 'approved' || $current['rights_status'] !== 'cleared') { + throw new RuntimeException('Approval changed while linking textbooks.'); + } Database::query( "INSERT INTO publication_bundle_assets (publication_bundle_id, content_asset_id, role, sort_order) - VALUES (?, ?, 'textbook', 1) - ON DUPLICATE KEY UPDATE role = 'textbook'", - [$bundleId, $assetId] + VALUES (?, ?, 'textbook', 1) ON DUPLICATE KEY UPDATE sort_order = VALUES(sort_order)", + [$row['bundle_id'], $row['asset_id']] ); - echo " - Linked to publication bundle {$bundleId} as textbook.\n"; - $publishedCount++; } -} - -if ($apply) { - echo "\n=== Successfully published {$publishedCount} textbooks! ===\n"; -} else { - echo "\nDRY RUN complete. Run with --apply to execute.\n"; + $pdo->commit(); + echo "Linked " . count($ready) . " reviewed textbooks.\n"; +} catch (Throwable $e) { + if ($pdo->inTransaction()) $pdo->rollBack(); + fwrite(STDERR, "FAILED: {$e->getMessage()}\n"); + exit(1); } diff --git a/backend/scripts/test_published_content_access_local.php b/backend/scripts/test_published_content_access_local.php new file mode 100644 index 0000000..5f459eb --- /dev/null +++ b/backend/scripts/test_published_content_access_local.php @@ -0,0 +1,38 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +(new ReflectionProperty(Database::class, 'instance'))->setValue(null, $pdo); +$pdo->exec('CREATE TABLE auth_identities (id INTEGER PRIMARY KEY, status TEXT)'); +$pdo->exec('CREATE TABLE students (id INTEGER PRIMARY KEY, grade_level TEXT, identity_id INTEGER)'); +$pdo->exec("INSERT INTO auth_identities VALUES (1, 'active'), (2, 'disabled')"); +$pdo->exec("INSERT INTO students VALUES (7, 'grade_10', 1), (8, 'grade_9', 1), (10, 'grade_10', 2)"); + +function checkContent(bool $condition, string $message): void +{ + if (!$condition) throw new RuntimeException($message); +} + +$asset = ['grade_key' => 'grade_10', 'storage_driver' => 'local', 'storage_key' => 'manifest.json']; +checkContent(PublishedContentService::studentMayRead(7, $asset), 'Same-grade student denied'); +checkContent(!PublishedContentService::studentMayRead(8, $asset), 'Other-grade student allowed'); +checkContent(!PublishedContentService::studentMayRead(9, $asset), 'Unknown student allowed'); +checkContent(!PublishedContentService::studentMayRead(10, $asset), 'Disabled identity allowed'); +checkContent(PublishedContentService::readLocalAsset($asset) !== null, 'Exact in-root asset not found'); +checkContent(PublishedContentService::readLocalAsset(array_merge($asset, ['storage_key' => '../../database_schema.sql'])) === null, 'Traversal escaped storage root'); +checkContent(PublishedContentService::readLocalAsset(array_merge($asset, ['storage_key' => 'missing.md'])) === null, 'Missing file substituted'); +checkContent(CurriculumService::getLessonMarkdown('../manifest.json') === '', 'Unsafe lesson read returned content'); +checkContent(!CurriculumService::safeMarkdownPath('../grade_10/a.md'), 'Traversal path accepted'); +checkContent(CurriculumService::safeMarkdownPath('grade_10/math_10/semester_1/unit_01/lesson_01.md'), 'Valid markdown path rejected'); +echo "PASS 10 local content-access checks (not an HTTP/tenant test)\n"; diff --git a/backend/scripts/test_published_curriculum_tree_filter.php b/backend/scripts/test_published_curriculum_tree_filter.php new file mode 100644 index 0000000..04cfcba --- /dev/null +++ b/backend/scripts/test_published_curriculum_tree_filter.php @@ -0,0 +1,54 @@ + ['subjects' => [ + 'math_10' => [ + 'resources' => ['textbooks' => ['items' => [['file' => 'unpublished.pdf']]]], + 'semesters' => ['semester_1' => ['units' => [ + 'unit_01' => ['lessons' => [ + ['id' => 'lesson_01', 'title' => 'عنوان أولي', 'file' => 'g10/math/u1/l1.md', 'outcomes' => ['غير مراجع']], + ['id' => 'lesson_02', 'title' => 'غير منشور', 'file' => 'g10/math/u1/l2.md'], + ]], + ]]], + ], + ]], + 'grade_11' => ['subjects' => [ + 'math_10' => ['semesters' => ['semester_1' => ['units' => [ + 'unit_01' => ['lessons' => [['id' => 'lesson_01', 'title' => 'درس آخر', 'file' => 'g10/math/u1/l1.md']]], + ]]]], + ]], +]; +$row = [ + 'grade_key' => 'grade_10', 'subject_key' => 'math_10', 'semester_key' => 'semester_1', + 'unit_key' => 'unit_01', 'lesson_key' => 'lesson_01', 'source_manifest_path' => 'g10/math/u1/l1.md', + 'uuid' => '0e506aae-4699-49e4-a91d-aa3146e4addb', 'title' => 'العنوان المراجع', + 'curriculum_version' => '2026-a', 'has_video' => true, + 'primary_asset_id' => '47d85a37-bdaf-4f54-a8b8-216b619b5d04', +]; + +function expectTree(bool $condition, string $message): void +{ + if (!$condition) throw new RuntimeException($message); +} + +expectTree(PublishedCurriculumTreeFilter::filter($manifest, []) === [], 'No publications must yield an empty tree'); +$visible = PublishedCurriculumTreeFilter::filter($manifest, [$row]); +expectTree(count($visible) === 1 && isset($visible['grade_10']), 'Unpublished grade leaked'); +$subject = $visible['grade_10']['subjects']['math_10']; +$lessons = $subject['semesters']['semester_1']['units']['unit_01']['lessons']; +expectTree(count($lessons) === 1, 'Unpublished lesson leaked'); +expectTree($lessons[0]['title'] === 'العنوان المراجع' && $lessons[0]['curriculum_lesson_id'] === $row['uuid'], 'Published identity/title not authoritative'); +expectTree($lessons[0]['curriculum_version'] === '2026-a', 'Curriculum version missing'); +expectTree($lessons[0]['primary_lesson_asset_id'] === $row['primary_asset_id'], 'Published markdown asset not linked'); +expectTree($lessons[0]['has_video'] === true, 'Published video availability lost'); +expectTree(!isset($lessons[0]['file']) && !isset($lessons[0]['outcomes']), 'Unreviewed manifest fields leaked'); +expectTree($subject['resources']['textbooks']['items'] === [], 'Unpublished resource leaked'); +expectTree(PublishedCurriculumTreeFilter::filter($manifest, [array_merge($row, ['source_manifest_path' => 'wrong.md'])]) === [], 'Wrong source path matched'); +expectTree(PublishedCurriculumTreeFilter::filter($manifest, [array_merge($row, ['subject_key' => 'physics_10'])]) === [], 'Wrong subject matched'); +echo "PASS 11 published curriculum filter checks\n"; diff --git a/backend/scripts/test_remediation_attempt_grader.php b/backend/scripts/test_remediation_attempt_grader.php new file mode 100644 index 0000000..5f715f3 --- /dev/null +++ b/backend/scripts/test_remediation_attempt_grader.php @@ -0,0 +1,37 @@ + 11, 'option_ids' => [101, 102], 'correct_option_id' => 102], + ['question_id' => 12, 'option_ids' => [201, 202], 'correct_option_id' => 201], + ['question_id' => 13, 'option_ids' => [301, 302], 'correct_option_id' => 302], +]; +$checks = 0; +function expectGrade(array $ids, array $key, array $answers, ?int $expected): void +{ + global $checks; + try { + $actual = RemediationAttemptGrader::grade($ids, $key, $answers); + if ($expected === null || $actual !== $expected) throw new RuntimeException('Unexpected grade'); + } catch (InvalidArgumentException $e) { + if ($expected !== null) throw $e; + } + $checks++; +} + +expectGrade($ids, $key, [102, 201, 302], 3); +expectGrade($ids, $key, [102, 202, 302], 2); +expectGrade($ids, $key, [101, 202, 301], 0); +expectGrade($ids, $key, [102, 201], null); +expectGrade($ids, $key, [102, 201, 999], null); +expectGrade($ids, $key, [102, 102, 302], null); +expectGrade($ids, $key, ['102', 201, 302], null); +expectGrade($ids, array_reverse($key), [102, 201, 302], null); +expectGrade([11, 11, 13], $key, [102, 201, 302], null); +echo "PASS {$checks} remediation grading cases\n"; diff --git a/backend/scripts/test_student_access_scope_local.php b/backend/scripts/test_student_access_scope_local.php new file mode 100644 index 0000000..d76c608 --- /dev/null +++ b/backend/scripts/test_student_access_scope_local.php @@ -0,0 +1,61 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +if ($pdo instanceof \Pdo\Sqlite) $pdo->createFunction('NOW', static fn() => gmdate('Y-m-d H:i:s'), 0); +else $pdo->sqliteCreateFunction('NOW', static fn() => gmdate('Y-m-d H:i:s'), 0); +(new ReflectionProperty(Database::class, 'instance'))->setValue(null, $pdo); + +$pdo->exec('CREATE TABLE directorates (id INTEGER PRIMARY KEY, type TEXT)'); +$pdo->exec('CREATE TABLE schools (id INTEGER PRIMARY KEY, name TEXT, type TEXT, directorate_id INTEGER)'); +$pdo->exec('CREATE TABLE students (id INTEGER PRIMARY KEY, national_id TEXT, grade_level TEXT, school_id INTEGER, is_school_sponsored INTEGER)'); +$pdo->exec('CREATE TABLE teachers (id INTEGER PRIMARY KEY, is_school_exclusive INTEGER, is_marketplace_public INTEGER)'); +$pdo->exec('CREATE TABLE courses (id INTEGER PRIMARY KEY, teacher_id INTEGER, school_id INTEGER, is_school_exclusive INTEGER, is_published INTEGER, price_jod NUMERIC, grade_level TEXT)'); +$pdo->exec('CREATE TABLE lessons (id INTEGER PRIMARY KEY, course_id INTEGER, is_free_preview INTEGER)'); +$pdo->exec('CREATE TABLE school_rosters (school_id INTEGER, claimed_student_id INTEGER, is_claimed INTEGER, grade_level TEXT)'); +$pdo->exec('CREATE TABLE course_access_passes (id INTEGER PRIMARY KEY, student_id INTEGER, course_id INTEGER, pass_type TEXT, expires_at TEXT, is_active INTEGER)'); +$pdo->exec("INSERT INTO schools VALUES (1,'School A','private',NULL),(2,'School B','private',NULL)"); +$pdo->exec("INSERT INTO students VALUES (1,'a','grade_10',NULL,0),(2,'b','grade_10',1,1),(3,'c','grade_10',2,1),(4,'d','grade_9',NULL,0),(5,'e','grade_10',1,1)"); +$pdo->exec('INSERT INTO teachers VALUES (1,0,1),(2,1,0)'); +$pdo->exec("INSERT INTO courses VALUES (10,1,NULL,0,1,10,'grade_10'),(11,2,1,1,1,30,'grade_10'),(12,2,2,1,1,30,'grade_10'),(13,1,NULL,0,1,0,'grade_10'),(14,1,NULL,0,0,0,'grade_10')"); +$pdo->exec('INSERT INTO lessons VALUES (100,10,0),(110,11,0),(120,12,0),(130,13,0),(140,14,0)'); +$pdo->exec("INSERT INTO school_rosters VALUES (1,2,1,'grade_10'),(2,3,1,'grade_10')"); +$pdo->exec("INSERT INTO course_access_passes VALUES (1,1,10,'full_marketplace',NULL,1),(2,2,10,'school_included',NULL,1)"); + +$checks = 0; +function expectAccess(int $studentId, int $courseId, int $lessonId, bool $allowed, string $reason, bool $legacySideEffects = false): void +{ + global $checks; + $result = StudentAccessControlService::validateLessonAccess($studentId, null, 'grade_10', $courseId, $lessonId, $legacySideEffects); + if ((bool)$result['allowed'] !== $allowed || (string)$result['reason'] !== $reason) { + throw new RuntimeException("Unexpected access for student {$studentId}, course {$courseId}: " . json_encode($result)); + } + $checks++; +} + +expectAccess(1, 10, 100, true, 'paid_pass_active'); +expectAccess(1, 11, 110, false, 'school_scope_mismatch'); +expectAccess(2, 11, 110, true, 'school_course_included'); +expectAccess(2, 12, 120, false, 'school_scope_mismatch'); +expectAccess(2, 10, 100, false, 'payment_required'); +expectAccess(2, 13, 130, true, 'free_course'); +expectAccess(5, 11, 110, false, 'school_roster_unverified'); +expectAccess(4, 13, 130, false, 'grade_mismatch', true); +expectAccess(1, 14, 140, false, 'course_unavailable'); +expectAccess(1, 10, 110, false, 'lesson_course_mismatch'); +expectAccess(99, 13, 130, false, 'unauthenticated_or_not_found'); + +$grade = $pdo->query('SELECT grade_level FROM students WHERE id=4')->fetchColumn(); +$passes = (int)$pdo->query('SELECT COUNT(*) FROM course_access_passes')->fetchColumn(); +if ($grade !== 'grade_9' || $passes !== 2) throw new RuntimeException('Access reads changed grade or issued passes'); +$checks++; +echo "PASS {$checks} local student-access scope checks (not an HTTP/MySQL test)\n"; diff --git a/backend/scripts/test_teacher_ledger_local.php b/backend/scripts/test_teacher_ledger_local.php new file mode 100644 index 0000000..42d2793 --- /dev/null +++ b/backend/scripts/test_teacher_ledger_local.php @@ -0,0 +1,52 @@ +setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +if ($pdo instanceof \Pdo\Sqlite) { + $pdo->createFunction('NOW', static fn() => gmdate('Y-m-d H:i:s'), 0); +} else { + $pdo->sqliteCreateFunction('NOW', static fn() => gmdate('Y-m-d H:i:s'), 0); +} +$property = new ReflectionProperty(Database::class, 'instance'); +$property->setValue(null, $pdo); +$pdo->exec('CREATE TABLE ledger_accounts (id INTEGER PRIMARY KEY, code TEXT, owner_type TEXT, owner_id INTEGER, currency TEXT)'); +$pdo->exec('CREATE TABLE ledger_entries (id INTEGER PRIMARY KEY, debit_account_id INTEGER, credit_account_id INTEGER, amount_fils INTEGER)'); +$pdo->exec('CREATE TABLE teacher_withdrawal_holds (id INTEGER PRIMARY KEY, uuid TEXT, teacher_id INTEGER, amount_fils INTEGER, status TEXT, idempotency_key TEXT, released_at TEXT, release_reason TEXT)'); + +function checkLedger(bool $condition, string $message): void +{ + if (!$condition) throw new RuntimeException($message); +} + +$empty = TeacherLedgerService::balance(42); +checkLedger($empty['account_id'] === null && $empty['available_fils'] === 0, 'Missing account must have zero available'); +checkLedger((int)$pdo->query('SELECT COUNT(*) FROM ledger_accounts')->fetchColumn() === 0, 'Balance read created an account'); + +$pdo->exec("INSERT INTO ledger_accounts VALUES (1, 'teacher_available', 'teacher', 42, 'JOD')"); +$pdo->exec('INSERT INTO ledger_entries VALUES (1, 2, 1, 2000), (2, 1, 2, 500)'); +$pdo->exec("INSERT INTO teacher_withdrawal_holds VALUES (1, '5de1e10c-a2ee-47c4-a9ba-5db6e894a94f', 42, 400, 'held', 'fixture-key', NULL, NULL)"); +$balance = TeacherLedgerService::balance(42); +checkLedger($balance['gross_fils'] === 1500 && $balance['held_fils'] === 400 && $balance['available_fils'] === 1100, 'Signed net balance or hold subtraction wrong'); + +$wrongTeacher = TeacherLedgerService::releaseHold(43, '5de1e10c-a2ee-47c4-a9ba-5db6e894a94f', 'سبب مراجعة'); +checkLedger($wrongTeacher['status'] === 'hold_not_releasable', 'Cross-teacher release was allowed'); +$released = TeacherLedgerService::releaseHold(42, '5de1e10c-a2ee-47c4-a9ba-5db6e894a94f', 'فشل نهائي موثق'); +checkLedger($released['status'] === 'released', 'First release failed'); +$again = TeacherLedgerService::releaseHold(42, '5de1e10c-a2ee-47c4-a9ba-5db6e894a94f', 'إعادة'); +checkLedger($again['status'] === 'hold_not_releasable', 'Second release changed state'); +$row = $pdo->query('SELECT status, release_reason FROM teacher_withdrawal_holds WHERE id=1')->fetch(PDO::FETCH_ASSOC); +checkLedger($row['status'] === 'released' && $row['release_reason'] === 'فشل نهائي موثق', 'Release reason not persisted'); +checkLedger(TeacherLedgerService::balance(42)['available_fils'] === 1500, 'Released hold still reduced availability'); + +$invalid = TeacherLedgerService::placeWithdrawalHold(42, 0, 'valid-key-1234567'); +checkLedger($invalid['http_status'] === 400, 'Zero hold should be rejected before database access'); +echo "PASS 9 local ledger checks (not a MySQL concurrency test)\n"; diff --git a/backend/scripts/test_video_coverage_validator.php b/backend/scripts/test_video_coverage_validator.php new file mode 100644 index 0000000..190274d --- /dev/null +++ b/backend/scripts/test_video_coverage_validator.php @@ -0,0 +1,35 @@ + $sha, + 'markdown_sha256' => str_repeat('b', 64), + 'truncated' => false, + 'gaps' => [], + 'segments' => [ + ['start_seconds'=>0,'end_seconds'=>30,'transcript_ref'=>'t1','visual_ref'=>'v1'], + ['start_seconds'=>30,'end_seconds'=>60,'transcript_ref'=>'t2','visual_ref'=>'v2'], + ], +]; +$cases = [ + [$valid, true], + [array_replace($valid, ['truncated'=>true]), false], + [array_replace($valid, ['gaps'=>[[30,31]]]), false], + [array_replace($valid, ['video_sha256'=>str_repeat('c',64)]), false], + [array_replace($valid, ['segments'=>[['start_seconds'=>1,'end_seconds'=>60,'transcript_ref'=>'t','visual_ref'=>'v']]]), false], + [array_replace($valid, ['segments'=>[['start_seconds'=>0,'end_seconds'=>59,'transcript_ref'=>'t','visual_ref'=>'v']]]), false], + [array_replace($valid, ['segments'=>[['start_seconds'=>0,'end_seconds'=>30,'transcript_ref'=>'t','visual_ref'=>'v'],['start_seconds'=>31,'end_seconds'=>60,'transcript_ref'=>'t','visual_ref'=>'v']]]), false], + [array_replace($valid, ['segments'=>[['start_seconds'=>0,'end_seconds'=>60,'transcript_ref'=>'','visual_ref'=>'v']]]), false], +]; +foreach ($cases as $index => [$coverage, $expected]) { + if (VideoCoverageValidator::valid($coverage, 60, $sha) !== $expected) { + throw new RuntimeException("Coverage case {$index} failed"); + } +} +echo 'PASS ' . count($cases) . " video coverage cases\n"; diff --git a/backend/scripts/test_video_review_report_validator.php b/backend/scripts/test_video_review_report_validator.php new file mode 100644 index 0000000..d762167 --- /dev/null +++ b/backend/scripts/test_video_review_report_validator.php @@ -0,0 +1,32 @@ + [], 'issues' => [], + 'coverage' => [ + 'truncated' => false, 'unprocessed_intervals' => [], + 'section_results' => [['status'=>'covered','evidence_refs'=>['segment-1']]], + 'objective_results' => [['status'=>'covered','evidence_refs'=>['segment-1']]], + ], +]; +$cases = [ + [$good, true], + [array_replace($good, ['missing_inputs'=>['transcript']]), false], + [array_replace_recursive($good, ['coverage'=>['truncated'=>true]]), false], + [array_replace_recursive($good, ['coverage'=>['unprocessed_intervals'=>[[0,5]]]]), false], + [array_replace_recursive($good, ['coverage'=>['section_results'=>[['status'=>'missing']]]]), false], + [array_replace($good, ['issues'=>[['severity'=>'major','resolution_status'=>'open']]]), false], + [array_replace($good, ['issues'=>[['severity'=>'critical','resolution_status'=>'resolved']]]), false], + [array_replace($good, ['issues'=>[['severity'=>'critical','resolution_status'=>'resolved','resolution_evidence'=>'reviewer-note-1']]]), true], +]; +foreach ($cases as $index => [$report, $expected]) { + if (VideoReviewReportValidator::approvable($report) !== $expected) { + throw new RuntimeException("Report case {$index} failed"); + } +} +echo 'PASS ' . count($cases) . " video review report cases\n"; diff --git a/docs/PLATFORM_EXECUTION_LEDGER.md b/docs/PLATFORM_EXECUTION_LEDGER.md index 2634bec..f643852 100644 --- a/docs/PLATFORM_EXECUTION_LEDGER.md +++ b/docs/PLATFORM_EXECUTION_LEDGER.md @@ -2,7 +2,7 @@ # 📋 دفتر متابعة وتنفيذ مهام منصة صَقِل — السجل المعماري الحي -تاريخ التحديث الأخير: **16 أيلول 2026** +تاريخ التحديث الأخير: **23 أيلول 2026** المرجع الأساسي: `docs/NEXT_PHASE_MASTER_SPEC.md` و `docs/NEXT_PHASE_EXECUTION_BACKLOG.md` و `AGENTS.md` الهدف: هذا الدفتر هو المرجع التوثيقي والتنفيذي المباشر لجميع محاور ومنعطفات منصة صَقِل، يوضح بدقة متناهية **ما تم إنجازه فعلياً في الكود**، و**شو تبقى من كل نقطة**، مع معايير القبول وشروط الانتقال للمرحلة التالية. @@ -13,18 +13,18 @@ | المعرّف | المحور / المهمة | النطاق والمستوى | الحالة الحالية | ما تم إنجازه في الكود | شو تبقى لإتمامه بدقة | |---|---|---|---|---|---| | **NP-00** | بيئة الاختبار وخط الأساس الآمن | البنية التحتية / P0 | `implemented_unverified` | منع Runtime DDL، فحص syntax لكل PHP، سكربت `run_migrations.php` مع مطابقة Checksum | تجهيز بيئة staging معزولة، تشغيل suite تكامل متصل بـ MySQL و Redis | -| **NP-01** | إيقاف النجاح والمحتوى المصطنع | الأمان والمصداقية / P0 | `verified_in_code` | تعطيل الاعتماد التلقائي لكليك، تعطيل سحب الأرباح التقديري، إزالة التوليد في GET، رد صريح بـ `unavailable` | جرد تنظيف الجداول من أي بيانات demo قديمة بعد المطابقة اليدوية | -| **NP-02** | الهوية المنهجية وحزم الأصول | المناهج والمحتوى / P0 | `in_progress` | 13 مبحثاً (392 درس ماركداون + 371 مواصفة مختبر)، جداول `curriculum_lessons` و `content_assets` | مراجعة واعتماد أول حزمة نشر (Bundle) رسمية لـ 12 درساً نموذجياً وربط الـ PDF الرسمي | -| **NP-03** | حصة المعلم والاستبدال الذري | استوديو المعلم / P1 | `verified_in_code` | `TeacherSubmissionService` مع Preflight و Idempotency-Key وقفل المعاملات، وربط التحقق في `TeacherStudioCubit` وراية التنبيه بالنسخة المرشحة في الواجهة | اختبارات E2E لرفع الفيديو الفعلي ومطابقة الصوت | -| **NP-04** | تدقيق الفيديو والأسئلة السقراطية | الجودة الأكاديمية / P0 | `in_progress` | مشغل قراءة صارم، إيقاف التوليد في GET، فحص Checkpoints المنشورة فقط، عقد البرومبتات | ربط الـ Worker بتفريغ صوتي حقيقي وأدلة مرئية كاملة للحصص المنشورة | -| **NP-05** | اختيار المعلم (0 / 1 / متعدد) | تجربة الطالب / P1 | `implemented_unverified` | `TeacherSelectionSheet` بالفرز التنازلي للتقييم، تشغيل الـ UUID المختار، كشف المعلم المصرح فقط | ربط شجرة المنهج في التطبيق بهوية الدرس المنشور بعد تفعيل الـ Bundles | -| **NP-06** | دفتر الحسابات وحجز الرصيد | المحاسبة المالية / P0 | `implemented_unverified` | `TeacherLedgerService` بوحدة الفلس، قيود القيد المزدوج، حجز السحب الذري بـ Idempotency | تسوية الرصيد الافتتاحي وتدقيق الصلاحيات متعدية الحسابات لمنع IDOR | +| **NP-01** | إيقاف النجاح والمحتوى المصطنع | الأمان والمصداقية / P0 | `in_progress` | تعطيل الاعتماد التلقائي لكليك، تعطيل سحب الأرباح التقديري، إزالة التوليد في GET ومسار الدرس الخام | جرد بقايا الفروع وبيانات demo القديمة، واختبار فشل الخادم/المحتوى عبر التطبيقات | +| **NP-02** | الهوية المنهجية وحزم الأصول | المناهج والمحتوى / P0 | `in_progress` | ملفات المنهج وجداول الأصول؛ بوابة النشر تشترط مراجعة وحقوقاً؛ شجرة الطالب ترشح المنشور فقط وتفصل فهرس تحضير المعلم | مراجعة واعتماد 12 درساً فعلياً، رفع PDF، وتشغيل بوابة النشر على staging واختبار المسارين | +| **NP-03** | حصة المعلم والاستبدال الذري | استوديو المعلم / P1 | `implemented_unverified` | Preflight ونسخة مرشحة؛ أُصلح بقاء النسخة القديمة منشورة أثناء الرفع، وربط إنشاء job بمعاملة الرفع | اختبار تزامن ورفع/استبدال E2E، والتحقق من كل مسارات النشر | +| **NP-04** | تدقيق الفيديو والأسئلة السقراطية | الجودة الأكاديمية / P0 | `in_progress` | أُغلق النشر الإداري المباشر؛ فحص تغطية زمنية كاملة وربط hashes قبل الاعتماد والنشر | ربط Worker بتفريغ ودليل بصري فعليين، مراجعة بشرية، واختبارات تكامل وإصدارات متزامنة | +| **NP-05** | اختيار المعلم (0 / 1 / متعدد) | تجربة الطالب / P1 | `implemented_unverified` | ترتيب خادمي مستقر، عدّ مرشّح بالصلاحية، دفعات 20 مع «عرض المزيد»، وتشغيل UUID المختار | اختبار MySQL/HTTP بحالات 0/1/2/100+ ومدرستين ونسخ بديلة؛ قياس تكلفة التصفح | +| **NP-06** | دفتر الحسابات وحجز الرصيد | المحاسبة المالية / P0 | `implemented_unverified` | جداول قيد مزدوج بالفلس، حجز بمفتاح إعادة وقفل صف المعلم، وقراءة رصيد بلا كتابة | اختبار تزامن MySQL فعلي، تسوية افتتاحية، تدقيق IDOR وبناء دورة التحويل/العكس المثبتة | | **NP-07** | قياس الدقائق المستحقة بالخادم | استحقاق المشاهدة / P1 | `implemented_unverified` | `WatchSessionService` مع فترات متصلة وحساب Server-side Delta ومقارنة زمن الخادم والعميل | اختبار الإجهاد وانقطاع الاتصال ومحاكاة التلاعب بسرعة المشغل | | **NP-08** | توزيع الإيراد وكشف حساب المعلم | المالية والشفافية / P1 | `blocked_policy` | فصل أوعية الدخل (مدرسي / مستقل)، إيقاف افتراض أرباح من حجم المنصة ككل | **قرار تجاري معلق:** حسم سعر الدقيقة، ونسبة صندوق المعلمين، وفترة التسوية | | **NP-09** | بوابة الدفع والبصمة والتحويل | الدفع المالي / P0 | `blocked_psp` | عزل مسارات كليك بوضع غير متاح بدلاً من المحاكاة الصورية والاحتيال | **تعاقد مصرفي معلق:** ربط PSP أردني معتمد، وبناء Webhooks موقعة، وبصمة الجهاز | | **NP-10** | حزمة الإنجليزية والصوت الموحد | المناهج التفاعلية / P1 | `in_progress` | تطوير مختبر الإنجليزية، تنظيم ملفات الوحدات والقواعد، إزالة IPA التخميني | توفير ملفات الصوت البشري المعتمدة للمفردات والنصوص وربطها بروابط CDN | -| **NP-11** | إكمال حلقة التعلم ودفتر الأخطاء | التجربة التعليمية / P1 | `verified_in_code` | 18 مختبراً، نافذة التفكير، دورة التكرار المتباعد بمرحلتين (تثبيت + استرجاع 7 أيام) بالخادم والواجهة | ربط واجهات الواجبات المدرسية بنظام مهام وتكليفات المعلم | -| **NP-12** | المؤسسات والمديريات والإشراف | إدارة التعليم / P1 | `implemented_unverified` | لوحات SuperAdmin والمديريات بقراءة حقيقية، عزل أدوار الـ Middlewares، blind index | استيراد شعب المدارس الحكومية والخاصة واختبارات العزل التام بين المدارس | +| **NP-11** | إكمال حلقة التعلم ودفتر الأخطاء | التجربة التعليمية / P1 | `implemented_unverified` | 18 مختبراً وواجهة العلاج؛ أضيفت محاولة بأسئلة منشورة وتصحيح خادم وموعد استرجاع بعد 7 أيام | تطبيق migration واختبار تكامل وتزامن، مراجعة مصدر الأسئلة، ربط الواجبات والتذكير | +| **NP-12** | المؤسسات والمديريات والإشراف | إدارة التعليم / P1 | `in_progress` | لوحات تقرأ البيانات؛ أُزيل منح دورة مجانية لكل طالب مدرسي، وأضيف فحص مدرسة/كشف انتساب ودور تغيير الصف | استيراد كشوف مؤكدة، عقود صلاحية المباحث، اختبارات IDOR وMySQL/HTTP لمدرستين | | **NP-13** | إثبات الصف العاشر والإطلاق | بوابة الإطلاق النهائي | `gate_pending` | جاهزية التطبيقات الأربعة، مسار التدريب المهني (VTC)، صفحة الهبوط الفاخرة | إجراء التجربة الحية على 12 درساً للصف العاشر مع عينة حقيقية من المعلمين والطلبة | --- @@ -59,11 +59,14 @@ ### [NP-02] الهوية المنهجية وحزم الملفات (Curriculum Identity & Bundles) * **الهدف:** تمييز كل درس بهوية ثابتة (الصف، المبحث، الفصل، الوحدة، الدرس، النسخة)، وربط الأصول كحزم نشر موثوقة. * **ما تم إنجازه في الكود:** - - 13 مبحثاً كاملاً للصف العاشر في `backend/storage/curriculum/grade_10` تضم 392 ملف ماركداون. - - 371 وثيقة مواصفات للمختبرات الافتراضية في `docs/virtual_labs/grade_10`. - - تطبيق جدول `curriculum_lessons` وجدول `content_assets` وجدول `publication_bundles`. + - فهرس الصف العاشر المحلي يحوي 392 ملف Markdown؛ العدد لا يثبت اكتمال المباحث أو صحة المحتوى أو الموافقة على نشره. + - يوجد 370 ملفاً في `docs/virtual_labs/grade_10` وفق جرد الملفات المحلي، ولا تعني مراجعة علمية. + - تعريف migrations لجداول `curriculum_lessons` و`content_assets` و`publication_bundles`؛ تطبيقها على staging/الإنتاج غير مثبت هنا. - نقطة `GET /api/curriculum/assets/{assetId}` التي تخدم الأصول عبر UUID فقط دون تمرير مسارات ملفات من العميل. - سكربت `publish_grade10_bundle.php` المخصص لنشر حزم الصف العاشر مع خيار `--pilot` للدروس الـ 12 النموذجية. + - بوابة `--pilot` تتحقق من 12 ملفات من نوع درس، وتمنع النشر عندما يغيب مراجع الدرس أو تاريخ مراجعته أو اعتماد الأصل أو تصفية حقوقه. السحب يحفظ قرار المراجعة وسجل الحقوق. + - الشجرة العامة للطالب لا تظهر إلا درساً له سجل منهجي معتمد وحزمة وأصل منشوران؛ فهرس تحضير المعلم منفصل خلف صلاحية الدور. + - الشجرة العامة ترسل UUID أصل Markdown الأساسي؛ قارئ الدرس يستعمله بدلاً من مسار ملف manifest أو UUID الدرس. مسارات `/api/curriculum/lesson` و`/api/curriculum/document` التي تقبل `file` صارت محصورة بأدوار إدارة المنهج. * **شو تبقى منها بدقة:** - [ ] مراجعة واعتماد الحزم المنشورة للدفعة الأولى ونقلها من حالة `draft` إلى `published`. - [ ] رفع ملفات الـ PDF الرسمية للكتب وأوراق العمل وتخزينها في التخزين السيادي كأصول ذات Checksum. @@ -80,6 +83,7 @@ - إضافة شريط التنبيه المرئي في واجهة الرفع `apps/teacher_app/lib/presentation/screens/tabs/teacher_studio_upload_tab.dart` لإعلام المعلم بصراحة: "نسخة مرشحة للاستبدال (إصدار X) — الحصة المنشورة الحالية تظل متاحة للطلاب حتى اجتياز الفحص". * **شو تبقى منها بدقة:** - [ ] اختبارات E2E لرفع الفيديو الفعلي ومطابقة الصوت الميداني. + - [ ] اختبار أن حصة المعلم القديمة تظل مرئية للطالب أثناء رفع ومراجعة البديلة، وأن فشل إنشاء job يرد الرفع إلى حالته السابقة ضمن المعاملة. - [ ] إشعار المعلم اللحظي عند انتهاء التدقيق البشري للنسخة المرشحة. --- @@ -99,10 +103,11 @@ ### [NP-05] اختيار المعلم (0 / 1 / متعدد) * **الهدف:** تطبيق منطق صَقِل: صفر حصص = إظهار الموارد المتاحة بصدق، حصة واحدة = تشغيل مباشر، أكثر من حصة = قائمة بالتقييم الموثق. * **ما تم إنجازه في الكود:** - - واجهة `TeacherSelectionSheet` في تطبيق الطالب بفرز تنازلي للتقييم الحقيقي وعدد التقييمات وشارة التوثيق. - - نقطة `GET /api/curriculum/lessons/{lessonId}/videos` التي تعيد فقط النسخ المنشورة والمصرح بها للطالب. + - واجهة `TeacherSelectionSheet` تعرض ترتيب الخادم ولا تعيد فرزه، وتطلب صفحات إضافية عند الحاجة؛ أزيلت أوصاف أساليب المعلمين المختلقة. تعرض المدة وتاريخ النشر إن أرسلهما الخادم. + - نقطة `GET /api/curriculum/lessons/{lessonId}/videos` تمسح المرشحين على دفعات ثابتة بدلاً من قصّ أول 101 صف، وتعيد العدد والصفحة بعد فحص صلاحية الطالب؛ شرط `EXISTS` يمنع تكرار الحصة عند وجود عدة أصول/وظائف مراجعة. * **شو تبقى منها بدقة:** - [ ] ربط شجرة المنهج في واجهة الطالب (`subject_hub_screen.dart`) بالـ UUID المعتمد للحزم المنشورة عند توفرها. + - [ ] اختبار قائمة 0/1/2/100+، ترتيب التساوي، صلاحيات مدرستين، وتبدل التقييم أو سحب الحصة بين الصفحات على MySQL وواجهة حقيقية. العدّ صحيح داخل طلب واحد عند ثبات snapshot؛ لا يضمن offset ثبات الصفحات بين طلبين إذا تغير النشر أو التقييم. --- @@ -110,11 +115,14 @@ * **الهدف:** نظام محاسبي صارم بوحدة الفلس (بدون فواصل عشرية عائمة)، وقيد مزدوج يمنع السحب المتجاوز للرصيد. * **ما تم إنجازه في الكود:** - خدمة `TeacherLedgerService` الداعمة لـ `balance` و `placeWithdrawalHold` و `releaseHold`. - - حجز السحب ذري (Atomic Transaction) يمنع طلبين متزامنين يتجاوزان الرصيد الفعلي. + - الحجز يقفل صف `teachers` للمعلم قبل قراءة الرصيد أو الحجز، ليُسلسل الطلبات المتزامنة لنفس المعلم؛ هذه آلية كود لم تُختبر بعد بتزامن MySQL فعلي. قراءة الرصيد لم تعد تنشئ حساباً عند غيابه، وحساب صافي القيود يستخدم أعداداً موقعة قبل الطرح. - جداول `ledger_accounts` و `ledger_entries` و `teacher_withdrawal_holds`. + - migration `20260923_teacher_hold_release_reason.sql` تحفظ سبب تحرير الحجز في السجل، ولا تخترع سبباً لتحريرات سابقة. * **شو تبقى منها بدقة:** + - [ ] تطبيق migration على staging قبل نشر الكاتب، ثم اختبار طلبَي حجز متزامنين وretry بمفتاح واحد وآخر مختلف، والتحرير المكرر، والرصيد السالب/الصفر، وفشل منتصف المعاملة. - [ ] تسوية الأرصدة الافتتاحية السابقة ومطابقتها قبل تفعيل أي قيد حي. - [ ] تدقيق أمان الـ IDOR للتأكد من عدم وصول أي معلم لدفتر معلم آخر. + - [ ] كل كاتب قيود/صرف مستقبلي يجب أن يلتزم بقفل صف المعلم ذاته قبل تغيير رصيده؛ الخدمة ليست موصولة بمسار دفع/سحب حي، ولا تثبت وحدها سلامة تحويل الأموال أو توازن كل قيد تاريخي. --- @@ -169,12 +177,12 @@ * **ما تم إنجازه في الكود:** - 18 مختبراً تفاعلياً مدمجاً ومفحوصاً باختبارات Smoke (12 لغة عربية + 6 فيزياء). - نافذة التفكير الإلزامي `ScaffoldedThinkingPauseSheet` (60 ثانية) مع التدرج المعرفي خطوة بخطوة. - - تطبيق دورة التكرار المتباعد بمرحلتين (Two-Stage Spaced Repetition) في `ErrorNotebookController.php`: - - المرحلة الأولى (التدريب العلاجي الأولي): ينتقل الخطأ من `pending_remediation` إلى `in_remediation` وتتم جدولة اختبار الاسترجاع بعد 7 أيام (`retention_scheduled_at = NOW() + 7 DAYS`). - - المرحلة الثانية (تحدي الاسترجاع والتثبيت): عند اجتياز الطالب للاختبار بعد اكتمال الفترة ينتقل إلى `mastered` مع تسجيل تاريخ الإتقان الفعلي. +- أضيفت migration `20260923_error_notebook_verified_attempts.sql` لحفظ المحاولة وموعد `retention_due_at` وحقول مراجعة أسئلة العلاج. التصحيح يجري على الخادم بثلاثة أسئلة منشورة ومراجعة بشرياً من نفس الدرس والمفهوم، مع خيار صحيح واحد لكل سؤال، ومفتاح إجابة محفوظ للمحاولة لا يصل إلى التطبيق. اجتياز المرحلة الأولى يحدد موعد المراجعة بعد 7 أيام؛ اجتياز الثانية بعد الموعد ينقل الحالة إلى `mastered`. - شاشة دفتر الأخطاء الذكي `SmartErrorNotebookScreen`: إضافة تبويبات التصفية بحسب الحالة (الكل، بانتظار المعالجة، قيد التثبيت 🌱، تم الإتقان ✨)، وصندوق إحصائي مستقل لقيد التثبيت، وزر خاص لبدء اختبار الاسترجاع والتثبيت المتباعد. - منظومة ربط ولي الأمر المشروطة بموافقة صريحة من الطالب. * **شو تبقى منها بدقة:** + - [ ] تطبيق migration على staging بعد نسخة احتياطية، واختبار المحاولة الصحيحة والخاطئة والمكررة والمتزامنة وانتهاء الصلاحية واختلاف الطالب، والتحقق من عدم رفع الإتقان قبل الموعد. + - [ ] مراجعة مصدر وحقوق وصحة أسئلة بنك الامتحان وتعبئة حقول `remediation_review_*` و`remediation_source_reference` لكل سؤال معتمد. الأسئلة القديمة تبدأ `draft` ولا تصلح تلقائياً لإثبات الإتقان. - [ ] ربط واجهات الواجبات المدرسية بنظام مهام وتكليفات المعلم. - [ ] دفع إشعارات تذكير للطالب عند استحقاق موعد اختبار التثبيت (اليوم السابع). @@ -186,8 +194,10 @@ - لوحات الإدارة والسوبر أدمن تقرأ إحصاءات قاعدة البيانات الحقيقية. - تشفير الرقم الوطني بـ AES-256-GCM مع فهرسة عمياء بـ HMAC. - عزل الصلاحيات عبر الـ Middlewares لكل دور. + - فحص الحصة يرفض نقل صف الطالب أو إنشاء تصريح مدرسي أثناء القراءة؛ الدورة ذات `school_id` لا تُفتح إلا لطالب المدرسة نفسها مع `school_rosters.is_claimed=1`، ولا يجيز تصريح `school_included` قديم الوصول لدورة سوق عامة. مسارا حالة ملف الطالب وتغيير صفه يشترطان دور الطالب، ولا يغير طالب المدرسة صفه من الواجهة. * **شو تبقى منها بدقة:** - [ ] بناء آلية استيراد شعب المدارس وقوائم الطلبة من ملفات Excel/CSV. + - [ ] تدقيق مصدر كشوف الانتساب الحالية وربط حقوق الدورات بعقد مدرسي صريح؛ وجود `courses.school_id` وحده ليس دليلاً تعاقدياً شاملاً. - [ ] اختبارات اختراق وعزل (IDOR multi-tenant isolation) لضمان عدم تسريب أي بيانات بين المدارس. --- @@ -207,8 +217,111 @@ ## 3. الخطوات المباشرة للتنفيذ الآن (Action Plan) -1. **الخطوة الأولى (تقني) — [تم الإنجاز]:** ربط خطوة التحقق المسبق (Preflight) وتنبيه النسخة المرشحة في استوديو المعلم داخل `apps/teacher_app`. -2. **الخطوة الثانية (تعليمي) — [تم الإنجاز]:** تطبيق خوارزمية التكرار المتباعد (المرحلتين: المعالجة الأولية + اختبار الاسترجاع والتثبيت بعد 7 أيام) في الخادم والواجهة. +1. **الخطوة الأولى (تقني) — [منفذة في الكود، غير متحققة E2E]:** ربط خطوة التحقق المسبق (Preflight) وتنبيه النسخة المرشحة في استوديو المعلم داخل `apps/teacher_app`. +2. **الخطوة الثانية (تعليمي) — [منفذة في الكود، غير متحققة تشغيلية]:** تطبيق خوارزمية التكرار المتباعد (المرحلتين: المعالجة الأولية + اختبار الاسترجاع والتثبيت بعد 7 أيام) في الخادم والواجهة، مع إعادة تحميل النتيجة من الخادم. 3. **الخطوة الثالثة (محتوى) — [التركيز الحالي]:** فحص واعتماد حزمة الـ 12 درساً النموذجية للصف العاشر (`publish_grade10_bundle.php --pilot`) والتحقق من سلامة الأصول والربط. +### 2026-09-20 — تحديث التطبيق والتحقق من مساري الاستبدال ودفتر الأخطاء + +- **NP-03 / استوديو المعلم:** اكتمل ربط نتيجة `preflight` في `TeacherStudioCubit` مع حالة النسخة المرشحة ورقمها، وأضيف تنبيه صريح في `teacher_studio_upload_tab.dart` بأن الحصة المنشورة الحالية تبقى متاحة حتى اجتياز الفحص. هذا يثبت تكامل الواجهة مع مسار الاستبدال المرشح، ولا يثبت بعد رفع فيديو فعلي أو فحصه من البداية للنهاية. +- **NP-11 / دفتر الأخطاء:** أصبح نجاح المسار العلاجي يعيد تحميل البيانات من الخادم بدلاً من تعديل الحالة محلياً بشكل تفاؤلي، وأضيفت حالة `in_remediation` وعدادها ومرشحها ومسار اختبار الاسترجاع بعد 7 أيام في الواجهة، بما يتوافق مع انتقالات الخادم إلى `mastered`. لا يثبت ذلك بعد إرسال تذكير فعلي عند الاستحقاق أو ربط تكليفات المدرسة. +- **المرجع:** commit `e8163fe` بتاريخ 2026-09-20، وشمل `ErrorNotebookController.php` وتطبيق الطالب وتطبيق المعلم وملفات دفتر السجل. +- **فحوص هذه الدفعة:** لا يوجد في سجل commit دليل على تشغيل فحص جديد بعد التغييرات. تبقى آخر فحوص موثقة هي PHP lint و`git diff --check` وFlutter analyze المذكورة في إدخالات 9 و16 أيلول؛ لا نعدّ هذا التحديث متحققاً بتكامل قاعدة بيانات أو E2E. +- **الحالة بعد التحديث:** لم تتغير بوابة الإطلاق. ما زال نشر حزمة الـ12 درساً، تطبيق migrations على staging، اختبارات MySQL/Redis، التفريغ الصوتي والأدلة المرئية، الصوت الإنجليزي المعتمد، سياسة الدخل ومزود الدفع، وتجربة المستخدمين الفعلية غير مثبتة. + +### 2026-09-23 — تصحيح صدق نتيجة دفتر الأخطاء (NP-11) + +- كشف التدقيق أن طلب `resolve` السابق كان يقبل إثبات النجاح من الهاتف دون الإجابات، وأن أسئلة عامة مؤلفة كانت تظهر إذا نقص بنك الأسئلة؛ كما لم يكن حقل موعد الاسترجاع موجوداً في المخطط. لذلك أعيدت حالة NP-11 من `verified_in_code` إلى `implemented_unverified`. +- أضيفت migration لمحاولات العلاج وموعد الاسترجاع وحقول المراجعة الأكاديمية. بدء المحاولة أصبح POST؛ يسحب ثلاثة أسئلة منشورة ومراجعة مرتبطة بنفس `lesson_id` و`topic_tag` ويخزن مفتاح الإجابة في الخادم. إن لم تكفِ الأسئلة يعرض عدم التوفر. الإرسال يتضمن خيارات الإجابات، والتصحيح والانتقال بين الحالات يحدثان داخل transaction مع قفل صف الفجوة والمحاولة، وصلاحية 30 دقيقة ومحاولة استعمال واحدة، وإعادة نفس الإرسال تعيد النتيجة دون زيادة العدادات. +- تطبيق الطالب لم يعد يستقبل `correct_index` ولا يرفع إتقاناً بناءً على تصحيح محلي. عند تعذر تحميل الدفتر يعرض خطأ وإعادة محاولة بدلاً من نسبة إتقان 100% مصطنعة. نتيجة اختبار الاسترجاع لا تدّعي إثبات الذاكرة طويلة المدى. +- **فحوص:** `php -l` للمتحكم والراوتر، اختبار محلي خالص `test_remediation_attempt_grader.php` (9 حالات)، `dart analyze` للملفات الثلاثة المعدلة (صفر أخطاء، 14 ملاحظة `withOpacity` موجودة سابقاً)، و`git diff --check`. **غير منفذ:** migration واختبار MySQL/HTTP حقيقي لغياب بيئة staging معزولة مثبتة؛ لم تُعدّل بيانات تشغيلية. +- **التالي:** طبّق migration واختبر حالات الفشل والتزامن على staging، ثم راجع واعتمد مجموعة أسئلة الدرس التجريبي قبل قياس الإتقان في تجربة الصف العاشر. + +### 2026-09-23 — إغلاق اختصار اعتماد حزمة الصف العاشر (NP-02) + +- كشف التدقيق أن `publish_grade10_bundle.php --apply` كان يرفع `source_status` و`review_status` و`rights_status` آلياً دون دليل مراجعة أو حقوق. عُدّل السكربت ليعرض أسباب المنع في dry-run، ويقتصر التنفيذ على تحويل حزمة مراجعة مستوفية إلى منشورة. يتطلب مراجعاً وتاريخاً للدرس، وأصلاً رئيسياً، واعتماداً وحقوقاً مصفاة ومصدرًا لكل أصل. سحب الحزمة لا يمحو هذه القرارات. +- صحح اختيار الـpilot ليشمل 12 ملفات دروس فعلية بدلاً من إدراج `intro_and_project.md` ضمن عدد الدروس. بوابة الملفات المحلية `validate_grade10_candidate.php --json --manifest=manifest.accepted.json` أعادت `passed` مع 0 أخطاء و581 تحذيراً على 369 مدخلاً؛ هذا فحص بنيوي وليس موافقة أكاديمية. +- **فحوص:** PHP lint و`git diff --check` لملفات التغيير. **غير منفذ:** dry-run المعتمد على MySQL، تطبيق migration، مراجعة بشرية، أو نشر حي. تبقى حزمة الـ12 غير معتمدة في هذا السجل. + +### 2026-09-23 — متابعة بوابات الصدق للمحتوى والعلاج + +- أضيف `RemediationAttemptGrader` لاختبار ربط كل إجابة بسؤالها وخياراتها المخزنة؛ فحص محلي خالص نجح في 9 حالات صحيحة وخاطئة ومدخلات غير صالحة. أضيفت حقول مراجعة بشرية لأسئلة العلاج، وتبدأ الأسئلة القديمة `draft` فلا تُستخدم لإثبات الإتقان قبل مراجعتها. +- أزيل اختصار آخر في `publish_grade10_textbooks.php`: لم يعد ينشئ حزمة منشورة أو أصل PDF معتمداً أو يصفّي الحقوق تلقائياً، ولا ينقل كتاباً إلى فصل مختلف عند غياب المطابقة. يربط فقط PDF طالب له أصل محلي معتمد وحقوق مصفاة وبصمة ملف مطابقة وحزمة منشورة في المبحث والفصل الصحيحين؛ المعاينة تعرض العوائق وتمنع `--apply` عند وجودها. +- **الفحوص المحلية:** PHP lint للسكربت والخدمة والمتحكم، 9 حالات للمصحح، و`git diff --check`. **غير مختبر:** سلوك MySQL والتزامن وربط PDF على staging؛ ملفات PDF وموافقات حقوقها تحتاج فحصاً بشرياً. + +### 2026-09-23 — إغلاق تجاوز النشر وحماية النسخة الحالية (NP-03/04) + +- أُلغي مسار `SuperAdminController::reviewSubmission` القديم الذي كان يغيّر حالة الفيديو إلى `published` دون job فحص؛ يعيد الآن 410 ويوجه إلى مسار الأدلة والقرار والنشر المنفصل. صُحح استعلام قائمة الحزم لحقول مخطط المنهج الفعلية. +- أضيف `VideoCoverageValidator`: يرفض التغطية المقطوعة، والفجوات الزمنية، وتعارض بصمة الفيديو أو نقص مراجع التفريغ والصورة. تقيد `VideoReviewService` الأدلة ببصمة الفيديو وMarkdown والمدة الفعلية، ثم تعيد فحص المصدر والنسخة والحزمة المنشورة وقرار المراجع قبل تبديل النسخة الحالية. +- أبقيت `teacher_submissions.status='published'` عند رفع بديلة أو مراجعتها إذا كان لها فيديو حالي؛ ونُقل إنشاء job إلى معاملة إرفاق الفيديو بحيث يفشل معها الرفع كله بدلاً من ترك نسخة `review` بلا مهمة. الاستبدال يتطلب وجود نسخة منشورة حالية. +- **فحوص:** 8 حالات محلية خالصة للتحقق الزمني، وPHP lint و`git diff --check`. **غير منفذ:** اختبار MySQL/HTTP وتزامن فعلي، تفريغ فيديو حقيقي، مراجعة أكاديمية أو نشر. + +### 2026-09-23 — مراجعة أعمق لمسارات الفيديو القديمة + +- لم يعد `VideoController::getPlaybackData` يحذف امتحانات أثناء GET أو يرسل أسئلة قديمة مع `is_correct` قبل إجابة الطالب. تظل قائمة الأسئلة في المسار القديم فارغة إلى حين نشر أسئلة مربوطة بنسخة الفيديو ودليلها. +- حفظ سؤال المعلم عبر `saveCheckpoint` أصبح مسودة غير منشورة (`is_published=0`) مع تحقق من الخيارات وتوقيت الفيديو ومعاملة قاعدة بيانات. يحتاج السؤال مسار ربط أدلة ومراجعة قبل النشر. +- أضيف فحص تقرير المراجعة: يرفض أقساماً/أهدافاً ناقصة أو مشكلات حرجة/كبرى بلا حل موثق. نجحت 8 حالات محلية لهذا الفحص، إلى جانب 8 حالات تغطية الفيديو و9 حالات تصحيح العلاج. هذا لا يثبت صحة الفيديو علمياً أو تكامل قاعدة البيانات. +- **باقٍ في NP-04:** عامل تفريغ الصوت والصور، إثبات مراجع الأدلة مقابل الملفات الفعلية، مراجعة بشرية للدرس والأسئلة، اختبار تزامن النشر على MySQL، وواجهة تقرير المعلم. + +### 2026-09-23 — ضبط تشغيل وبث النسخ المنشورة (NP-04/05/07) + +- مسارا التشغيل القديمان المعتمدان على العنوان أو رقم `lessons` يعيدان 410؛ التشغيل المعتمد يبقى بمعرّف نسخة الفيديو المنشورة. أضيف تحقق صلاحية الطالب والنسخة المنشورة والمصدر المنهجي قبل إرسال الفيديو المحلي أو مقطع HLS، مع `Cache-Control: private, no-store` للملفات المحلية. يمكن للمعلم صاحب الدورة معاينة رفعه، وللسوبر أدمن صلاحية الإدارة. +- قائمة حصص الدرس وتشغيل النسخة وبدء جلسة المشاهدة تشترط job مراجعة معتمد وأصل Markdown مصرحاً بحقوقه داخل حزمة منشورة، وتغلق جلسة مشاهدة قائمة عند سحب الصلاحية قبل إضافة أي فترة جديدة. +- **لم يُتحقق تشغيلها بعد:** تشغيل HLS على جهاز حقيقي، صلاحيات المدرسة مقابل السوق، أحمال 100+ معلم، أو تزامن سحب أصل مع مشاهدة جارية. NP-05 لا تُعد مكتملة دون اختبار القائمة والصفحات على MySQL. + +### 2026-09-23 — إصلاح صفحات قائمة المعلمين (NP-05) + +- أزيل سقف 101 قبل فحص الوصول؛ استعلام المرشحين يعمل بدفعات 100 ضمن snapshot للطلب، ثم يعدّ الحصص المصرّح بها ويعيد الصفحة المطلوبة فقط. ترتيب التعادل يعتمد معرّف نسخة الفيديو، و`EXISTS` يحول دون تكرار الصف عند تعدد سجلات النشر أو المراجعة. +- تطبيق الطالب يطلب أول 20 حصة، يفتح الوحيدة مباشرة وفق `available_count`، ويتيح تحميل الدفعات التالية. أُزيل الفرز المحلي والوصف التعليمي المصطنع لأسلوب المعلم. لم يعد غياب علامة `hasVideo` القديمة يمنع الاستعلام عن درس له هوية منهجية. +- **فحوص منفذة:** PHP lint للمتحكم، `dart analyze` للملفات الثلاثة المعدلة (لا مشكلات)، و`git diff --check`. **غير منفذ:** اختبار قاعدة بيانات أو واجهة/شبكة فعلية؛ تبقى تكلفة المسح الكاملة لكل طلب بحاجة قياس عند كثافة المعلمين، وتبدل القائمة بين الطلبات يحتاج تحديثاً عند التفاعل. + +### 2026-09-23 — تدقيق حجز رصيد المعلم (NP-06) + +- كشف التدقيق أن معاملة `placeWithdrawalHold` القديمة لم تُسلسل الحجوزات لمعلم واحد: كان كل طلب يستطيع قراءة الرصيد قبل إدخال حجز الآخر. أضيف قفل `teachers ... FOR UPDATE` قبل أي قراءة ثابتة، مع إبقاء مفتاح الإعادة ضمن المعاملة نفسها. لا يُفتح مسار سحب حقيقي بهذا التعديل. +- `balance()` أصبحت قراءة صرفة ولا تنشئ حساباً؛ الحساب غير الموجود يساوي صفراً غير مستحق للسحب. أصلح طرح `BIGINT UNSIGNED` بصيغة `CAST(... AS SIGNED)`. سبب تحرير الحجز سيُحفظ بعد migration الجديدة؛ السجلات السابقة لا تُنسب لها أسباب مفترضة. +- **فحوص منفذة:** 9 حالات SQLite محلية معزولة لقراءة الرصيد الصرفة، صافي المدين/الدائن وخصم حجز موجود، حفظ سبب التحرير، منع التحرير لمعلم آخر، وتكراره؛ PHP lint و`git diff --check`. **غير منفذ:** إنشاء حجز موجب على MySQL staging واختبارات تزامن/فشل/استعادة؛ متغيرات قاعدة البيانات وأدوات MySQL/Docker غير متاحة في بيئة العمل الحالية. SQLite لا يثبت سلوك `FOR UPDATE`. لا تُعد NP-06 مقبولة قبل هذه الاختبارات وتسوية الأرصدة ودورة دفع مثبتة. + +### 2026-09-23 — فصل شجرة التحضير عن فهرس الطالب المنشور (NP-01/02) + +- أصبح `/api/curriculum/tree` عاماً للطالب لكنه يعيد فقط الدروس المطابقة بهويتها ومسارها إلى سجل منهجي معتمد وحزمة منشورة وأصل Markdown مراجع ومصرح بحقوقه. لا يمرر ملف manifest أو نتاجاته الأولية أو موارده غير المنشورة، ولا يعيد manifest عند خطأ قاعدة البيانات؛ يظهر `503` صريحاً. الفراغ المنشور يعود بكائن `{}`، لا مصفوفة ملتبسة. +- نُقل استعمال المعلم لفهرس التحضير إلى `/api/teacher/curriculum/intake-tree` خلف صلاحية المعلم، لتظل الدروس غير المنشورة قابلة للاختيار في سير التحضير دون إيهام الطالب بأنها جاهزة. مواد PDF وأوراق العمل في الشجرة العامة تأتي فقط من أصول ذات مراجعة وحقوق وحزمة منشورة، مع قيد الصف والمبحث معاً. +- لم تعد واجهة الطالب تعرض كل الصفوف K12 عند خلو النتيجة، ولا تحول الاستجابة المعطوبة إلى «لا محتوى». عُدلت حالة الفراغ والبحث لتصف ما ثبت فقط؛ قراءة فهرس التخزين لم تعد تنشئ ملف manifest أثناء GET. +- **فحوص منفذة:** 11 حالة خالصة لترشيح شجرة النشر (درسان بنفس المعرف/المسار بصفين، مورد غير منشور، درس غير منشور، تطابق هوية ونسخة المنهج وUUID الأصل)، PHP lint، `dart analyze` للملفات المعدلة، و`git diff --check`. **غير منفذ:** HTTP/MySQL على staging، فتح كتاب PDF فعلي، وحقوق/دقة المنهج الأكاديمية. + +### 2026-09-23 — سد مسار ملف الدرس الخام وضبط خدمة الأصل (NP-01/02) + +- قُصر `/api/curriculum/lesson` و`/api/curriculum/document` على إدارة المنهج؛ أزيلت محاولة الطالب جلب `file` الخام. ترسل شجرة النشر UUID أصل Markdown الأساسي، وتستخدمه واجهة «قراءة الدرس». غياب الملف لم يعد يعيد فقرة «محتوى معتمد» ثابتة. +- خدمة الأصل المنشور تتحقق من صف الطالب المسجل ومن أن الملف المحلّي هو التخزين المحدد ضمن جذر المنهج، وتطابق الحجم وSHA-256 قبل الإرسال، مع ترويسات منع التخزين المؤقت والـMIME sniffing. لم يعد البحث عن ملف بديل في مجلدات أخرى أو تحت جذر المشروع. +- حفظ مسودة Markdown يرفض المسار المتجاوز للجذر أو الكتابة على أصل مربوط بحزمة منشورة، ولم يعد يحدّث صفوف `lessons` ببحث `LIKE` قد يطال درساً آخر. رسالة الحفظ تصف «مسودة»، لا اعتماداً أكاديمياً. +- أُزيل JWT من رابط PDF الخارجي: بعد مصادقة الطالب يصدر الخادم تذكرة عشوائية محفوظة في Redis لمدة 120 ثانية، مقيدة بمعرّف أصل PDF والطالب. مسار التنزيل يعيد التحقق من نشر الأصل وسلامته وصف الطالب وحالة هويته عند كل طلب. التذكرة قابلة لإعادة الطلب أثناء عمرها القصير للسماح بطلبات قارئ PDF، وليست بديلاً عن جلسة عامة. +- **فحوص منفذة:** 10 حالات محلية معزولة للوصول/الملف والمسار، 11 حالة لترشيح الشجرة بعد إضافة UUID الأصل، PHP lint، `dart analyze`، و`git diff --check`. **غير منفذ:** اختبار HTTP/MySQL/Redis، انتهاء التذكرة أو محاولة استعمالها لأصل آخر على خادم فعلي، سحب أصل أثناء طلب جارٍ، واختبار PDF على جهاز. +- **فجوة أمن/تجربة مفتوحة:** رابط PDF يحمل الآن تذكرة قصيرة ومقيدة لا JWT، لكن نجاح عرض الصفحات المتعددة عبر المتصفح الخارجي لم يُختبر. لا يحمل مخطط الأصل نطاق مدرسة/tenant، لذلك تحقق الصف لا يثبت عزل أصول مدرسة عن أخرى. النسخة المحلية المحفوظة تحتاج سياسة انتهاء/سحب موثقة قبل اعتبارها cache إنتاجياً آمناً. + +### 2026-09-23 — ضبط وصول المدرسة والسوق وملف الطالب (NP-05/12) + +- أزيلت آلية `validateLessonAccess` التي كانت تحوّل صف الطالب المستقل تلقائياً عند قراءة حصة وتمنح الطالب المدرسي أي دورة مجاناً وتُنشئ `school_included` pass أثناء الطلب. صار الفحص قراءة صرفة: تطابق الصف، دورة منشورة، تطابق الدرس مع الدورة، وحجب الدورة المدرسية عن مدرسة أخرى أو مستقل. وصول طالب المدرسة لدورة مدرسته يتطلب انتساباً مؤكداً في `school_rosters`؛ الدورة العامة المدفوعة تتطلب تصريح سوق نشطاً، ولا يكفي `school_included` قديم. +- `POST /api/student/profile/update-grade` و`GET /api/student/profile/status` خلف دور الطالب. تغيير الصف يرفض طالب المدرسة والصف/المسار غير الصالح؛ قراءة الملف لا تكتب تصحيح صف تلقائي. أُغلق فرع تحديث ملف قديم كان يقبل JWT مباشرة لتغيير الرقم الوطني دون إثبات الهوية والجلسة النشطة؛ إكمال التسجيل الجديد يظل عبر `identity_token` المخصص. +- **فحوص منفذة:** 12 حالة SQLite محلية معزولة تشمل طالباً مستقلاً، مدرستين، كشفاً غير مؤكد، دورة عامة مجانية ومدفوعة، تصريحاً مدرسياً قديماً، صفاً خاطئاً، ودورة غير منشورة، مع التحقق من عدم تغير الصف أو عدد التصاريح. PHP lint و`git diff --check`. +- **غير منفذ:** MySQL/HTTP مع مدرستين وعقود حقيقية، تسوية التصاريح القديمة، تدقيق منافذ المعاينة، وإثبات أن كل دورة `school_id` مصرح بها تعاقدياً لتلك المدرسة. نتيجة الفحص المحلي لا تُعد إثبات عزل شامل للمنصة. + +### 2026-09-29 — تجربة الطالب على الخادم المباشر (NP-01/02/04) + +- شُغّل تطبيق الطالب على macOS واتصل بـ`saqel.intaleqapp.com`؛ أعاد API للمناهج 13 مبحثاً منشوراً. السبب الجذري لرابط التشغيل الخاطئ كان سكربت `link_legacy_math_videos.php`: يحقن مضيف `b-cdn.net` ويعلّم نسخاً منشورة من دون مهام مراجعة. السجلان الحاليان للدرس 1 و2 من هذا المصدر أعادا HTTP 403. ملفات HLS المقابلة على Cloudflare R2 تعيد HTTP 200، ومددها 778 و930 ثانية، ومفتاحا المنهج وعناوين الدرسين متطابقان مع الملفين المحليين على الخادم. لا توجد لهما مهام/أدلة مراجعة معتمدة. +- أُصلح إصدار تذاكر تنزيل PDF في الخادم: أضيف مسار POST لإصدار ticket وGET لتنزيل الأصل، خدمة Redis قصيرة العمر ومقيدة بالأصل والطالب، وتحقق صلاحية الصف وبصمة الملف في خدمة المحتوى. عولجت فجوة التوافق مع نسخة `PublishedContentService` المنشورة، ثم تحققت مسارات HTTP كـ401/403 دون تسجيل دخول ونجح إصدار تذكرة من التطبيق. عناوين الكتب أصبحت تميز الملفات المعروفة وتصرح بالفصل/الوحدة عند نقص اسم المصدر. +- أُنشئت نسختان بديلتان عبر `TeacherSubmissionService` الرسمي، ببصمة MP4 المطابقة للملفين على الخادم، وحُفظت حالتهما `review` مع `needs_evidence`. بقيت النسختان الحاليتان كما هما إلى أن تجتاز البدائل المراجعة. المدخلات الناقصة لكل مرشح: تفريغ مؤقت كامل، دليل بصري، وبيان تغطية. أُوقفت أداة الربط القديمة محلياً وعلى الخادم، كي لا تعيد نشر روابط Bunny أو تتجاوز الفحص. +- شُددت API الإنتاج: الشجرة وقائمة الحصص وendpoint التشغيل لا تعتبر الفيديو منشوراً إلا بعد موافقة مراجعة الفيديو وربطه بحزمة Markdown المنشورة وبصمتها. فذلك يحجب الإصدارات القديمة التي لا تملك أي سجل مراجعة، ويمنع اعتبار المرشحين الجدد منشورين قبل اكتمال الدليل. إعداد R2 يُحمّل من ملف البيئة الخارجي `/home/intaleqapp-saqel/.env` عبر bootstrap الإنتاج؛ فحص آمن للتهيئة أكد وجود account/access/secret/endpoint/bucket/public URL من دون طباعة قيمها. ملف `backend/.env` المحلي ليس مصدر إعداد الإنتاج. +- **الفحوص المنفذة:** بناء وتشغيل macOS debug، تحقق واجهة فشل التشغيل عبر CUA، `flutter analyze` للملفات السبعة (صفر errors؛ 20 info style diagnostics)، PHP lint لملفات التغيير، `git diff --check`، طلبات HTTP لقوائم HLS R2، وتسجيل المرشحين من خلال الخدمة الرسمية والتحقق من SHA256 وحالة مهمة الفحص. +- **المتبقي:** لا يمكن نشر المرشحين من دون الأدلة الزمنية والبصرية ومراجع أكاديمية بشرية؛ لم تُختلق هذه الأدلة. بعد مراجعتها، يلزم قرار اعتماد ثم نشر النسخة البديلة، وبعدها اختبار تشغيلها على الماك. اختبار فتح صفحات PDF متعددة وتدفق انتهاء تذكرة التنزيل يحتاجان تحققاً إضافياً على جهاز الطالب. تغييرات Flutter محلية ولم تُوزع كتطبيق إنتاج؛ تغييرات API وإيقاف السكربت نُشرت على الخادم. + +### 2026-09-30 — استكمال فحص الطالب على macOS (NP-01/02/04/05/11) + +- **بيئة الفحص:** macOS 26.0 على Apple Silicon، تشغيل `flutter run -d macos --no-pub` بالنسخة المحلية على API الإنتاج، بحساب طالب صف عاشر موجود مسبقاً على الجهاز. لا توجد كتابة في قاعدة الإنتاج أثناء هذا الفحص. +- **نتائج مرئية:** `/api/auth/me` أعاد 200؛ `/api/student/lessons` أعاد 200 وقائمة فارغة؛ `/api/curriculum/tree` أعاد 13 مبحثاً. واجهة الرياضيات تعرض دروس manifest إضافية لا تتوفر لها حصص، وبعض صفوف الدرس بلا `primary_lesson_asset_id`. فتح قارئ PDF لهذه الحالة أظهر «المورد لا يحمل معرّف أصل منشوراً صالحاً» وصفحة فارغة. هذا خلل عقد API/بيانات منشورة، وليس عطلاً في R2 أو عارض PDF نفسه. +- **نزاهة المختبرات:** الفحص وجد `previewModeEnabled=true` افتراضياً ومسار ربط بالعنوان/مفتاح الدرس، وأدى فعلياً إلى اقتراح مختبر «أوتار الدائرة والقطاعات» لدرس «حل معادلات خاصة». أُغلقت المعاينة في تطبيق الطالب؛ صار فتح مختبر الدرس يتطلب UUID منهجي مطابقاً ومختبراً يحمل إصدار حزمة منشورة، واختفى fallback الذي كان يفتح الأداة التخصصية عند خلو قائمة المختبرات. +- **إصلاح محلي غير منشور:** عقد `GET /api/curriculum/tree` المحلي صار يربط صفوف intake بمخرجات حزم منشورة وأصل Markdown معتمد ومصفّى الحقوق ويعيد `primary_lesson_asset_id`. استعلام `has_video` يطلب مراجعة معتمدة وبصمة Markdown مطابقة. الإنتاج ما زال على نسخة controller أقدم لا ترسل معرّف الأصل، وملف `PublishedCurriculumTreeFilter.php` غير موجود فيه؛ لا يجوز تبديل route إلى service الجديدة وحدها. هذا التعديل لم يُنشر بعد، ولا تعني بيانات الدرس المحلية أنها اجتازت المراجعة أو أن PDF كتاباً متاحاً. +- **الفحوص المنفذة:** `flutter run` macOS ناجح؛ مشي UI حتى رياضيات/درس/PDF وملاحظة الخطأ؛ `dart analyze` للملفات الأربع (صفر errors، ملاحظة lint قديمة واحدة)؛ PHP lint؛ `php backend/scripts/test_published_curriculum_tree_filter.php` نجح بـ11 check؛ `git diff --check`. +- **ترتيب المتابعة:** (1) تصحيح عقد `/api/curriculum/tree` على staging ثم نشره بعد التحقق، كي لا يعرض إلا دروس الحزم المنشورة ويرسل UUID الأصل. (2) نشر أو ربط مصادر PDF حقيقية بمراجعة وحقوق موثقتين ثم اختبار تذكرة التنزيل وفتح صفحات متعددة على macOS. (3) توفير أدلة مراجعة الفيديو للمرشحين R2 ثم مراجعة بشرية ونشر إصدار؛ بعده اختبار اختيار معلم واحد/متعدد وتشغيل/استئناف المشاهدة. (4) معالجة `/api/student/lessons` الفارغ: تقرير سليم بعد إزالة legacy بلا حصص معتمدة، لكنه يجعل لوحة اليوم خالية؛ يجب ربط كتالوج المنهج الجديد بالتطبيق أو تقديم تجربة واضحة للفهرس المنشور. (5) مراجعة مسارات الاختبار وبنك الأسئلة ودفتر الأخطاء والتحميل دون ادعاء إتقان أو نجاح عند غياب مصادر معتمدة. +- **غير مفحوص في هذه الجولة:** PDF قابل للتنزيل/متعدد الصفحات (الأصل غير متوفر في الاستجابة)، قائمة فيديو بعد المصادقة، تشغيل HLS داخل التطبيق بعد اعتماد نسخة، الاختبارات والدفتر والتقدم، قارئ الشاشة/VoiceOver، بناء release موقّع، تطبيق المعلم/الإدارة على macOS. +- **مسار Git والنشر:** فُحص مستودع الماك والسيرفر؛ كلاهما `main` عند `e8163fe`. نُفذ `git pull --ff-only origin main` على السيرفر بحساب مالك المستودع بعد رفض الكتابة بحساب SSH على `.git/FETCH_HEAD`؛ النتيجة `Already up to date`. لم يكن على origin commit أحدث كي يصلح الفهرس أو التطبيق. بقيت تعديلات وملفات تشغيل غير متتبعة موجودة على السيرفر، ولم تُمسح أو تُستبدل. تغييرات الماك الحالية غير مرفوعة؛ لذلك build الماك لا يحدّث الإنتاج وحده. +