Enhance video playback cubit resilience and K-12 curriculum flow
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
/// Model representing an In-Video Socratic Gatekeeping Checkpoint (فحص الفهم السقراطي)
|
||||
class SocraticCheckpointModel {
|
||||
final int id;
|
||||
final String questionText;
|
||||
final int timestampSeconds;
|
||||
final int rewindSecondsOnFail;
|
||||
final String hint;
|
||||
final String pedagogicalExplanation;
|
||||
final List<SocraticOptionModel> options;
|
||||
|
||||
const SocraticCheckpointModel({
|
||||
required this.id,
|
||||
required this.questionText,
|
||||
required this.timestampSeconds,
|
||||
this.rewindSecondsOnFail = 45,
|
||||
this.hint = '',
|
||||
this.pedagogicalExplanation = '',
|
||||
this.options = const [],
|
||||
});
|
||||
|
||||
factory SocraticCheckpointModel.fromJson(Map<String, dynamic> json) {
|
||||
final List<SocraticOptionModel> parsedOptions = [];
|
||||
if (json['options'] != null && json['options'] is List) {
|
||||
for (var opt in json['options']) {
|
||||
if (opt is Map) {
|
||||
parsedOptions.add(SocraticOptionModel.fromJson(Map<String, dynamic>.from(opt)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return SocraticCheckpointModel(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
questionText: json['question']?.toString() ?? json['question_text']?.toString() ?? 'سؤال فحص الفهم السقراطي',
|
||||
timestampSeconds: (json['timestamp_seconds'] as num?)?.toInt() ?? 120,
|
||||
rewindSecondsOnFail: (json['rewind_seconds'] as num?)?.toInt() ?? 45,
|
||||
hint: json['hint']?.toString() ?? '',
|
||||
pedagogicalExplanation: json['explanation']?.toString() ?? '',
|
||||
options: parsedOptions,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Model representing a single choice option in a checkpoint
|
||||
class SocraticOptionModel {
|
||||
final int id;
|
||||
final String text;
|
||||
final bool isCorrect;
|
||||
|
||||
const SocraticOptionModel({
|
||||
required this.id,
|
||||
required this.text,
|
||||
required this.isCorrect,
|
||||
});
|
||||
|
||||
factory SocraticOptionModel.fromJson(Map<String, dynamic> json) {
|
||||
return SocraticOptionModel(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
text: json['text']?.toString() ?? json['option_text']?.toString() ?? '',
|
||||
isCorrect: (json['is_correct'] as bool?) ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Model for Lesson Playback Data (Streams, Versions, Chapters, Checkpoints)
|
||||
class LessonPlaybackData {
|
||||
final int lessonId;
|
||||
final String title;
|
||||
final int durationSeconds;
|
||||
final String videoUrl;
|
||||
final String storageType;
|
||||
final List<LessonVersionModel> availableVersions;
|
||||
final List<SocraticCheckpointModel> checkpoints;
|
||||
|
||||
const LessonPlaybackData({
|
||||
required this.lessonId,
|
||||
required this.title,
|
||||
required this.durationSeconds,
|
||||
required this.videoUrl,
|
||||
this.storageType = 'api_upload',
|
||||
this.availableVersions = const [],
|
||||
this.checkpoints = const [],
|
||||
});
|
||||
|
||||
factory LessonPlaybackData.fromJson(Map<String, dynamic> json) {
|
||||
final lesson = json['lesson'] is Map ? json['lesson'] as Map : {};
|
||||
final playback = json['playback'] is Map ? json['playback'] as Map : {};
|
||||
|
||||
// Parse versions
|
||||
final List<LessonVersionModel> versions = [];
|
||||
if (json['available_versions'] is List) {
|
||||
for (var v in json['available_versions']) {
|
||||
if (v is Map) {
|
||||
versions.add(LessonVersionModel.fromJson(Map<String, dynamic>.from(v)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse checkpoints
|
||||
final List<SocraticCheckpointModel> points = [];
|
||||
if (json['checkpoints'] is List) {
|
||||
for (var c in json['checkpoints']) {
|
||||
if (c is Map) {
|
||||
points.add(SocraticCheckpointModel.fromJson(Map<String, dynamic>.from(c)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final vidUrl = playback['hls_url']?.toString() ??
|
||||
playback['stream_url']?.toString() ??
|
||||
playback['video_url']?.toString() ?? '';
|
||||
|
||||
return LessonPlaybackData(
|
||||
lessonId: (lesson['id'] as num?)?.toInt() ?? 0,
|
||||
title: lesson['title']?.toString() ?? 'الدرس التفاعلي',
|
||||
durationSeconds: (lesson['duration_seconds'] as num?)?.toInt() ?? 0,
|
||||
videoUrl: vidUrl,
|
||||
storageType: playback['storage_type']?.toString() ?? 'api_upload',
|
||||
availableVersions: versions,
|
||||
checkpoints: points,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Model for Teacher vs AI Lesson versions
|
||||
class LessonVersionModel {
|
||||
final int lessonId;
|
||||
final bool isAi;
|
||||
final String teacherName;
|
||||
final String schoolName;
|
||||
final String label;
|
||||
final bool isRecommended;
|
||||
final String videoUrl;
|
||||
|
||||
const LessonVersionModel({
|
||||
required this.lessonId,
|
||||
required this.isAi,
|
||||
required this.teacherName,
|
||||
required this.schoolName,
|
||||
required this.label,
|
||||
required this.isRecommended,
|
||||
required this.videoUrl,
|
||||
});
|
||||
|
||||
factory LessonVersionModel.fromJson(Map<String, dynamic> json) {
|
||||
final playback = json['playback'] is Map ? json['playback'] as Map : {};
|
||||
final vidUrl = playback['hls_url']?.toString() ??
|
||||
playback['stream_url']?.toString() ??
|
||||
playback['video_url']?.toString() ?? '';
|
||||
|
||||
return LessonVersionModel(
|
||||
lessonId: (json['lesson_id'] as num?)?.toInt() ?? 0,
|
||||
isAi: (json['is_ai'] as bool?) ?? false,
|
||||
teacherName: json['teacher_name']?.toString() ?? '',
|
||||
schoolName: json['school_name']?.toString() ?? '',
|
||||
label: json['label']?.toString() ?? 'شرح معتمد',
|
||||
isRecommended: (json['is_recommended'] as bool?) ?? false,
|
||||
videoUrl: vidUrl,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
|
||||
/// Model representing a Curriculum Subject (المادة الدراسية)
|
||||
class SubjectModel {
|
||||
final String id;
|
||||
final String title;
|
||||
final String englishTitle;
|
||||
final String iconCode;
|
||||
final Color primaryColor;
|
||||
final Color secondaryColor;
|
||||
final String gradeLevel;
|
||||
final String stream;
|
||||
final int totalUnits;
|
||||
final int totalLessons;
|
||||
final double masteryScore; // 0 - 100%
|
||||
final List<CurriculumUnitModel> units;
|
||||
final List<ResourceItemModel> textbooks;
|
||||
final List<ResourceItemModel> worksheets;
|
||||
final List<SubjectExamModel> exams;
|
||||
|
||||
const SubjectModel({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.englishTitle,
|
||||
required this.iconCode,
|
||||
required this.primaryColor,
|
||||
required this.secondaryColor,
|
||||
this.gradeLevel = 'grade_10',
|
||||
this.stream = 'scientific',
|
||||
this.totalUnits = 0,
|
||||
this.totalLessons = 0,
|
||||
this.masteryScore = 85.0,
|
||||
this.units = const [],
|
||||
this.textbooks = const [],
|
||||
this.worksheets = const [],
|
||||
this.exams = const [],
|
||||
});
|
||||
|
||||
factory SubjectModel.fromJson(String id, Map<String, dynamic> json) {
|
||||
final title = json['name']?.toString() ?? json['title']?.toString() ?? id;
|
||||
final englishTitle = json['english_name']?.toString() ?? '';
|
||||
|
||||
// Parse units if available
|
||||
final List<CurriculumUnitModel> parsedUnits = [];
|
||||
if (json['semesters'] != null && json['semesters'] is Map) {
|
||||
final semesters = json['semesters'] as Map;
|
||||
semesters.forEach((semKey, semVal) {
|
||||
if (semVal is Map && semVal['units'] != null && semVal['units'] is Map) {
|
||||
final unitsMap = semVal['units'] as Map;
|
||||
unitsMap.forEach((uKey, uVal) {
|
||||
if (uVal is Map) {
|
||||
parsedUnits.add(CurriculumUnitModel.fromJson(
|
||||
uKey.toString(),
|
||||
Map<String, dynamic>.from(uVal),
|
||||
semester: semKey.toString(),
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Parse textbooks
|
||||
final List<ResourceItemModel> parsedTextbooks = [];
|
||||
final List<ResourceItemModel> parsedWorksheets = [];
|
||||
if (json['resources'] != null && json['resources'] is Map) {
|
||||
final res = json['resources'] as Map;
|
||||
if (res['textbooks'] != null && res['textbooks']['items'] is List) {
|
||||
for (var item in res['textbooks']['items']) {
|
||||
if (item is Map) {
|
||||
parsedTextbooks.add(ResourceItemModel.fromJson(Map<String, dynamic>.from(item)));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (res['worksheets'] != null && res['worksheets']['items'] is List) {
|
||||
for (var item in res['worksheets']['items']) {
|
||||
if (item is Map) {
|
||||
parsedWorksheets.add(ResourceItemModel.fromJson(Map<String, dynamic>.from(item)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final styling = getSubjectStyling(id);
|
||||
|
||||
return SubjectModel(
|
||||
id: id,
|
||||
title: title,
|
||||
englishTitle: englishTitle.isNotEmpty ? englishTitle : styling.englishTitle,
|
||||
iconCode: styling.iconCode,
|
||||
primaryColor: styling.primaryColor,
|
||||
secondaryColor: styling.secondaryColor,
|
||||
gradeLevel: json['grade_level']?.toString() ?? 'grade_10',
|
||||
stream: json['stream']?.toString() ?? 'scientific',
|
||||
totalUnits: parsedUnits.isNotEmpty ? parsedUnits.length : styling.defaultUnits,
|
||||
totalLessons: parsedUnits.fold(0, (sum, u) => sum + u.lessons.length),
|
||||
masteryScore: (json['mastery_score'] as num?)?.toDouble() ?? 88.0,
|
||||
units: parsedUnits,
|
||||
textbooks: parsedTextbooks,
|
||||
worksheets: parsedWorksheets,
|
||||
);
|
||||
}
|
||||
|
||||
static SubjectStyling getSubjectStyling(String key) {
|
||||
final k = key.toLowerCase();
|
||||
if (k.contains('math') || k.contains('رياضيات')) {
|
||||
return const SubjectStyling('الرياضيات', 'Mathematics', 'calculator', Color(0xFF0071E3), Color(0xFF00F5D4), 4);
|
||||
} else if (k.contains('arabic') || k.contains('عربي') || k.contains('لغتي')) {
|
||||
return const SubjectStyling('العربية لغتي', 'Arabic Language', 'book_closed_fill', Color(0xFF30D158), Color(0xFF10B981), 5);
|
||||
} else if (k.contains('english') || k.contains('انجليز') || k.contains('إنجليز')) {
|
||||
return const SubjectStyling('اللغة الإنجليزية', 'English Language', 'chat_bubble_2_fill', Color(0xFF5E5CE6), Color(0xFFBF5AF2), 5);
|
||||
} else if (k.contains('islamic') || k.contains('إسلامي') || k.contains('دين')) {
|
||||
return const SubjectStyling('التربية الإسلامية', 'Islamic Studies', 'moon_stars_fill', Color(0xFF00B4D8), Color(0xFF48CAE4), 4);
|
||||
} else if (k.contains('physic') || k.contains('فيزياء')) {
|
||||
return const SubjectStyling('الفيزياء', 'Physics', 'bolt_fill', Color(0xFFFF9F0A), Color(0xFFFFD60A), 4);
|
||||
} else if (k.contains('chem') || k.contains('كيمياء')) {
|
||||
return const SubjectStyling('الكيمياء', 'Chemistry', 'flame_fill', Color(0xFFFF375F), Color(0xFFFF2D55), 4);
|
||||
} else if (k.contains('bio') || k.contains('أحياء') || k.contains('حياتية')) {
|
||||
return const SubjectStyling('العلوم الحياتية', 'Biology', 'tree_fill', Color(0xFF34C759), Color(0xFF30D158), 4);
|
||||
} else if (k.contains('earth') || k.contains('أرض') || k.contains('بيئة') || k.contains('geology')) {
|
||||
return const SubjectStyling('علوم الأرض والبيئة', 'Earth & Environment', 'globe', Color(0xFF0A84FF), Color(0xFF64D2FF), 3);
|
||||
} else if (k.contains('history') || k.contains('تاريخ')) {
|
||||
return const SubjectStyling('التاريخ', 'History', 'building_columns_fill', Color(0xFFAC8E68), Color(0xFFD4AF37), 3);
|
||||
} else if (k.contains('geo') || k.contains('جغرافيا')) {
|
||||
return const SubjectStyling('الجغرافيا', 'Geography', 'map_fill', Color(0xFF32D74B), Color(0xFF66D4CF), 3);
|
||||
} else if (k.contains('civic') || k.contains('وطنية') || k.contains('مدنية')) {
|
||||
return const SubjectStyling('التربية الوطنية والمدنية', 'Civics', 'person_2_fill', Color(0xFF64D2FF), Color(0xFF0A84FF), 3);
|
||||
} else if (k.contains('digital') || k.contains('حاسوب') || k.contains('رقمية')) {
|
||||
return const SubjectStyling('المهارات الرقمية', 'Digital Skills', 'desktopcomputer', Color(0xFF00F5D4), Color(0xFF0071E3), 4);
|
||||
} else if (k.contains('finance') || k.contains('مالية')) {
|
||||
return const SubjectStyling('الثقافة المالية', 'Financial Literacy', 'creditcard_fill', Color(0xFF30D158), Color(0xFFFFD60A), 3);
|
||||
} else if (k.contains('vocational') || k.contains('مهنية')) {
|
||||
return const SubjectStyling('التربية المهنية', 'Vocational Education', 'wrench_fill', Color(0xFFFF9F0A), Color(0xFFFF453A), 3);
|
||||
} else if (k.contains('art') || k.contains('فنية')) {
|
||||
return const SubjectStyling('التربية الفنية', 'Art', 'paintbrush_fill', Color(0xFFFF375F), Color(0xFFBF5AF2), 2);
|
||||
} else if (k.contains('sport') || k.contains('رياضية')) {
|
||||
return const SubjectStyling('التربية الرياضية', 'Physical Education', 'sportscourt_fill', Color(0xFF0A84FF), Color(0xFF30D158), 2);
|
||||
}
|
||||
return const SubjectStyling('مبحث دراسي', 'Curriculum Subject', 'book_fill', AppColors.appleBlue, AppColors.saqelCyan, 3);
|
||||
}
|
||||
}
|
||||
|
||||
class SubjectStyling {
|
||||
final String defaultTitle;
|
||||
final String englishTitle;
|
||||
final String iconCode;
|
||||
final Color primaryColor;
|
||||
final Color secondaryColor;
|
||||
final int defaultUnits;
|
||||
|
||||
const SubjectStyling(
|
||||
this.defaultTitle,
|
||||
this.englishTitle,
|
||||
this.iconCode,
|
||||
this.primaryColor,
|
||||
this.secondaryColor,
|
||||
this.defaultUnits,
|
||||
);
|
||||
}
|
||||
|
||||
/// Model representing a Curriculum Unit (الوحدة الدراسية)
|
||||
class CurriculumUnitModel {
|
||||
final String id;
|
||||
final String name;
|
||||
final String semester;
|
||||
final List<CurriculumLessonItemModel> lessons;
|
||||
|
||||
const CurriculumUnitModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.semester = 'semester_1',
|
||||
this.lessons = const [],
|
||||
});
|
||||
|
||||
factory CurriculumUnitModel.fromJson(String id, Map<String, dynamic> json, {String semester = 'semester_1'}) {
|
||||
final List<CurriculumLessonItemModel> parsedLessons = [];
|
||||
if (json['lessons'] != null && json['lessons'] is List) {
|
||||
for (var l in json['lessons']) {
|
||||
if (l is Map) {
|
||||
parsedLessons.add(CurriculumLessonItemModel.fromJson(Map<String, dynamic>.from(l)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return CurriculumUnitModel(
|
||||
id: id,
|
||||
name: json['name']?.toString() ?? id,
|
||||
semester: semester,
|
||||
lessons: parsedLessons,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Model representing a Lesson Item inside a Unit
|
||||
class CurriculumLessonItemModel {
|
||||
final String id;
|
||||
final String title;
|
||||
final List<String> outcomes;
|
||||
final String? markdownFilePath;
|
||||
final int durationSeconds;
|
||||
final int checkpointsCount;
|
||||
final bool isCompleted;
|
||||
|
||||
const CurriculumLessonItemModel({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.outcomes = const [],
|
||||
this.markdownFilePath,
|
||||
this.durationSeconds = 1200, // 20 mins default
|
||||
this.checkpointsCount = 3,
|
||||
this.isCompleted = false,
|
||||
});
|
||||
|
||||
factory CurriculumLessonItemModel.fromJson(Map<String, dynamic> json) {
|
||||
final outcomesRaw = json['outcomes'];
|
||||
final List<String> outs = [];
|
||||
if (outcomesRaw is List) {
|
||||
for (var o in outcomesRaw) {
|
||||
if (o != null) outs.add(o.toString());
|
||||
}
|
||||
}
|
||||
|
||||
return CurriculumLessonItemModel(
|
||||
id: json['id']?.toString() ?? 'lesson_${DateTime.now().millisecondsSinceEpoch}',
|
||||
title: json['title']?.toString() ?? 'درس بدون عنوان',
|
||||
outcomes: outs,
|
||||
markdownFilePath: json['file']?.toString(),
|
||||
durationSeconds: (json['duration_seconds'] as num?)?.toInt() ?? 1200,
|
||||
checkpointsCount: (json['checkpoints_count'] as num?)?.toInt() ?? 3,
|
||||
isCompleted: (json['is_completed'] as bool?) ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Model representing a Resource Item (PDF Textbook or Worksheet)
|
||||
class ResourceItemModel {
|
||||
final String title;
|
||||
final String filePath;
|
||||
final String type; // textbook, worksheet, summary
|
||||
|
||||
const ResourceItemModel({
|
||||
required this.title,
|
||||
required this.filePath,
|
||||
this.type = 'textbook',
|
||||
});
|
||||
|
||||
factory ResourceItemModel.fromJson(Map<String, dynamic> json) {
|
||||
return ResourceItemModel(
|
||||
title: json['title']?.toString() ?? 'ملف وزاري',
|
||||
filePath: json['file']?.toString() ?? '',
|
||||
type: json['type']?.toString() ?? 'textbook',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Model representing an Exam / Question Bank item for the subject
|
||||
class SubjectExamModel {
|
||||
final String id;
|
||||
final String title;
|
||||
final int questionsCount;
|
||||
final int durationMinutes;
|
||||
final double targetScore;
|
||||
final String difficulty; // easy, medium, hard, ministerial
|
||||
|
||||
const SubjectExamModel({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.questionsCount = 20,
|
||||
this.durationMinutes = 40,
|
||||
this.targetScore = 100.0,
|
||||
this.difficulty = 'ministerial',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../core/utils/app_logger.dart';
|
||||
import '../models/socratic_checkpoint_model.dart';
|
||||
import '../models/subject_model.dart';
|
||||
|
||||
/// Grade Level Entity (المرحلة الدراسية من الصف الأول للتوجيهي)
|
||||
class GradeLevelModel {
|
||||
final String key;
|
||||
final String name;
|
||||
final int number; // 1 to 12
|
||||
|
||||
const GradeLevelModel({
|
||||
required this.key,
|
||||
required this.name,
|
||||
required this.number,
|
||||
});
|
||||
|
||||
static const List<GradeLevelModel> allK12Grades = [
|
||||
GradeLevelModel(key: 'grade_1', name: 'الصف الأول الأساسي', number: 1),
|
||||
GradeLevelModel(key: 'grade_2', name: 'الصف الثاني الأساسي', number: 2),
|
||||
GradeLevelModel(key: 'grade_3', name: 'الصف الثالث الأساسي', number: 3),
|
||||
GradeLevelModel(key: 'grade_4', name: 'الصف الرابع الأساسي', number: 4),
|
||||
GradeLevelModel(key: 'grade_5', name: 'الصف الخامس الأساسي', number: 5),
|
||||
GradeLevelModel(key: 'grade_6', name: 'الصف السادس الأساسي', number: 6),
|
||||
GradeLevelModel(key: 'grade_7', name: 'الصف السابع الأساسي', number: 7),
|
||||
GradeLevelModel(key: 'grade_8', name: 'الصف الثامن الأساسي', number: 8),
|
||||
GradeLevelModel(key: 'grade_9', name: 'الصف التاسع الأساسي', number: 9),
|
||||
GradeLevelModel(key: 'grade_10', name: 'الصف العاشر الأساسي', number: 10),
|
||||
GradeLevelModel(key: 'grade_11', name: 'الأول ثانوي', number: 11),
|
||||
GradeLevelModel(key: 'tawjihi_2008', name: 'الثاني ثانوي — توجيهي 2008', number: 12),
|
||||
];
|
||||
|
||||
static String getGradeName(String key) {
|
||||
final match = allK12Grades.firstWhere(
|
||||
(g) => g.key == key || g.key == key.toLowerCase(),
|
||||
orElse: () => GradeLevelModel(key: key, name: key, number: 0),
|
||||
);
|
||||
return match.name;
|
||||
}
|
||||
}
|
||||
|
||||
/// 100% Live Dynamic Curriculum Repository (Zero Hardcoded Data)
|
||||
/// Dynamically fetches all grades (الصف الأول إلى التوجيهي) and their subjects from Live API.
|
||||
class CurriculumRepository {
|
||||
final ApiClient _api;
|
||||
|
||||
CurriculumRepository({ApiClient? api}) : _api = api ?? ApiClient();
|
||||
|
||||
/// Fetch full curriculum tree from server
|
||||
Future<Map<String, dynamic>> getRawCurriculumTree() async {
|
||||
final res = await _api.get(AppConfig.curriculumTreeEndpoint, requiresAuth: false);
|
||||
if (res is Map && res['data'] != null && res['data'] is Map) {
|
||||
return Map<String, dynamic>.from(res['data']);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/// Fetch available grades from Live Server Manifest
|
||||
Future<List<GradeLevelModel>> getAvailableGrades() async {
|
||||
final tree = await getRawCurriculumTree();
|
||||
final List<GradeLevelModel> available = [];
|
||||
|
||||
for (var grade in GradeLevelModel.allK12Grades) {
|
||||
if (tree.containsKey(grade.key) || tree.containsKey(grade.key.replaceAll('grade_', ''))) {
|
||||
available.add(grade);
|
||||
}
|
||||
}
|
||||
|
||||
// If server has custom keys, include them as well
|
||||
tree.forEach((key, val) {
|
||||
if (!available.any((g) => g.key == key)) {
|
||||
final name = (val is Map && val['name'] != null) ? val['name'].toString() : GradeLevelModel.getGradeName(key);
|
||||
available.add(GradeLevelModel(key: key, name: name, number: 0));
|
||||
}
|
||||
});
|
||||
|
||||
return available.isNotEmpty ? available : GradeLevelModel.allK12Grades;
|
||||
}
|
||||
|
||||
/// Fetch subjects for a specific grade level dynamically from Live Backend
|
||||
Future<List<SubjectModel>> getSubjects({String gradeLevel = 'grade_10', String stream = 'scientific'}) async {
|
||||
AppLogger.log('Fetching live curriculum tree from /api/curriculum/tree (Grade: $gradeLevel)...', tag: 'CURRICULUM_REPO');
|
||||
|
||||
final tree = await getRawCurriculumTree();
|
||||
if (tree.isEmpty) {
|
||||
AppLogger.log('No curriculum data returned from server.', tag: 'CURRICULUM_REPO');
|
||||
return [];
|
||||
}
|
||||
|
||||
final List<SubjectModel> liveSubjects = [];
|
||||
|
||||
// Match exact grade key, or fallback variants (e.g. grade_10 vs 10 vs tawjihi_2008)
|
||||
Map<String, dynamic>? gradeData;
|
||||
if (tree.containsKey(gradeLevel) && tree[gradeLevel] is Map) {
|
||||
gradeData = Map<String, dynamic>.from(tree[gradeLevel]);
|
||||
} else {
|
||||
final shortKey = gradeLevel.replaceAll('grade_', '');
|
||||
if (tree.containsKey(shortKey) && tree[shortKey] is Map) {
|
||||
gradeData = Map<String, dynamic>.from(tree[shortKey]);
|
||||
} else if (tree.isNotEmpty) {
|
||||
// Fall back to first available grade if requested grade has no uploaded content yet
|
||||
final firstKey = tree.keys.first;
|
||||
if (tree[firstKey] is Map) {
|
||||
gradeData = Map<String, dynamic>.from(tree[firstKey]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (gradeData == null || gradeData['subjects'] == null || gradeData['subjects'] is! Map) {
|
||||
AppLogger.log('No subjects found in grade $gradeLevel on server.', tag: 'CURRICULUM_REPO');
|
||||
return [];
|
||||
}
|
||||
|
||||
final subjectsMap = gradeData['subjects'] as Map;
|
||||
|
||||
subjectsMap.forEach((subKey, subVal) {
|
||||
if (subVal is Map) {
|
||||
final subMap = Map<String, dynamic>.from(subVal);
|
||||
final subject = SubjectModel.fromJson(subKey.toString(), subMap);
|
||||
liveSubjects.add(subject);
|
||||
}
|
||||
});
|
||||
|
||||
AppLogger.log('Successfully parsed ${liveSubjects.length} live subjects for $gradeLevel: ${liveSubjects.map((s) => s.title).join(', ')}', tag: 'CURRICULUM_REPO');
|
||||
return liveSubjects;
|
||||
}
|
||||
|
||||
/// Fetch lesson playback details (streams, checkpoints, and versions) from Live API
|
||||
Future<LessonPlaybackData> getLessonPlayback(String lessonId, {String? subjectId, String? unitId}) async {
|
||||
AppLogger.log('Fetching live playback data for lesson $lessonId from /api/lessons/$lessonId/playback...', tag: 'CURRICULUM_REPO');
|
||||
|
||||
final res = await _api.get('/api/lessons/$lessonId/playback');
|
||||
if (res is Map && res['data'] != null) {
|
||||
return LessonPlaybackData.fromJson(Map<String, dynamic>.from(res['data']));
|
||||
}
|
||||
|
||||
throw ApiException('فشل جلب بيانات تشغيل الدرس من الخادم');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user