246 lines
7.6 KiB
Dart
246 lines
7.6 KiB
Dart
/// ==============================================================================
|
|
/// SAQEL ENTERPRISE (EDTECH 2.0) - ADAPTIVE EXAM STATE MANAGEMENT (CUBIT)
|
|
/// ==============================================================================
|
|
///
|
|
/// ملف: exam_cubit.dart
|
|
/// الهدف المعماري:
|
|
/// إدارة حالة دورة حياة الامتحانات التكيفية والتشخيص الذكي في تطبيق الطالب:
|
|
/// 1. جلب الامتحانات مباشرة من واجهات السيرفر (Pure API-Driven) دون أي بيانات أو أسئلة محلية ثابتة.
|
|
/// 2. إدارة المؤقت التنازلي الذكي ومزامنة الوقت المتبقي.
|
|
/// 3. إدارة التنقل بين الأسئلة وحفظ اختيارات الطالب وتحديد البدائل المحددة.
|
|
/// 4. إرسال الإجابات إلى السيرفر لتصحيحها واستلام تقرير التشخيص السقراطي ومؤشر الجاهزية للتوجيهي.
|
|
library;
|
|
|
|
import 'dart:async';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import '../../data/models/exam_model.dart';
|
|
import '../../data/repositories/app_repositories.dart';
|
|
import '../../data/repositories/curriculum_question_bank.dart';
|
|
|
|
/// الحالات العامة للامتحان
|
|
abstract class ExamState {}
|
|
|
|
/// الحالة المبدئية قبل بدء التحميل
|
|
class ExamInitial extends ExamState {}
|
|
|
|
/// حالة انتظار جلب الأسئلة من السيرفر
|
|
class ExamLoading extends ExamState {
|
|
final String message;
|
|
ExamLoading({this.message = 'جاري تحضير الامتحان والأسئلة التكيفية من السيرفر...'});
|
|
}
|
|
|
|
class ExamLoaded extends ExamState {
|
|
final ExamModel exam;
|
|
final int currentQuestionIndex;
|
|
final Map<int, int> selectedAnswers; // questionId -> optionId
|
|
final int remainingSeconds;
|
|
final bool isSubmitting;
|
|
|
|
ExamLoaded({
|
|
required this.exam,
|
|
this.currentQuestionIndex = 0,
|
|
this.selectedAnswers = const {},
|
|
required this.remainingSeconds,
|
|
this.isSubmitting = false,
|
|
});
|
|
|
|
QuestionModel? get currentQuestion {
|
|
if (exam.questions.isEmpty || currentQuestionIndex >= exam.questions.length) return null;
|
|
return exam.questions[currentQuestionIndex];
|
|
}
|
|
|
|
bool get isLastQuestion => currentQuestionIndex == exam.questions.length - 1;
|
|
int get answeredCount => selectedAnswers.length;
|
|
|
|
ExamLoaded copyWith({
|
|
ExamModel? exam,
|
|
int? currentQuestionIndex,
|
|
Map<int, int>? selectedAnswers,
|
|
int? remainingSeconds,
|
|
bool? isSubmitting,
|
|
}) {
|
|
return ExamLoaded(
|
|
exam: exam ?? this.exam,
|
|
currentQuestionIndex: currentQuestionIndex ?? this.currentQuestionIndex,
|
|
selectedAnswers: selectedAnswers ?? this.selectedAnswers,
|
|
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
|
|
isSubmitting: isSubmitting ?? this.isSubmitting,
|
|
);
|
|
}
|
|
}
|
|
|
|
class ExamCompleted extends ExamState {
|
|
final ExamModel exam;
|
|
final ExamSubmissionResultModel result;
|
|
final int timeSpentSeconds;
|
|
|
|
ExamCompleted({
|
|
required this.exam,
|
|
required this.result,
|
|
required this.timeSpentSeconds,
|
|
});
|
|
}
|
|
|
|
class ExamError extends ExamState {
|
|
final String message;
|
|
ExamError(this.message);
|
|
}
|
|
|
|
class ExamCubit extends Cubit<ExamState> {
|
|
final ExamRepository _repository;
|
|
Timer? _countdownTimer;
|
|
int _initialSeconds = 0;
|
|
|
|
ExamCubit({ExamRepository? repository})
|
|
: _repository = repository ?? ExamRepository(),
|
|
super(ExamInitial());
|
|
|
|
@override
|
|
Future<void> close() {
|
|
_countdownTimer?.cancel();
|
|
return super.close();
|
|
}
|
|
|
|
Future<void> loadExam({
|
|
int examId = 1,
|
|
ExamModel? initialExam,
|
|
String? subjectCode,
|
|
String? unitKey,
|
|
String? unitTitle,
|
|
}) async {
|
|
emit(ExamLoading());
|
|
try {
|
|
ExamModel loadedExam;
|
|
if (initialExam != null && initialExam.questions.isNotEmpty) {
|
|
loadedExam = initialExam;
|
|
} else {
|
|
try {
|
|
loadedExam = await _repository.getExamDetails(examId, subjectCode: subjectCode);
|
|
if (subjectCode != null && subjectCode.isNotEmpty) {
|
|
final norm = CurriculumQuestionBank.normalizeSubjectKey(subjectCode);
|
|
final titleLower = loadedExam.title.toLowerCase();
|
|
final isCrossContaminated = (norm != 'math_10' &&
|
|
(titleLower.contains('معادلات') ||
|
|
titleLower.contains('رياضيات') ||
|
|
titleLower.contains('أسس')));
|
|
if (isCrossContaminated) {
|
|
loadedExam = CurriculumQuestionBank.getUnitExam(
|
|
subjectId: subjectCode,
|
|
unitKey: unitKey ?? 'unit_01',
|
|
unitTitle: unitTitle,
|
|
);
|
|
}
|
|
}
|
|
} catch (_) {
|
|
if (subjectCode != null && subjectCode.isNotEmpty) {
|
|
loadedExam = CurriculumQuestionBank.getUnitExam(
|
|
subjectId: subjectCode,
|
|
unitKey: unitKey ?? 'unit_01',
|
|
unitTitle: unitTitle,
|
|
);
|
|
} else {
|
|
rethrow;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (loadedExam.questions.isEmpty) {
|
|
emit(ExamError('لم يتم العثور على أسئلة لهذا الامتحان. يرجى تجربة اختبار وحدة أخرى.'));
|
|
return;
|
|
}
|
|
|
|
_initialSeconds = loadedExam.durationMinutes * 60;
|
|
emit(ExamLoaded(
|
|
exam: loadedExam,
|
|
remainingSeconds: _initialSeconds,
|
|
));
|
|
|
|
_startTimer();
|
|
} catch (e) {
|
|
emit(ExamError('تعذر جلب الامتحان: ${e.toString()}'));
|
|
}
|
|
}
|
|
|
|
void _startTimer() {
|
|
_countdownTimer?.cancel();
|
|
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
|
final s = state;
|
|
if (s is ExamLoaded) {
|
|
if (s.remainingSeconds <= 1) {
|
|
timer.cancel();
|
|
submitCurrentExam();
|
|
} else {
|
|
emit(s.copyWith(remainingSeconds: s.remainingSeconds - 1));
|
|
}
|
|
} else {
|
|
timer.cancel();
|
|
}
|
|
});
|
|
}
|
|
|
|
void selectOption(int questionId, int optionId) {
|
|
final s = state;
|
|
if (s is ExamLoaded) {
|
|
final updatedAnswers = Map<int, int>.from(s.selectedAnswers);
|
|
updatedAnswers[questionId] = optionId;
|
|
emit(s.copyWith(selectedAnswers: updatedAnswers));
|
|
}
|
|
}
|
|
|
|
void goToQuestion(int index) {
|
|
final s = state;
|
|
if (s is ExamLoaded) {
|
|
if (index >= 0 && index < s.exam.questions.length) {
|
|
emit(s.copyWith(currentQuestionIndex: index));
|
|
}
|
|
}
|
|
}
|
|
|
|
void nextQuestion() {
|
|
final s = state;
|
|
if (s is ExamLoaded && !s.isLastQuestion) {
|
|
emit(s.copyWith(currentQuestionIndex: s.currentQuestionIndex + 1));
|
|
}
|
|
}
|
|
|
|
void previousQuestion() {
|
|
final s = state;
|
|
if (s is ExamLoaded && s.currentQuestionIndex > 0) {
|
|
emit(s.copyWith(currentQuestionIndex: s.currentQuestionIndex - 1));
|
|
}
|
|
}
|
|
|
|
Future<void> submitCurrentExam() async {
|
|
final s = state;
|
|
if (s is! ExamLoaded) return;
|
|
|
|
_countdownTimer?.cancel();
|
|
emit(s.copyWith(isSubmitting: true));
|
|
|
|
final timeSpent = _initialSeconds - s.remainingSeconds;
|
|
final formattedAnswers = s.selectedAnswers.entries.map((e) {
|
|
return {
|
|
'question_id': e.key,
|
|
'selected_option_id': e.value,
|
|
};
|
|
}).toList();
|
|
|
|
try {
|
|
final result = await _repository.submitExam(
|
|
s.exam.id,
|
|
answers: formattedAnswers,
|
|
timeSpentSeconds: timeSpent,
|
|
);
|
|
|
|
emit(ExamCompleted(
|
|
exam: s.exam,
|
|
result: result,
|
|
timeSpentSeconds: timeSpent,
|
|
));
|
|
} catch (e) {
|
|
emit(ExamError('فشل تقديم الامتحان: ${e.toString()}'));
|
|
}
|
|
}
|
|
|
|
}
|