Files
saqel/apps/student_app/lib/logic/cubits/exam_cubit.dart
T

212 lines
6.4 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';
/// الحالات العامة للامتحان
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}) async {
emit(ExamLoading());
try {
ExamModel loadedExam;
if (initialExam != null && initialExam.questions.isNotEmpty) {
loadedExam = initialExam;
} else {
loadedExam = await _repository.getExamDetails(examId);
}
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()}'));
}
}
}