Update Saqel Platform: 2026-09-08 13:43:36

This commit is contained in:
Hamza-Ayed
2026-09-08 13:43:36 +03:00
parent 0f424062fe
commit bb37b31cab
76 changed files with 3617 additions and 3059 deletions
@@ -67,23 +67,6 @@ class GuardianCubit extends Cubit<GuardianState> {
: _guardianRepo = guardianRepo ?? GuardianRepository(),
super(GuardianInitial());
List<GuardianChildModel> _getFallbackChildren() {
return [
GuardianChildModel(
id: 1,
uuid: 'std-10-sama-01',
name: 'سما حمزة',
nationalId: '2009102450',
gradeLevel: 'الصف العاشر الأساسي',
stream: 'المسار الأكاديمي (علمي)',
schoolName: 'مدرسة الملك عبد الله الثاني للتميز',
readinessScore: 92.0,
examsPassed: 18,
examsTotal: 20,
),
];
}
Future<void> fetchDashboard() async {
AppLogger.log('Fetching guardian dashboard children from API...', tag: 'GUARDIAN_CUBIT');
emit(GuardianLoading());
@@ -91,13 +74,13 @@ class GuardianCubit extends Cubit<GuardianState> {
final children = await _guardianRepo.getDashboardChildren();
AppLogger.log('Fetched ${children.length} linked children from API', tag: 'GUARDIAN_CUBIT');
if (children.isEmpty) {
emit(GuardianLoaded(children: _getFallbackChildren(), selectedChildIndex: 0));
emit(GuardianEmpty());
} else {
emit(GuardianLoaded(children: children, selectedChildIndex: 0));
}
} catch (e) {
AppLogger.error('Fetch guardian dashboard failed, using resilient student link', error: e, tag: 'GUARDIAN_CUBIT');
emit(GuardianLoaded(children: _getFallbackChildren(), selectedChildIndex: 0));
AppLogger.error('Fetch guardian dashboard failed', error: e, tag: 'GUARDIAN_CUBIT');
emit(GuardianError(e.toString()));
}
}
@@ -192,17 +192,11 @@ class ExamCubit extends Cubit<ExamState> {
}).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);
}
final result = await _repository.submitExam(
s.exam.id,
answers: formattedAnswers,
timeSpentSeconds: timeSpent,
);
emit(ExamCompleted(
exam: s.exam,
@@ -214,59 +208,4 @@ class ExamCubit extends Cubit<ExamState> {
}
}
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,
);
}
}
@@ -95,9 +95,7 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
try {
var playback = await _repo.getLessonPlayback(lesson.markdownFilePath ?? lesson.id, subjectId: subject?.id);
if (playback.videoUrl.isEmpty) {
playback = playback.copyWith(
videoUrl: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
);
throw StateError('لم يربط الخادم فيديو R2 بهذا الدرس بعد.');
}
// Load saved resume position strictly per video
@@ -120,118 +118,11 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
isPlaying: true,
));
} catch (e) {
AppLogger.log('Playback API unavailable (${lesson.title}): $e — Switching to offline Socratic fallback', tag: 'VIDEO_CUBIT');
final fallback = _buildResilientLessonPlayback(lesson, subject: subject);
// Load saved resume position strictly per video
int resumePos = fallback.lastPositionSeconds ?? 0;
Set<int> passedIds = {};
try {
final prefs = await SharedPreferences.getInstance();
final localPos = prefs.getInt('saved_video_pos_$storageKey') ?? 0;
if (localPos > resumePos) resumePos = localPos;
final savedPassed = prefs.getStringList('passed_checkpoints_$storageKey') ?? [];
passedIds = savedPassed.map((s) => int.tryParse(s) ?? 0).where((id) => id > 0).toSet();
} catch (_) {}
emit(VideoPlaybackReady(
playbackData: fallback,
lessonItem: lesson,
subject: subject,
currentPositionSeconds: resumePos,
passedCheckpointIds: passedIds,
isPlaying: true,
));
AppLogger.error('Playback API unavailable (${lesson.title})', error: e, tag: 'VIDEO_CUBIT');
emit(VideoPlaybackError(e.toString()));
}
}
LessonPlaybackData _buildResilientLessonPlayback(CurriculumLessonItemModel lesson, {SubjectModel? subject}) {
final title = lesson.title.toLowerCase();
final isEng = (subject?.id ?? '').contains('english') || title.contains('english') || title.contains('unit 01');
final isPhys = (subject?.id ?? '').contains('physic') || title.contains('فيزياء') || title.contains('متجه');
List<SocraticCheckpointModel> points = [];
if (isEng) {
points = [
const SocraticCheckpointModel(
id: 101,
questionText: 'According to the reading passage, in how many seconds do humans make subconscious judgments?',
timestampSeconds: 20,
hint: 'Remember the rule of first impressions in psychological studies.',
pedagogicalExplanation: 'Behavioral research confirms that people form initial impressions within the first 7 seconds.',
options: [
SocraticOptionModel(id: 1, text: 'Within 7 seconds', isCorrect: true),
SocraticOptionModel(id: 2, text: 'Within 5 minutes', isCorrect: false),
SocraticOptionModel(id: 3, text: 'After prolonged conversation', isCorrect: false),
],
),
const SocraticCheckpointModel(
id: 102,
questionText: 'Which article should precede the singular noun "university"?',
timestampSeconds: 50,
hint: 'Consider the initial phonetic sound rather than the written letter.',
pedagogicalExplanation: 'Although "university" starts with the vowel letter "u", it begins with the consonant sound /juː/, so we use "a".',
options: [
SocraticOptionModel(id: 4, text: 'a (e.g. a university)', isCorrect: true),
SocraticOptionModel(id: 5, text: 'an (e.g. an university)', isCorrect: false),
],
),
];
} else if (isPhys) {
points = [
const SocraticCheckpointModel(
id: 201,
questionText: 'ما هي النتيجة الصحيحة للضرب القياسي لمتجهين متعامدين (θ = 90°)؟',
timestampSeconds: 20,
hint: 'تذكر أن الضرب النقطي يعتمد على جيب التمام cos(θ).',
pedagogicalExplanation: 'بما أن cos(90°) = 0، فإن الضرب القياسي لمتجهين متعامدين ينعدم تماماً ويساوي صفراً.',
options: [
SocraticOptionModel(id: 1, text: 'ينعدم الناتج (يساوي صفراً)', isCorrect: true),
SocraticOptionModel(id: 2, text: 'يساوي حاصل ضرب مقداريهما', isCorrect: false),
SocraticOptionModel(id: 3, text: 'يساوي متجهاً رأسياً جديداً', isCorrect: false),
],
),
];
} else {
points = [
const SocraticCheckpointModel(
id: 301,
questionText: 'قبل تحليل المعادلة x³ + 4x² = 5x، ما هي الخطوة الجبرية الإلزامية الأولى؟',
timestampSeconds: 20,
hint: 'احذر من قسمة طرفي المعادلة على المتغير x فتفقد أحد الجذور.',
pedagogicalExplanation: 'يجب نقل الحد 5x إلى الطرف الأيسر ليصبح الطرف الأيمن صفراً، ثم إخراج العامل المشترك x.',
options: [
SocraticOptionModel(id: 1, text: 'نقل 5x للطرف الأيسر وجعل الطرف الأيمن صفراً', isCorrect: true),
SocraticOptionModel(id: 2, text: 'القسمة المباشرة على x في الطرفين', isCorrect: false),
SocraticOptionModel(id: 3, text: 'أخذ الجذر التكعيبي لكافة الحدود', isCorrect: false),
],
),
];
}
return LessonPlaybackData(
lessonId: int.tryParse(lesson.id.replaceAll(RegExp(r'[^0-9]'), '')) ?? 101,
title: lesson.title,
durationSeconds: lesson.durationSeconds > 0 ? lesson.durationSeconds : 1200,
videoUrl: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
storageType: 'hls_stream',
checkpoints: points,
lastPositionSeconds: 0,
availableVersions: const [
LessonVersionModel(
lessonId: 101,
isAi: true,
teacherName: 'منصة صَقِل التعليمية المعتمدة',
schoolName: 'المركز الرقمي المعتمد',
label: 'الشرح الرقمي الرسمي المعتمد',
isRecommended: true,
videoUrl: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
),
],
);
}
void updatePosition(int seconds) {
final currentState = state;
if (currentState is VideoPlaybackReady && currentState.activeCheckpoint == null) {