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

257 lines
7.5 KiB
Dart

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 {
ExamSubmissionResultModel result;
try {
result = await _repository.submitExam(
s.exam.id,
answers: formattedAnswers,
timeSpentSeconds: timeSpent,
);
} catch (_) {
// Safe offline evaluation fallback if API token not present
result = _evaluateLocally(s.exam, s.selectedAnswers);
}
emit(ExamCompleted(
exam: s.exam,
result: result,
timeSpentSeconds: timeSpent,
));
} catch (e) {
emit(ExamError('فشل تقديم الامتحان: ${e.toString()}'));
}
}
ExamSubmissionResultModel _evaluateLocally(ExamModel exam, Map<int, int> selectedAnswers) {
int earnedScore = 0;
int totalScore = 0;
List<String> weakTopics = [];
List<DetailedAnswerModel> detailedAnswers = [];
for (var q in exam.questions) {
totalScore += q.points;
final selectedOptId = selectedAnswers[q.id];
QuestionOptionModel? correctOption;
try {
correctOption = q.options.firstWhere((opt) => opt.isCorrect);
} catch (_) {
correctOption = q.options.isNotEmpty ? q.options.first : null;
}
final isCorrect = selectedOptId != null && correctOption != null && selectedOptId == correctOption.id;
final points = isCorrect ? q.points : 0;
earnedScore += points;
if (!isCorrect) {
if (!weakTopics.contains(q.topicTag)) {
weakTopics.add(q.topicTag);
}
}
detailedAnswers.add(DetailedAnswerModel(
questionId: q.id,
selectedOptionId: selectedOptId ?? 0,
isCorrect: isCorrect,
pointsAwarded: points,
explanation: q.explanationText ?? 'راجع نص القاعدة في كتاب الوزارة',
aiHint: q.aiHint,
));
}
final pct = totalScore > 0 ? (earnedScore / totalScore) * 100 : 0.0;
final passed = pct >= exam.passingPercentage;
final report = passed
? 'أداء ممتاز! حققت نسبة إتقان ${pct.toStringAsFixed(1)}%. لديك استيعاب عميق للمفاهيم الأساسية.'
: 'تم رصد تعثر في مفاهيم: ${weakTopics.join('، ')}. ننصح بمشاهدة مقاطع الشرح المركزة لمعالجة الثغرات.';
return ExamSubmissionResultModel(
attemptId: DateTime.now().millisecondsSinceEpoch ~/ 1000,
score: earnedScore,
totalScore: totalScore,
percentage: pct,
passed: passed,
rewindSeconds: passed ? 0 : 45,
aiDiagnosticReport: report,
weakTopics: weakTopics,
tawjihiReadinessScore: (pct * 0.7) + (passed ? 25.0 : 10.0),
detailedAnswers: detailedAnswers,
);
}
}