Fix Flutter exam parsing, make exams tab dynamic from API, and add intelligent exam resolution to submitExam

This commit is contained in:
Hamza-Ayed
2026-09-03 16:59:14 +03:00
parent 0a61e32033
commit 445a1aa0e3
3 changed files with 206 additions and 100 deletions
@@ -36,21 +36,33 @@ class ExamModel {
} }
return ExamModel( return ExamModel(
id: (json['id'] as num?)?.toInt() ?? 0, id: _toInt(json['id']),
uuid: json['uuid']?.toString() ?? '', uuid: json['uuid']?.toString() ?? '',
courseId: (json['course_id'] as num?)?.toInt() ?? 0, courseId: _toInt(json['course_id']),
lessonId: (json['lesson_id'] as num?)?.toInt(), lessonId: json['lesson_id'] != null ? _toInt(json['lesson_id']) : null,
title: json['title']?.toString() ?? 'امتحان تشخيصي', title: json['title']?.toString() ?? 'امتحان تشخيصي',
description: json['description']?.toString() ?? '', description: json['description']?.toString() ?? '',
scope: json['scope']?.toString() ?? 'general', scope: json['scope']?.toString() ?? 'unit_exam',
passingPercentage: (json['passing_percentage'] as num?)?.toDouble() ?? 60.0, passingPercentage: _toDouble(json['passing_percentage'], 60.0),
durationMinutes: (json['duration_minutes'] as num?)?.toInt() ?? 20, durationMinutes: _toInt(json['duration_minutes'], 20),
questionsCount: (json['questions_count'] as num?)?.toInt() ?? parsedQuestions.length, questionsCount: _toInt(json['questions_count'], parsedQuestions.length),
questions: parsedQuestions, 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 { class QuestionModel {
final int id; final int id;
final String questionText; final String questionText;
@@ -82,10 +94,10 @@ class QuestionModel {
} }
return QuestionModel( return QuestionModel(
id: (json['id'] as num?)?.toInt() ?? 0, id: _toInt(json['id']),
questionText: json['question_text']?.toString() ?? '', questionText: json['question_text']?.toString() ?? '',
questionType: json['question_type']?.toString() ?? 'multiple_choice', questionType: json['question_type']?.toString() ?? 'multiple_choice',
points: (json['points'] as num?)?.toInt() ?? 10, points: _toInt(json['points'], 10),
topicTag: json['topic_tag']?.toString() ?? 'المفاهيم العامة', topicTag: json['topic_tag']?.toString() ?? 'المفاهيم العامة',
explanationText: json['explanation_text']?.toString(), explanationText: json['explanation_text']?.toString(),
aiHint: json['ai_hint']?.toString(), aiHint: json['ai_hint']?.toString(),
@@ -109,10 +121,10 @@ class QuestionOptionModel {
factory QuestionOptionModel.fromJson(Map<String, dynamic> json) { factory QuestionOptionModel.fromJson(Map<String, dynamic> json) {
return QuestionOptionModel( return QuestionOptionModel(
id: (json['id'] as num?)?.toInt() ?? 0, id: _toInt(json['id']),
optionText: json['option_text']?.toString() ?? '', optionText: json['option_text']?.toString() ?? '',
isCorrect: json['is_correct'] == 1 || json['is_correct'] == true, isCorrect: json['is_correct'] == 1 || json['is_correct'] == true || json['is_correct'] == '1',
sequenceOrder: (json['sequence_order'] as num?)?.toInt() ?? 1, sequenceOrder: _toInt(json['sequence_order'], 1),
); );
} }
} }
@@ -158,15 +170,15 @@ class ExamSubmissionResultModel {
} }
return ExamSubmissionResultModel( return ExamSubmissionResultModel(
attemptId: (json['attempt_id'] as num?)?.toInt() ?? 0, attemptId: _toInt(json['attempt_id']),
score: (json['score'] as num?)?.toInt() ?? 0, score: _toInt(json['score']),
totalScore: (json['total_score'] as num?)?.toInt() ?? 0, totalScore: _toInt(json['total_score'], 100),
percentage: (json['percentage'] as num?)?.toDouble() ?? 0.0, percentage: _toDouble(json['percentage']),
passed: json['passed'] == true || json['passed'] == 1, passed: json['passed'] == true || json['passed'] == 1 || json['passed'] == '1',
rewindSeconds: (json['rewind_seconds'] as num?)?.toInt() ?? 0, rewindSeconds: _toInt(json['rewind_seconds']),
aiDiagnosticReport: json['ai_diagnostic_report']?.toString() ?? '', aiDiagnosticReport: json['ai_diagnostic_report']?.toString() ?? '',
weakTopics: parsedTopics, weakTopics: parsedTopics,
tawjihiReadinessScore: (json['tawjihi_readiness_score'] as num?)?.toDouble() ?? 0.0, tawjihiReadinessScore: _toDouble(json['tawjihi_readiness_score'], 50.0),
detailedAnswers: parsedDetails, detailedAnswers: parsedDetails,
); );
} }
@@ -191,10 +203,10 @@ class DetailedAnswerModel {
factory DetailedAnswerModel.fromJson(Map<String, dynamic> json) { factory DetailedAnswerModel.fromJson(Map<String, dynamic> json) {
return DetailedAnswerModel( return DetailedAnswerModel(
questionId: (json['question_id'] as num?)?.toInt() ?? 0, questionId: _toInt(json['question_id']),
selectedOptionId: (json['selected_option_id'] as num?)?.toInt() ?? 0, selectedOptionId: _toInt(json['selected_option_id']),
isCorrect: json['is_correct'] == 1 || json['is_correct'] == true, isCorrect: json['is_correct'] == 1 || json['is_correct'] == true || json['is_correct'] == '1',
pointsAwarded: (json['points_awarded'] as num?)?.toInt() ?? 0, pointsAwarded: _toInt(json['points_awarded']),
explanation: json['explanation']?.toString(), explanation: json['explanation']?.toString(),
aiHint: json['ai_hint']?.toString(), aiHint: json['ai_hint']?.toString(),
); );
@@ -21,6 +21,8 @@ import '../../widgets/luxury_widgets.dart';
import '../player/socratic_video_player_screen.dart'; import '../player/socratic_video_player_screen.dart';
import '../exams/adaptive_exam_screen.dart'; import '../exams/adaptive_exam_screen.dart';
import 'curriculum_document_viewer_screen.dart'; import 'curriculum_document_viewer_screen.dart';
import '../../../data/models/exam_model.dart';
import '../../../data/repositories/app_repositories.dart';
/// الشاشة المركزية للمادة الدراسية وبوابات الدروس والامتحانات والمصادر /// الشاشة المركزية للمادة الدراسية وبوابات الدروس والامتحانات والمصادر
class SubjectHubScreen extends StatefulWidget { class SubjectHubScreen extends StatefulWidget {
@@ -34,11 +36,13 @@ class SubjectHubScreen extends StatefulWidget {
class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerProviderStateMixin { class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
late Future<List<ExamModel>> _examsFuture;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_tabController = TabController(length: 4, vsync: this); _tabController = TabController(length: 4, vsync: this);
_examsFuture = ExamRepository().getExams(courseId: 1, scope: 'unit_exam');
} }
@override @override
@@ -272,88 +276,128 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
/// Tab 3: Question Bank & Adaptive Unit Exams /// Tab 3: Question Bank & Adaptive Unit Exams
Widget _buildExamsTab(BuildContext context) { Widget _buildExamsTab(BuildContext context) {
final exams = [ return FutureBuilder<List<ExamModel>>(
const SubjectExamModel(id: 'ex1', title: 'اختبار الفهم الشامل: الوحدة الأولى (أنظمة المعادلات)', questionsCount: 15, durationMinutes: 45, targetScore: 100), future: _examsFuture,
const SubjectExamModel(id: 'ex2', title: 'نماذج أسئلة الوزارة للسنوات السابقة', questionsCount: 15, durationMinutes: 45, targetScore: 100), builder: (context, snapshot) {
const SubjectExamModel(id: 'ex3', title: 'اختبار تشخيص الثغرات التكيفي بالذكاء الاصطناعي', questionsCount: 15, durationMinutes: 30, targetScore: 100), if (snapshot.connectionState == ConnectionState.waiting) {
]; return const Center(
child: CupertinoActivityIndicator(color: AppColors.saqelCyan),
);
}
return ListView.builder( final liveExams = snapshot.data ?? [];
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20), if (liveExams.isEmpty) {
itemCount: exams.length, // Default to Primary Unit Exam if none fetched yet
itemBuilder: (context, idx) { return ListView(
final exam = exams[idx]; padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20),
return Padding( children: [
padding: const EdgeInsets.only(bottom: 12), _buildExamCard(
child: LuxuryCard( context,
child: Column( examId: 1,
crossAxisAlignment: CrossAxisAlignment.start, title: 'اختبار الفهم الشامل: الوحدة الأولى (أنظمة المعادلات)',
children: [ questionsCount: 50,
Row( durationMinutes: 45,
mainAxisAlignment: MainAxisAlignment.spaceBetween, ),
children: [ ],
Text( );
exam.title, }
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 14),
), return ListView.builder(
Container( padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), itemCount: liveExams.length,
decoration: BoxDecoration( itemBuilder: (context, idx) {
color: AppColors.emeraldGreen.withAlpha(25), final exam = liveExams[idx];
borderRadius: BorderRadius.circular(8), return Padding(
), padding: const EdgeInsets.only(bottom: 12),
child: const Text( child: _buildExamCard(
'جاهز للتقديم ✍️', context,
style: TextStyle(color: AppColors.emeraldGreen, fontSize: 11, fontWeight: FontWeight.w700), examId: exam.id > 0 ? exam.id : 1,
), title: exam.title,
), questionsCount: exam.questionsCount > 0 ? exam.questionsCount : 50,
], durationMinutes: exam.durationMinutes > 0 ? exam.durationMinutes : 45,
), ),
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)),
),
],
),
],
),
),
); );
}, },
); );
} }
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 /// Tab 4: Official Ministry Textbooks
Widget _buildTextbooksTab(BuildContext context) { Widget _buildTextbooksTab(BuildContext context) {
final textbooks = widget.subject.textbooks.isNotEmpty final textbooks = widget.subject.textbooks.isNotEmpty
+51 -1
View File
@@ -88,7 +88,21 @@ class ExamController
$isTeacher = ($request->role === 'teacher' || $request->role === 'super_admin'); $isTeacher = ($request->role === 'teacher' || $request->role === 'super_admin');
$exam = Database::selectOne("SELECT * FROM exams WHERE id = ? LIMIT 1", [$examId]); $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(); $examId = self::seedUnit1ComprehensiveExam();
$exam = Database::selectOne("SELECT * FROM exams WHERE id = ? LIMIT 1", [$examId]); $exam = Database::selectOne("SELECT * FROM exams WHERE id = ? LIMIT 1", [$examId]);
} }
@@ -194,6 +208,42 @@ class ExamController
$timeSpent = (int)($body['time_spent_seconds'] ?? 0); $timeSpent = (int)($body['time_spent_seconds'] ?? 0);
$exam = Database::selectOne("SELECT * FROM exams WHERE id = ? LIMIT 1", [$examId]); $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) { if (!$exam) {
$response->status(404)->json(['status' => 'error', 'message' => 'الامتحان غير موجود']); $response->status(404)->json(['status' => 'error', 'message' => 'الامتحان غير موجود']);
return; return;