Harden published curriculum and student flows

This commit is contained in:
Hamza-Ayed
2026-09-30 08:22:17 +03:00
parent e8163fe265
commit ce7b0fcf14
53 changed files with 4723 additions and 3317 deletions
@@ -55,6 +55,7 @@ class ErrorNotebookItem {
final String errorCategory; // 'conceptual', 'calculation', 'rushed', 'misinterpretation'
final String status; // 'pending_remediation', 'in_remediation', 'mastered'
final int remediationAttemptsCount;
final bool retentionIsDue;
final String? masteredAt;
final String createdAt;
@@ -72,6 +73,7 @@ class ErrorNotebookItem {
required this.errorCategory,
required this.status,
required this.remediationAttemptsCount,
this.retentionIsDue = false,
this.masteredAt,
required this.createdAt,
});
@@ -125,6 +127,7 @@ class ErrorNotebookItem {
errorCategory: json['error_category']?.toString() ?? 'conceptual',
status: json['status']?.toString() ?? 'pending_remediation',
remediationAttemptsCount: (json['remediation_attempts_count'] as num?)?.toInt() ?? 0,
retentionIsDue: json['retention_is_due'] == true || json['retention_is_due'] == 1 || json['retention_is_due'] == '1',
masteredAt: json['mastered_at']?.toString(),
createdAt: json['created_at']?.toString() ?? '',
);
@@ -149,6 +152,7 @@ class ErrorNotebookItem {
errorCategory: errorCategory,
status: status ?? this.status,
remediationAttemptsCount: remediationAttemptsCount ?? this.remediationAttemptsCount,
retentionIsDue: retentionIsDue,
masteredAt: masteredAt ?? this.masteredAt,
createdAt: createdAt,
);
@@ -158,16 +162,12 @@ class ErrorNotebookItem {
class RemedialQuestion {
final int id;
final String question;
final List<String> options;
final int correctIndex;
final String explanation;
final List<RemedialOption> options;
RemedialQuestion({
required this.id,
required this.question,
required this.options,
required this.correctIndex,
required this.explanation,
});
factory RemedialQuestion.fromJson(Map<String, dynamic> json) {
@@ -175,9 +175,17 @@ class RemedialQuestion {
return RemedialQuestion(
id: (json['id'] as num?)?.toInt() ?? 0,
question: json['question']?.toString() ?? '',
options: rawOptions.map((e) => e.toString()).toList(),
correctIndex: (json['correct_index'] as num?)?.toInt() ?? 0,
explanation: json['explanation']?.toString() ?? '',
options: rawOptions.map((e) => RemedialOption.fromJson(Map<String, dynamic>.from(e as Map))).toList(),
);
}
}
class RemedialOption {
final int id;
final String text;
RemedialOption({required this.id, required this.text});
factory RemedialOption.fromJson(Map<String, dynamic> json) => RemedialOption(
id: (json['id'] as num?)?.toInt() ?? 0,
text: json['text']?.toString() ?? '',
);
}
@@ -209,6 +209,8 @@ class CurriculumLessonItemModel {
final List<String> outcomes;
final String? markdownFilePath;
final String? curriculumLessonId;
final String? curriculumVersion;
final String? primaryLessonAssetId;
final int durationSeconds;
final int checkpointsCount;
final bool isCompleted;
@@ -220,6 +222,8 @@ class CurriculumLessonItemModel {
this.outcomes = const [],
this.markdownFilePath,
this.curriculumLessonId,
this.curriculumVersion,
this.primaryLessonAssetId,
this.durationSeconds = 0,
this.checkpointsCount = 0,
this.isCompleted = false,
@@ -254,6 +258,8 @@ class CurriculumLessonItemModel {
outcomes: outs,
markdownFilePath: filePath,
curriculumLessonId: json['curriculum_lesson_id']?.toString(),
curriculumVersion: json['curriculum_version']?.toString(),
primaryLessonAssetId: json['primary_lesson_asset_id']?.toString(),
durationSeconds: (json['duration_seconds'] as num?)?.toInt() ?? 0,
checkpointsCount: (json['checkpoints_count'] as num?)?.toInt() ?? 0,
isCompleted: (json['is_completed'] as bool?) ?? false,
@@ -28,19 +28,53 @@ class GradeLevelModel {
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),
GradeLevelModel(
key: 'tawjihi_2008', name: 'الثاني ثانوي — توجيهي 2008', number: 12),
];
static String normalizeGradeKey(String raw) {
final clean = raw.toLowerCase().trim();
if (clean.contains('عاشر') || clean.contains('grade_10') || clean.contains('grade 10') || clean.contains('grade10') || clean == '10') return 'grade_10';
if (clean.contains('تاسع') || clean.contains('grade_9') || clean.contains('grade 9') || clean.contains('grade9') || clean == '9') return 'grade_9';
if (clean.contains('ثامن') || clean.contains('grade_8') || clean.contains('grade 8') || clean.contains('grade8') || clean == '8') return 'grade_8';
if (clean.contains('سابع') || clean.contains('grade_7') || clean.contains('grade 7') || clean.contains('grade7') || clean == '7') return 'grade_7';
if (clean.contains('سادس') || clean.contains('grade_6') || clean.contains('grade 6') || clean.contains('grade6') || clean == '6') return 'grade_6';
if (clean.contains('خامس') || clean.contains('grade_5') || clean.contains('grade 5') || clean.contains('grade5') || clean == '5') return 'grade_5';
if (clean.contains('حادي') || clean.contains('أول ثانوي') || clean.contains('grade_11') || clean.contains('grade 11') || clean == '11') return 'grade_11';
if (clean.contains('ثاني عشر') || clean.contains('توجيهي') || clean.contains('tawjihi') || clean.contains('grade_12') || clean.contains('grade 12') || clean == '12') return 'tawjihi_2008';
if (clean.contains('عاشر') ||
clean.contains('grade_10') ||
clean.contains('grade 10') ||
clean.contains('grade10') ||
clean == '10') return 'grade_10';
if (clean.contains('تاسع') ||
clean.contains('grade_9') ||
clean.contains('grade 9') ||
clean.contains('grade9') ||
clean == '9') return 'grade_9';
if (clean.contains('ثامن') ||
clean.contains('grade_8') ||
clean.contains('grade 8') ||
clean.contains('grade8') ||
clean == '8') return 'grade_8';
if (clean.contains('سابع') ||
clean.contains('grade_7') ||
clean.contains('grade 7') ||
clean.contains('grade7') ||
clean == '7') return 'grade_7';
if (clean.contains('سادس') ||
clean.contains('grade_6') ||
clean.contains('grade 6') ||
clean.contains('grade6') ||
clean == '6') return 'grade_6';
if (clean.contains('خامس') ||
clean.contains('grade_5') ||
clean.contains('grade 5') ||
clean.contains('grade5') ||
clean == '5') return 'grade_5';
if (clean.contains('حادي') ||
clean.contains('أول ثانوي') ||
clean.contains('grade_11') ||
clean.contains('grade 11') ||
clean == '11') return 'grade_11';
if (clean.contains('ثاني عشر') ||
clean.contains('توجيهي') ||
clean.contains('tawjihi') ||
clean.contains('grade_12') ||
clean.contains('grade 12') ||
clean == '12') return 'tawjihi_2008';
return raw;
}
@@ -54,8 +88,7 @@ class GradeLevelModel {
}
}
/// 100% Live Dynamic Curriculum Repository (Zero Hardcoded Data)
/// Dynamically fetches all grades (الصف الأول إلى التوجيهي) and their subjects from Live API.
/// Reads only the server-published curriculum catalogue.
class CurriculumRepository {
final ApiClient _api;
@@ -63,11 +96,12 @@ class CurriculumRepository {
/// 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) {
final res =
await _api.get(AppConfig.curriculumTreeEndpoint, requiresAuth: false);
if (res is Map && res['status'] == 'success' && res['data'] is Map) {
return Map<String, dynamic>.from(res['data']);
}
return {};
throw ApiException('استجابة فهرس الدروس المنشورة غير صالحة.');
}
/// Fetch available grades from Live Server Manifest
@@ -78,22 +112,28 @@ class CurriculumRepository {
for (var grade in GradeLevelModel.allK12Grades) {
final norm = GradeLevelModel.normalizeGradeKey(grade.key);
final shortKey = norm.replaceAll('grade_', '');
if (tree.containsKey(grade.key) || tree.containsKey(norm) || tree.containsKey(shortKey)) {
if (tree.containsKey(grade.key) ||
tree.containsKey(norm) ||
tree.containsKey(shortKey)) {
available.add(grade);
}
}
return available.isNotEmpty ? available : GradeLevelModel.allK12Grades;
return available;
}
/// Fetch subjects for a specific grade level dynamically from Live Backend
Future<List<SubjectModel>> getSubjects({String gradeLevel = 'grade_10', String stream = 'scientific'}) async {
Future<List<SubjectModel>> getSubjects(
{String gradeLevel = 'grade_10', String stream = 'scientific'}) async {
final normGrade = GradeLevelModel.normalizeGradeKey(gradeLevel);
AppLogger.log('Fetching live curriculum tree from /api/curriculum/tree (Grade: $gradeLevel -> $normGrade)...', tag: 'CURRICULUM_REPO');
AppLogger.log(
'Fetching live curriculum tree from /api/curriculum/tree (Grade: $gradeLevel -> $normGrade)...',
tag: 'CURRICULUM_REPO');
final tree = await getRawCurriculumTree();
if (tree.isEmpty) {
AppLogger.log('No curriculum data returned from server.', tag: 'CURRICULUM_REPO');
AppLogger.log('No curriculum data returned from server.',
tag: 'CURRICULUM_REPO');
return [];
}
@@ -119,7 +159,8 @@ class CurriculumRepository {
}
if (entry.value is Map && entry.value['name'] != null) {
final name = entry.value['name'].toString();
if (GradeLevelModel.normalizeGradeKey(name) == normGrade || name.contains(normGrade)) {
if (GradeLevelModel.normalizeGradeKey(name) == normGrade ||
name.contains(normGrade)) {
gradeData = Map<String, dynamic>.from(entry.value);
break;
}
@@ -128,8 +169,12 @@ class CurriculumRepository {
}
}
if (gradeData == null || gradeData['subjects'] == null || gradeData['subjects'] is! Map) {
AppLogger.log('No subjects found in grade $gradeLevel ($normGrade) on server.', tag: 'CURRICULUM_REPO');
if (gradeData == null ||
gradeData['subjects'] == null ||
gradeData['subjects'] is! Map) {
AppLogger.log(
'No subjects found in grade $gradeLevel ($normGrade) on server.',
tag: 'CURRICULUM_REPO');
return [];
}
@@ -143,74 +188,130 @@ class CurriculumRepository {
}
});
AppLogger.log('Successfully parsed ${liveSubjects.length} live subjects for $gradeLevel: ${liveSubjects.map((s) => s.title).join(', ')}', tag: 'CURRICULUM_REPO');
AppLogger.log(
'Successfully parsed ${liveSubjects.length} live subjects for $gradeLevel: ${liveSubjects.map((s) => s.title).join(', ')}',
tag: 'CURRICULUM_REPO');
return liveSubjects;
}
Future<List<PublishedLessonVideoModel>> getPublishedLessonVideos(String curriculumLessonId) async {
AppLogger.event('FetchPublishedVideosRequested', details: {'curriculumLessonId': curriculumLessonId}, tag: 'CURRICULUM_REPO');
final res = await _api.get('/api/curriculum/lessons/$curriculumLessonId/videos');
final data = res is Map ? res['data'] : null;
final raw = data is Map ? data['items'] : null;
if (raw is! List) return const [];
final list = raw.whereType<Map>().map((item) => PublishedLessonVideoModel.fromJson(Map<String, dynamic>.from(item))).where((item) => item.videoVersionId.isNotEmpty).toList();
AppLogger.event('FetchPublishedVideosSuccess', details: {'count': list.length, 'teachers': list.map((v) => v.teacherName).toList()}, tag: 'CURRICULUM_REPO');
return list;
Future<List<PublishedLessonVideoModel>> getPublishedLessonVideos(
String curriculumLessonId) async {
return (await getPublishedLessonVideosPage(curriculumLessonId)).items;
}
Future<LessonPlaybackData> getVideoVersionPlayback(String videoVersionId) async {
AppLogger.event('FetchVideoPlaybackRequested', details: {'videoVersionId': videoVersionId}, tag: 'CURRICULUM_REPO');
Future<PublishedLessonVideoPage> getPublishedLessonVideosPage(
String curriculumLessonId,
{int cursor = 0,
int limit = 20}) async {
AppLogger.event('FetchPublishedVideosRequested',
details: {'curriculumLessonId': curriculumLessonId},
tag: 'CURRICULUM_REPO');
final res = await _api.get(
'/api/curriculum/lessons/$curriculumLessonId/videos',
queryParams: {'cursor': cursor, 'limit': limit});
final data = res is Map ? res['data'] : null;
final raw = data is Map ? data['items'] : null;
if (raw is! List || data is! Map)
throw ApiException('استجابة قائمة الحصص غير صالحة.');
final list = raw
.whereType<Map>()
.map((item) =>
PublishedLessonVideoModel.fromJson(Map<String, dynamic>.from(item)))
.where((item) => item.videoVersionId.isNotEmpty)
.toList();
AppLogger.event('FetchPublishedVideosSuccess',
details: {
'count': list.length,
'teachers': list.map((v) => v.teacherName).toList()
},
tag: 'CURRICULUM_REPO');
return PublishedLessonVideoPage(
items: list,
availableCount: (data['available_count'] as num?)?.toInt() ?? list.length,
nextCursor: (data['next_cursor'] as num?)?.toInt(),
);
}
Future<LessonPlaybackData> getVideoVersionPlayback(
String videoVersionId) async {
AppLogger.event('FetchVideoPlaybackRequested',
details: {'videoVersionId': videoVersionId}, tag: 'CURRICULUM_REPO');
final res = await _api.get('/api/video-versions/$videoVersionId/playback');
if (res is Map && res['data'] is Map) {
final pb = LessonPlaybackData.fromJson(Map<String, dynamic>.from(res['data']));
AppLogger.event('FetchVideoPlaybackSuccess', details: {
'videoVersionId': pb.videoVersionId,
'videoUrl': pb.videoUrl,
'duration': pb.durationSeconds,
}, tag: 'CURRICULUM_REPO');
final pb =
LessonPlaybackData.fromJson(Map<String, dynamic>.from(res['data']));
AppLogger.event('FetchVideoPlaybackSuccess',
details: {
'videoVersionId': pb.videoVersionId,
'duration': pb.durationSeconds,
},
tag: 'CURRICULUM_REPO');
return pb;
}
throw ApiException('فشل جلب تشغيل الحصة المنشورة من الخادم');
}
Future<String> startWatchSession(String videoVersionId) async {
AppLogger.event('StartWatchSessionRequested', details: {'videoVersionId': videoVersionId}, tag: 'CURRICULUM_REPO');
final res = await _api.post('/api/video-versions/$videoVersionId/watch-sessions', body: const {});
AppLogger.event('StartWatchSessionRequested',
details: {'videoVersionId': videoVersionId}, tag: 'CURRICULUM_REPO');
final res = await _api.post(
'/api/video-versions/$videoVersionId/watch-sessions',
body: const {});
final data = res is Map ? res['data'] : null;
final source = data is Map ? data : res;
final id = source is Map ? source['watch_session_id']?.toString() : null;
if (id == null || id.isEmpty) throw ApiException('لم ينشئ الخادم جلسة مشاهدة.');
AppLogger.event('StartWatchSessionSuccess', details: {'sessionId': id}, tag: 'CURRICULUM_REPO');
if (id == null || id.isEmpty)
throw ApiException('لم ينشئ الخادم جلسة مشاهدة.');
AppLogger.event('StartWatchSessionSuccess',
details: {'sessionId': id}, tag: 'CURRICULUM_REPO');
return id;
}
Future<void> recordWatchEvent(String sessionId, int sequenceNo, String eventType, int positionMs) async {
AppLogger.event('RecordWatchEvent', details: {
'sessionId': sessionId,
'seq': sequenceNo,
'type': eventType,
'positionMs': positionMs,
}, tag: 'CURRICULUM_REPO');
await _api.post('/api/watch-sessions/$sessionId/events', body: {'sequence_no': sequenceNo, 'event_type': eventType, 'position_ms': positionMs});
Future<void> recordWatchEvent(String sessionId, int sequenceNo,
String eventType, int positionMs) async {
AppLogger.event('RecordWatchEvent',
details: {
'sessionId': sessionId,
'seq': sequenceNo,
'type': eventType,
'positionMs': positionMs,
},
tag: 'CURRICULUM_REPO');
await _api.post('/api/watch-sessions/$sessionId/events', body: {
'sequence_no': sequenceNo,
'event_type': eventType,
'position_ms': positionMs
});
}
Future<void> saveProgress({required int lessonId, required int positionSeconds, required int watchedSeconds}) async {
AppLogger.event('SaveProgressRequested', details: {
'lessonId': lessonId,
'position': positionSeconds,
'watched': watchedSeconds,
}, tag: 'CURRICULUM_REPO');
Future<void> saveProgress(
{required int lessonId,
required int positionSeconds,
required int watchedSeconds}) async {
AppLogger.event('SaveProgressRequested',
details: {
'lessonId': lessonId,
'position': positionSeconds,
'watched': watchedSeconds,
},
tag: 'CURRICULUM_REPO');
await _api.post('/api/student/lessons/$lessonId/progress', body: {
'position_seconds': positionSeconds,
'watched_seconds': watchedSeconds,
});
}
Future<void> submitCheckpoint({required int examId, required int questionId, required int optionId}) async {
AppLogger.event('SubmitCheckpointRequested', details: {
'examId': examId,
'questionId': questionId,
'optionId': optionId,
}, tag: 'CURRICULUM_REPO');
Future<void> submitCheckpoint(
{required int examId,
required int questionId,
required int optionId}) async {
AppLogger.event('SubmitCheckpointRequested',
details: {
'examId': examId,
'questionId': questionId,
'optionId': optionId,
},
tag: 'CURRICULUM_REPO');
await _api.post('/api/exams/$examId/submit', body: {
'answers': [
{'question_id': questionId, 'selected_option_id': optionId}
@@ -226,8 +327,36 @@ class PublishedLessonVideoModel {
final double rating;
final int ratingCount;
final bool isNew;
const PublishedLessonVideoModel({required this.videoVersionId, required this.submissionId, required this.teacherName, required this.rating, required this.ratingCount, required this.isNew});
factory PublishedLessonVideoModel.fromJson(Map<String, dynamic> json) => PublishedLessonVideoModel(
videoVersionId: json['video_version_id']?.toString() ?? '', submissionId: json['submission_id']?.toString() ?? '', teacherName: json['teacher_name']?.toString() ?? 'معلم معتمد', rating: (json['rating'] as num?)?.toDouble() ?? 0, ratingCount: (json['rating_count'] as num?)?.toInt() ?? 0, isNew: json['is_new'] == true,
);
final int durationSeconds;
final DateTime? publishedAt;
const PublishedLessonVideoModel(
{required this.videoVersionId,
required this.submissionId,
required this.teacherName,
required this.rating,
required this.ratingCount,
required this.isNew,
required this.durationSeconds,
this.publishedAt});
factory PublishedLessonVideoModel.fromJson(Map<String, dynamic> json) =>
PublishedLessonVideoModel(
videoVersionId: json['video_version_id']?.toString() ?? '',
submissionId: json['submission_id']?.toString() ?? '',
teacherName: json['teacher_name']?.toString() ?? '',
rating: (json['rating'] as num?)?.toDouble() ?? 0,
ratingCount: (json['rating_count'] as num?)?.toInt() ?? 0,
isNew: json['is_new'] == true,
durationSeconds: (json['duration_seconds'] as num?)?.toInt() ?? 0,
publishedAt: DateTime.tryParse(json['published_at']?.toString() ?? ''),
);
}
class PublishedLessonVideoPage {
final List<PublishedLessonVideoModel> items;
final int availableCount;
final int? nextCursor;
const PublishedLessonVideoPage(
{required this.items,
required this.availableCount,
required this.nextCursor});
}
@@ -23,18 +23,7 @@ class ErrorNotebookRepository {
'summary': ErrorNotebookSummary.fromJson(Map<String, dynamic>.from(data['summary'] as Map? ?? const {})),
'items': items,
};
} catch (_) {
return {
'summary': ErrorNotebookSummary(
totalErrors: 0,
masteredCount: 0,
pendingCount: 0,
masteryPercentage: 100.0,
bySubject: {},
),
'items': <ErrorNotebookItem>[],
};
}
} catch (_) { rethrow; }
}
Future<Map<String, dynamic>> getChildErrorNotebook(int studentId) async {
@@ -48,18 +37,7 @@ class ErrorNotebookRepository {
'summary': ErrorNotebookSummary.fromJson(Map<String, dynamic>.from(data['summary'] as Map? ?? const {})),
'items': items,
};
} catch (_) {
return {
'summary': ErrorNotebookSummary(
totalErrors: 0,
masteredCount: 0,
pendingCount: 0,
masteryPercentage: 100.0,
bySubject: {},
),
'items': <ErrorNotebookItem>[],
};
}
} catch (_) { rethrow; }
}
Future<String?> logError({
@@ -99,30 +77,33 @@ class ErrorNotebookRepository {
return null;
}
Future<List<RemedialQuestion>> getRemediationQuiz({required String errorUuid, required String topicName}) async {
Future<Map<String, dynamic>> getRemediationQuiz({required String errorUuid}) async {
try {
final decoded = await _api.get(
final decoded = await _api.post(
'/api/student/error-notebook/remediation-quiz',
queryParams: {'error_uuid': errorUuid, 'topic_name': topicName},
body: {'error_uuid': errorUuid},
);
final data = Map<String, dynamic>.from(decoded['data'] as Map? ?? const {});
return (data['questions'] as List? ?? const [])
.map((item) => RemedialQuestion.fromJson(Map<String, dynamic>.from(item as Map)))
.toList();
return {
'attempt_uuid': data['attempt_uuid']?.toString() ?? '',
'questions': (data['questions'] as List? ?? const [])
.map((item) => RemedialQuestion.fromJson(Map<String, dynamic>.from(item as Map)))
.toList(),
};
} catch (_) {
return [];
return {'attempt_uuid': '', 'questions': <RemedialQuestion>[]};
}
}
Future<bool> resolveError(String errorUuid) async {
Future<Map<String, dynamic>?> resolveError(String errorUuid, String attemptUuid, List<int> answers) async {
try {
final decoded = await _api.post(
'/api/student/error-notebook/resolve',
body: {'error_uuid': errorUuid},
body: {'error_uuid': errorUuid, 'attempt_uuid': attemptUuid, 'answers': answers},
);
return decoded is Map && decoded['status'] == 'success';
return decoded is Map && decoded['status'] == 'success' ? Map<String, dynamic>.from(decoded) : null;
} catch (_) {
return false;
return null;
}
}
}