257 lines
7.5 KiB
Dart
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,
|
|
);
|
|
}
|
|
}
|