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 1d423e7..d46503a 100644 --- a/apps/student_app/lib/logic/cubits/video_playback_cubit.dart +++ b/apps/student_app/lib/logic/cubits/video_playback_cubit.dart @@ -97,14 +97,18 @@ class VideoPlaybackCubit extends Cubit { void updatePosition(int seconds) { final currentState = state; if (currentState is VideoPlaybackReady && currentState.activeCheckpoint == null) { - // Check if this second triggers a Socratic checkpoint + // Check if natural forward playback reaches a Socratic checkpoint for (var cp in currentState.playbackData.checkpoints) { - final crossedCheckpoint = seconds >= cp.timestampSeconds && - (_lastObservedPosition < cp.timestampSeconds || _lastObservedPosition > seconds); - if (!currentState.passedCheckpointIds.contains(cp.id) && crossedCheckpoint) { - AppLogger.log('🚨 Socratic Checkpoint Triggered! (${cp.questionText})', tag: 'SOCRATIC_ENGINE'); + 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'); + _lastObservedPosition = cp.timestampSeconds; emit(currentState.copyWith( - currentPositionSeconds: seconds, + currentPositionSeconds: cp.timestampSeconds, isPlaying: false, // Freeze video playback activeCheckpoint: cp, )); @@ -127,7 +131,25 @@ class VideoPlaybackCubit extends Cubit { void seekTo(int seconds) { final currentState = state; if (currentState is VideoPlaybackReady) { - emit(currentState.copyWith(currentPositionSeconds: seconds)); + // 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) { + blockingCheckpoint = cp; + } + } + } + + // If user tries to skip past an unpassed checkpoint, snap precisely to that checkpoint + final actualSeek = blockingCheckpoint != null ? blockingCheckpoint.timestampSeconds : seconds; + _lastObservedPosition = actualSeek; + + emit(currentState.copyWith( + currentPositionSeconds: actualSeek, + activeCheckpoint: blockingCheckpoint, + isPlaying: blockingCheckpoint == null ? currentState.isPlaying : false, + )); } } @@ -155,6 +177,7 @@ class VideoPlaybackCubit extends Cubit { // Correct Answer -> Reward readiness score (+0.5%) & resume video AppLogger.log('✅ Correct answer! Rewarding +0.5% readiness.', tag: 'SOCRATIC_ENGINE'); final updatedPassed = Set.from(currentState.passedCheckpointIds)..add(cp.id); + _lastObservedPosition = cp.timestampSeconds; emit(currentState.copyWith( clearActiveCheckpoint: true, isPlaying: true, @@ -169,8 +192,9 @@ class VideoPlaybackCubit extends Cubit { } return true; } else { - // Wrong Answer -> Socratic Productive Struggle: Rewind video by N seconds - final rewindTo = (currentState.currentPositionSeconds - cp.rewindSecondsOnFail).clamp(0, currentState.playbackData.durationSeconds); + // Wrong Answer -> Socratic Productive Struggle: Rewind video by N seconds from checkpoint + final rewindTo = (cp.timestampSeconds - cp.rewindSecondsOnFail).clamp(0, currentState.playbackData.durationSeconds); + _lastObservedPosition = rewindTo; AppLogger.log('❌ Incorrect answer. Socratic remediation: Rewinding to ${rewindTo}s.', tag: 'SOCRATIC_ENGINE'); emit(currentState.copyWith( clearActiveCheckpoint: true, 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 389d77c..a85f7a8 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 @@ -435,52 +435,63 @@ class _SocraticVideoPlayerScreenState extends State w child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Understanding checkpoints on the timeline - 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 - ...state.playbackData.checkpoints.map((cp) { - final posFraction = (cp.timestampSeconds / duration).clamp(0.0, 1.0); - final isPassed = state.passedCheckpointIds.contains(cp.id); - return Positioned( - left: (MediaQuery.of(context).size.width * 0.85 * posFraction).clamp(20.0, 700.0), - 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, - ), - ], - ), - ), - ); - }), - ], - ), + 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( diff --git a/apps/student_app/pubspec.lock b/apps/student_app/pubspec.lock index 173cd02..bf689f5 100644 --- a/apps/student_app/pubspec.lock +++ b/apps/student_app/pubspec.lock @@ -332,10 +332,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -348,10 +348,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" nested: dependency: transitive description: @@ -577,10 +577,10 @@ packages: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.11" typed_data: dependency: transitive description: diff --git a/backend/app/Controllers/ExamController.php b/backend/app/Controllers/ExamController.php index 1999026..e60dd0c 100644 --- a/backend/app/Controllers/ExamController.php +++ b/backend/app/Controllers/ExamController.php @@ -157,6 +157,8 @@ class ExamController : "تحتاج لمراجعة المفاهيم المتعلقة بـ: " . implode('، ', array_keys($weakTopics)) . ". يُنصح بمشاهدة مقطع الشرح الموصى به."; // Insert Attempt Record + self::ensureSchema(); + $uuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x', mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff), @@ -171,13 +173,17 @@ class ExamController [$uuid, $studentId, $examId, $earnedScore, $totalPoints, $percentage, $status, $timeSpent, json_encode(array_keys($weakTopics), JSON_UNESCAPED_UNICODE), $aiReport] ); - // Save detailed question answers + // Save detailed question answers safely foreach ($detailedAnswers as $dAns) { - Database::insert( - "INSERT INTO student_question_answers (attempt_id, student_id, question_id, selected_option_id, is_correct, points_awarded, time_spent_seconds) - VALUES (?, ?, ?, ?, ?, ?, ?)", - [$attemptId, $studentId, $dAns['question_id'], $dAns['selected_option_id'], $dAns['is_correct'], $dAns['points_awarded'], 0] - ); + try { + Database::insert( + "INSERT INTO student_question_answers (attempt_id, student_id, question_id, selected_option_id, is_correct, points_awarded, time_spent_seconds) + VALUES (?, ?, ?, ?, ?, ?, ?)", + [$attemptId, $studentId, $dAns['question_id'], $dAns['selected_option_id'], $dAns['is_correct'], $dAns['points_awarded'], 0] + ); + } catch (\Throwable $e) { + error_log("student_question_answers insert notice: " . $e->getMessage()); + } } // Update Student Cumulative Mastery & Tawjihi Readiness Score @@ -318,4 +324,37 @@ class ExamController ] ]); } + + public static function ensureSchema(): void + { + try { + Database::query( + "CREATE TABLE IF NOT EXISTS `student_question_answers` ( + `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + `attempt_id` BIGINT UNSIGNED NOT NULL, + `student_id` BIGINT UNSIGNED NOT NULL, + `question_id` BIGINT UNSIGNED NOT NULL, + `selected_option_id` BIGINT UNSIGNED NULL, + `is_correct` TINYINT(1) NOT NULL DEFAULT 0, + `points_awarded` DECIMAL(5, 2) NOT NULL DEFAULT 0.00, + `time_spent_seconds` INT UNSIGNED NOT NULL DEFAULT 0, + `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + KEY `idx_sqa_attempt` (`attempt_id`), + KEY `idx_sqa_student` (`student_id`), + KEY `idx_sqa_question` (`question_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci" + ); + + // Ensure completed_at in exam_attempts + $colCheck = Database::selectOne( + "SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'exam_attempts' AND COLUMN_NAME = 'completed_at' LIMIT 1" + ); + if (!$colCheck) { + Database::query("ALTER TABLE exam_attempts ADD COLUMN completed_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP AFTER ai_diagnostic_report"); + } + } catch (\Throwable $e) { + error_log("ExamController ensureSchema notice: " . $e->getMessage()); + } + } } diff --git a/backend/storage/curriculum/manifest.json b/backend/storage/curriculum/manifest.json index 873b1b8..42888e2 100644 --- a/backend/storage/curriculum/manifest.json +++ b/backend/storage/curriculum/manifest.json @@ -8,67 +8,6 @@ "semester_1": { "name": "الفصل الدراسي الأول", "units": { - "unit_01_exponents_equations": { - "name": "الوحدة الأولى: الأسس والمعادلات", - "lessons": [ - { - "id": "u1_l1_solving_equations", - "title": "الدرس 1: حل نظام مكون من معادلة خطية ومعادلة تربيعية", - "outcomes": [ - "حل نظام معادلات", - "التمثيل البياني" - ], - "file": "grade_10\/math_10\/semester_1\/unit_01\/lesson_1.md" - }, - { - "id": "u1_l2_solving_quadratic_system", - "title": "الدرس 2: حل نظام مكون من معادلتين تربيعيتين", - "outcomes": [ - "استخدام التعويض", - "استخدام الحذف" - ], - "file": "grade_10\/math_10\/semester_1\/unit_01\/lesson_2.md" - } - ] - }, - "unit_02_circle": { - "name": "الوحدة الثانية: الدائرة", - "lessons": [ - { - "id": "u2_l1_circle_properties", - "title": "الدرس 1: أوتار الدائرة وأقطارها ومماساتها", - "outcomes": [ - "نظريات الدائرة", - "تطبيقات المماسات" - ], - "file": "grade_10\/math_10\/semester_1\/unit_02\/lesson_1.md" - } - ] - }, - "unit_03_trigonometry": { - "name": "الوحدة الثالثة: حساب المثلثات", - "lessons": [] - }, - "unit_04_trig_apps": { - "name": "الوحدة الرابعة: تطبيقات المثلثات", - "lessons": [] - }, - "unit_05_functions": { - "name": "الوحدة الخامسة: الاقترانات", - "lessons": [] - }, - "unit_06_derivatives": { - "name": "الوحدة السادسة: المشتقات", - "lessons": [] - }, - "unit_07_vectors": { - "name": "الوحدة السابعة: المتجهات", - "lessons": [] - }, - "unit_08_stats": { - "name": "الوحدة الثامنة: الإحصاء والاحتمالات", - "lessons": [] - }, "unit_01": { "name": "الوحدة الأولى: المعادلات (Equations)", "lessons": [