From 445a1aa0e3ab46f52afe7c60b3503362427c3406 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Thu, 3 Sep 2026 16:59:14 +0300 Subject: [PATCH] Fix Flutter exam parsing, make exams tab dynamic from API, and add intelligent exam resolution to submitExam --- .../lib/data/models/exam_model.dart | 58 ++++-- .../curriculum/subject_hub_screen.dart | 196 +++++++++++------- backend/app/Controllers/ExamController.php | 52 ++++- 3 files changed, 206 insertions(+), 100 deletions(-) diff --git a/apps/student_app/lib/data/models/exam_model.dart b/apps/student_app/lib/data/models/exam_model.dart index 98ab56f..f4a5eac 100644 --- a/apps/student_app/lib/data/models/exam_model.dart +++ b/apps/student_app/lib/data/models/exam_model.dart @@ -36,21 +36,33 @@ class ExamModel { } return ExamModel( - id: (json['id'] as num?)?.toInt() ?? 0, + id: _toInt(json['id']), uuid: json['uuid']?.toString() ?? '', - courseId: (json['course_id'] as num?)?.toInt() ?? 0, - lessonId: (json['lesson_id'] as num?)?.toInt(), + courseId: _toInt(json['course_id']), + lessonId: json['lesson_id'] != null ? _toInt(json['lesson_id']) : null, title: json['title']?.toString() ?? 'امتحان تشخيصي', description: json['description']?.toString() ?? '', - scope: json['scope']?.toString() ?? 'general', - passingPercentage: (json['passing_percentage'] as num?)?.toDouble() ?? 60.0, - durationMinutes: (json['duration_minutes'] as num?)?.toInt() ?? 20, - questionsCount: (json['questions_count'] as num?)?.toInt() ?? parsedQuestions.length, + scope: json['scope']?.toString() ?? 'unit_exam', + passingPercentage: _toDouble(json['passing_percentage'], 60.0), + durationMinutes: _toInt(json['duration_minutes'], 20), + questionsCount: _toInt(json['questions_count'], parsedQuestions.length), questions: parsedQuestions, ); } } +int _toInt(dynamic value, [int defaultValue = 0]) { + if (value == null) return defaultValue; + if (value is num) return value.toInt(); + return int.tryParse(value.toString()) ?? defaultValue; +} + +double _toDouble(dynamic value, [double defaultValue = 0.0]) { + if (value == null) return defaultValue; + if (value is num) return value.toDouble(); + return double.tryParse(value.toString()) ?? defaultValue; +} + class QuestionModel { final int id; final String questionText; @@ -82,10 +94,10 @@ class QuestionModel { } return QuestionModel( - id: (json['id'] as num?)?.toInt() ?? 0, + id: _toInt(json['id']), questionText: json['question_text']?.toString() ?? '', questionType: json['question_type']?.toString() ?? 'multiple_choice', - points: (json['points'] as num?)?.toInt() ?? 10, + points: _toInt(json['points'], 10), topicTag: json['topic_tag']?.toString() ?? 'المفاهيم العامة', explanationText: json['explanation_text']?.toString(), aiHint: json['ai_hint']?.toString(), @@ -109,10 +121,10 @@ class QuestionOptionModel { factory QuestionOptionModel.fromJson(Map json) { return QuestionOptionModel( - id: (json['id'] as num?)?.toInt() ?? 0, + id: _toInt(json['id']), optionText: json['option_text']?.toString() ?? '', - isCorrect: json['is_correct'] == 1 || json['is_correct'] == true, - sequenceOrder: (json['sequence_order'] as num?)?.toInt() ?? 1, + isCorrect: json['is_correct'] == 1 || json['is_correct'] == true || json['is_correct'] == '1', + sequenceOrder: _toInt(json['sequence_order'], 1), ); } } @@ -158,15 +170,15 @@ class ExamSubmissionResultModel { } return ExamSubmissionResultModel( - attemptId: (json['attempt_id'] as num?)?.toInt() ?? 0, - score: (json['score'] as num?)?.toInt() ?? 0, - totalScore: (json['total_score'] as num?)?.toInt() ?? 0, - percentage: (json['percentage'] as num?)?.toDouble() ?? 0.0, - passed: json['passed'] == true || json['passed'] == 1, - rewindSeconds: (json['rewind_seconds'] as num?)?.toInt() ?? 0, + attemptId: _toInt(json['attempt_id']), + score: _toInt(json['score']), + totalScore: _toInt(json['total_score'], 100), + percentage: _toDouble(json['percentage']), + passed: json['passed'] == true || json['passed'] == 1 || json['passed'] == '1', + rewindSeconds: _toInt(json['rewind_seconds']), aiDiagnosticReport: json['ai_diagnostic_report']?.toString() ?? '', weakTopics: parsedTopics, - tawjihiReadinessScore: (json['tawjihi_readiness_score'] as num?)?.toDouble() ?? 0.0, + tawjihiReadinessScore: _toDouble(json['tawjihi_readiness_score'], 50.0), detailedAnswers: parsedDetails, ); } @@ -191,10 +203,10 @@ class DetailedAnswerModel { factory DetailedAnswerModel.fromJson(Map json) { return DetailedAnswerModel( - questionId: (json['question_id'] as num?)?.toInt() ?? 0, - selectedOptionId: (json['selected_option_id'] as num?)?.toInt() ?? 0, - isCorrect: json['is_correct'] == 1 || json['is_correct'] == true, - pointsAwarded: (json['points_awarded'] as num?)?.toInt() ?? 0, + questionId: _toInt(json['question_id']), + selectedOptionId: _toInt(json['selected_option_id']), + isCorrect: json['is_correct'] == 1 || json['is_correct'] == true || json['is_correct'] == '1', + pointsAwarded: _toInt(json['points_awarded']), explanation: json['explanation']?.toString(), aiHint: json['ai_hint']?.toString(), ); 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 788989b..01149bb 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 @@ -21,6 +21,8 @@ import '../../widgets/luxury_widgets.dart'; import '../player/socratic_video_player_screen.dart'; import '../exams/adaptive_exam_screen.dart'; import 'curriculum_document_viewer_screen.dart'; +import '../../../data/models/exam_model.dart'; +import '../../../data/repositories/app_repositories.dart'; /// الشاشة المركزية للمادة الدراسية وبوابات الدروس والامتحانات والمصادر class SubjectHubScreen extends StatefulWidget { @@ -34,11 +36,13 @@ class SubjectHubScreen extends StatefulWidget { class _SubjectHubScreenState extends State with SingleTickerProviderStateMixin { late TabController _tabController; + late Future> _examsFuture; @override void initState() { super.initState(); _tabController = TabController(length: 4, vsync: this); + _examsFuture = ExamRepository().getExams(courseId: 1, scope: 'unit_exam'); } @override @@ -272,88 +276,128 @@ class _SubjectHubScreenState extends State with SingleTickerPr /// Tab 3: Question Bank & Adaptive Unit Exams Widget _buildExamsTab(BuildContext context) { - final exams = [ - const SubjectExamModel(id: 'ex1', title: 'اختبار الفهم الشامل: الوحدة الأولى (أنظمة المعادلات)', questionsCount: 15, durationMinutes: 45, targetScore: 100), - const SubjectExamModel(id: 'ex2', title: 'نماذج أسئلة الوزارة للسنوات السابقة', questionsCount: 15, durationMinutes: 45, targetScore: 100), - const SubjectExamModel(id: 'ex3', title: 'اختبار تشخيص الثغرات التكيفي بالذكاء الاصطناعي', questionsCount: 15, durationMinutes: 30, targetScore: 100), - ]; + return FutureBuilder>( + future: _examsFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center( + child: CupertinoActivityIndicator(color: AppColors.saqelCyan), + ); + } - return ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20), - itemCount: exams.length, - itemBuilder: (context, idx) { - final exam = exams[idx]; - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: LuxuryCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - exam.title, - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 14), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: AppColors.emeraldGreen.withAlpha(25), - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - 'جاهز للتقديم ✍️', - style: TextStyle(color: AppColors.emeraldGreen, fontSize: 11, fontWeight: FontWeight.w700), - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - const Icon(CupertinoIcons.question_circle, color: AppColors.textSecondaryDark, size: 14), - const SizedBox(width: 4), - Text('${exam.questionsCount} أسئلة', style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 12)), - const SizedBox(width: 12), - const Icon(CupertinoIcons.time, color: AppColors.textSecondaryDark, size: 14), - const SizedBox(width: 4), - Text('${exam.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)), - ), - onPressed: () { - Navigator.of(context).push( - CupertinoPageRoute( - builder: (context) => AdaptiveExamScreen( - examId: idx + 1, - title: exam.title, - subjectTitle: widget.subject.title, - ), - ), - ); - }, - child: const Text('بدء الاختبار 🚀', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700)), - ), - ], - ), - ], - ), - ), + final liveExams = snapshot.data ?? []; + if (liveExams.isEmpty) { + // Default to Primary Unit Exam if none fetched yet + return ListView( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20), + children: [ + _buildExamCard( + context, + examId: 1, + title: 'اختبار الفهم الشامل: الوحدة الأولى (أنظمة المعادلات)', + questionsCount: 50, + durationMinutes: 45, + ), + ], + ); + } + + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20), + itemCount: liveExams.length, + itemBuilder: (context, idx) { + final exam = liveExams[idx]; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _buildExamCard( + context, + examId: exam.id > 0 ? exam.id : 1, + title: exam.title, + questionsCount: exam.questionsCount > 0 ? exam.questionsCount : 50, + durationMinutes: exam.durationMinutes > 0 ? exam.durationMinutes : 45, + ), + ); + }, ); }, ); } + Widget _buildExamCard( + BuildContext context, { + required int examId, + required String title, + required int questionsCount, + required int durationMinutes, + }) { + return LuxuryCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + title, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 14), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: AppColors.emeraldGreen.withAlpha(25), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'جاهز للتقديم ✍️', + style: TextStyle(color: AppColors.emeraldGreen, fontSize: 11, fontWeight: FontWeight.w700), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon(CupertinoIcons.question_circle, color: AppColors.textSecondaryDark, size: 14), + const SizedBox(width: 4), + Text('$questionsCount سؤالاً في البنك', style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 12)), + const SizedBox(width: 12), + const Icon(CupertinoIcons.time, color: AppColors.textSecondaryDark, size: 14), + const SizedBox(width: 4), + 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)), + ), + onPressed: () { + Navigator.of(context).push( + CupertinoPageRoute( + builder: (context) => AdaptiveExamScreen( + examId: examId, + title: title, + subjectTitle: widget.subject.title, + ), + ), + ); + }, + child: const Text('بدء الاختبار 🚀', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700)), + ), + ], + ), + ], + ), + ); + } + /// Tab 4: Official Ministry Textbooks Widget _buildTextbooksTab(BuildContext context) { final textbooks = widget.subject.textbooks.isNotEmpty diff --git a/backend/app/Controllers/ExamController.php b/backend/app/Controllers/ExamController.php index 3daecc8..faf4377 100644 --- a/backend/app/Controllers/ExamController.php +++ b/backend/app/Controllers/ExamController.php @@ -88,7 +88,21 @@ class ExamController $isTeacher = ($request->role === 'teacher' || $request->role === 'super_admin'); $exam = Database::selectOne("SELECT * FROM exams WHERE id = ? LIMIT 1", [$examId]); - if (!$exam || $examId === 1 || (str_contains($exam['title'] ?? '', 'الوحدة الأولى') && count(Database::select("SELECT id FROM questions WHERE exam_id = ?", [$examId])) < 15)) { + if (!$exam) { + // Fallback: Find published unit exam with questions + $exam = Database::selectOne( + "SELECT e.* FROM exams e + JOIN questions q ON q.exam_id = e.id + WHERE e.is_published = 1 AND (e.scope = 'unit_exam' OR e.scope = 'unit_comprehensive') + GROUP BY e.id HAVING COUNT(q.id) >= 10 + ORDER BY e.id DESC LIMIT 1" + ); + if ($exam) { + $examId = (int)$exam['id']; + } + } + + if (!$exam || (count(Database::select("SELECT id FROM questions WHERE exam_id = ?", [$examId])) < 15)) { $examId = self::seedUnit1ComprehensiveExam(); $exam = Database::selectOne("SELECT * FROM exams WHERE id = ? LIMIT 1", [$examId]); } @@ -194,6 +208,42 @@ class ExamController $timeSpent = (int)($body['time_spent_seconds'] ?? 0); $exam = Database::selectOne("SELECT * FROM exams WHERE id = ? LIMIT 1", [$examId]); + if (!$exam) { + // 1. Resolve exam from submitted question IDs + if (!empty($answers) && is_array($answers)) { + $firstQId = (int)($answers[0]['question_id'] ?? 0); + if ($firstQId > 0) { + $qRow = Database::selectOne("SELECT exam_id FROM questions WHERE id = ? LIMIT 1", [$firstQId]); + if ($qRow && !empty($qRow['exam_id'])) { + $examId = (int)$qRow['exam_id']; + $exam = Database::selectOne("SELECT * FROM exams WHERE id = ? LIMIT 1", [$examId]); + } + } + } + + // 2. Resolve to latest published unit exam with questions + if (!$exam) { + $exam = Database::selectOne( + "SELECT e.* FROM exams e + JOIN questions q ON q.exam_id = e.id + WHERE e.is_published = 1 AND (e.scope = 'unit_exam' OR e.scope = 'unit_comprehensive') + GROUP BY e.id HAVING COUNT(q.id) >= 10 + ORDER BY e.id DESC LIMIT 1" + ); + if ($exam) { + $examId = (int)$exam['id']; + } + } + + // 3. Fallback to any published exam + if (!$exam) { + $exam = Database::selectOne("SELECT * FROM exams WHERE is_published = 1 ORDER BY id DESC LIMIT 1"); + if ($exam) { + $examId = (int)$exam['id']; + } + } + } + if (!$exam) { $response->status(404)->json(['status' => 'error', 'message' => 'الامتحان غير موجود']); return;