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
+65 -14
View File
@@ -6,13 +6,57 @@ import 'package:flutter/foundation.dart';
class AppLogger {
AppLogger._();
static const _sensitiveKeyParts = [
'authorization',
'token',
'secret',
'password',
'phone',
'national',
'identity',
'otp',
'ticket',
'email',
'fingerprint',
'pin',
'address',
];
static bool _isSensitiveKey(String key) {
final normalized = key.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]'), '');
return _sensitiveKeyParts.any(normalized.contains);
}
static dynamic _safeLogValue(dynamic value, {String key = ''}) {
if (_isSensitiveKey(key)) return '[REDACTED]';
if (value is Map) {
return value.map((k, v) => MapEntry(
k.toString(),
_safeLogValue(v, key: k.toString()),
));
}
if (value is List) {
return value.map((v) => _safeLogValue(v)).toList();
}
if (value is String && key.isEmpty) return '[TEXT OMITTED]';
return value;
}
static Uri _safeUri(Uri uri) {
final safeQuery = Map<String, String>.fromEntries(
uri.queryParameters.entries.where((entry) => !_isSensitiveKey(entry.key)),
);
return uri.replace(queryParameters: safeQuery.isEmpty ? null : safeQuery);
}
static void log(String message, {String tag = 'SAQEL'}) {
if (kDebugMode) {
debugPrint('[$tag] $message');
}
}
static void event(String name, {Map<String, dynamic>? details, String tag = 'EVENT'}) {
static void event(String name,
{Map<String, dynamic>? details, String tag = 'EVENT'}) {
if (!kDebugMode) return;
final buffer = StringBuffer();
buffer.write('⚡ [$tag] $name');
@@ -35,21 +79,23 @@ class AppLogger {
if (!kDebugMode) return;
final buffer = StringBuffer();
buffer.writeln('\n🌐 ─── [HTTP REQUEST] ──────────────────────────────────────────');
buffer.writeln(
'\n🌐 ─── [HTTP REQUEST] ──────────────────────────────────────────');
buffer.writeln('➡️ Method: $method');
buffer.writeln('🔗 URL: $uri');
buffer.writeln('🔗 URL: ${_safeUri(uri)}');
if (headers != null && headers.isNotEmpty) {
final safeHeaders = Map<String, String>.from(headers);
if (safeHeaders.containsKey('Authorization')) {
final auth = safeHeaders['Authorization']!;
safeHeaders['Authorization'] = auth.length > 20 ? '${auth.substring(0, 15)}...' : 'Bearer [REDACTED]';
safeHeaders['Authorization'] = '[REDACTED]';
}
safeHeaders.removeWhere((key, _) => _isSensitiveKey(key));
buffer.writeln('📋 Headers: ${jsonEncode(safeHeaders)}');
}
if (body != null) {
buffer.writeln('📦 Body: ${body is String ? body : jsonEncode(body)}');
buffer.writeln('📦 Body: ${jsonEncode(_safeLogValue(body))}');
}
buffer.writeln('───────────────────────────────────────────────────────────────');
buffer.writeln(
'───────────────────────────────────────────────────────────────');
debugPrint(buffer.toString());
}
@@ -66,24 +112,28 @@ class AppLogger {
final emoji = isSuccess ? '✅' : '⚠️';
final buffer = StringBuffer();
buffer.writeln('\n$emoji ─── [HTTP RESPONSE $statusCode] ────────────────────────────────');
buffer.writeln(
'\n$emoji ─── [HTTP RESPONSE $statusCode] ────────────────────────────────');
buffer.writeln('⬅️ Method: $method');
buffer.writeln('🔗 URL: $uri');
buffer.writeln('🔗 URL: ${_safeUri(uri)}');
if (duration != null) {
buffer.writeln('⏱️ Time: ${duration.inMilliseconds}ms');
}
if (responseBody != null) {
buffer.writeln('📦 Data: ${responseBody is String ? responseBody : jsonEncode(responseBody)}');
buffer.writeln('📦 Data: ${jsonEncode(_safeLogValue(responseBody))}');
}
buffer.writeln('───────────────────────────────────────────────────────────────');
buffer.writeln(
'───────────────────────────────────────────────────────────────');
debugPrint(buffer.toString());
}
static void error(String message, {dynamic error, StackTrace? stackTrace, String tag = 'ERROR'}) {
static void error(String message,
{dynamic error, StackTrace? stackTrace, String tag = 'ERROR'}) {
if (!kDebugMode) return;
final buffer = StringBuffer();
buffer.writeln('\n🚨 ─── [ERROR: $tag] ──────────────────────────────────────────');
buffer.writeln(
'\n🚨 ─── [ERROR: $tag] ──────────────────────────────────────────');
buffer.writeln('❌ Message: $message');
if (error != null) {
buffer.writeln('⚠️ Error: $error');
@@ -91,7 +141,8 @@ class AppLogger {
if (stackTrace != null) {
buffer.writeln('📍 Trace: \n$stackTrace');
}
buffer.writeln('───────────────────────────────────────────────────────────────');
buffer.writeln(
'───────────────────────────────────────────────────────────────');
debugPrint(buffer.toString());
}
}
@@ -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;
}
}
}
@@ -9,6 +9,7 @@ import '../../data/repositories/error_notebook_repository.dart';
abstract class VideoPlaybackState {}
class VideoPlaybackInitial extends VideoPlaybackState {}
class VideoPlaybackLoading extends VideoPlaybackState {}
class VideoPlaybackReady extends VideoPlaybackState {
@@ -53,9 +54,12 @@ class VideoPlaybackReady extends VideoPlaybackState {
playbackData: playbackData ?? this.playbackData,
lessonItem: lessonItem ?? this.lessonItem,
subject: subject ?? this.subject,
currentPositionSeconds: currentPositionSeconds ?? this.currentPositionSeconds,
currentPositionSeconds:
currentPositionSeconds ?? this.currentPositionSeconds,
isPlaying: isPlaying ?? this.isPlaying,
activeCheckpoint: clearActiveCheckpoint ? null : (activeCheckpoint ?? this.activeCheckpoint),
activeCheckpoint: clearActiveCheckpoint
? null
: (activeCheckpoint ?? this.activeCheckpoint),
isCheckpointPassed: isCheckpointPassed ?? this.isCheckpointPassed,
remediationNotice: remediationNotice,
readinessBonusAdded: readinessBonusAdded ?? this.readinessBonusAdded,
@@ -80,8 +84,10 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
: _repo = repo ?? CurriculumRepository(),
super(VideoPlaybackInitial());
String _getLessonStorageKey(CurriculumLessonItemModel? lesson, {String? fallbackId}) {
if (lesson?.markdownFilePath != null && lesson!.markdownFilePath!.isNotEmpty) {
String _getLessonStorageKey(CurriculumLessonItemModel? lesson,
{String? fallbackId}) {
if (lesson?.markdownFilePath != null &&
lesson!.markdownFilePath!.isNotEmpty) {
return lesson.markdownFilePath!.replaceAll(RegExp(r'[^a-zA-Z0-9_]'), '_');
}
if (lesson?.id != null && lesson!.id.isNotEmpty) {
@@ -90,14 +96,17 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
return fallbackId ?? 'lesson_default';
}
Future<void> loadLesson(CurriculumLessonItemModel lesson, {SubjectModel? subject, String? selectedVideoVersionId}) async {
AppLogger.event('LoadLessonPlaybackRequested', details: {
'lessonTitle': lesson.title,
'lessonId': lesson.id,
'curriculumLessonId': lesson.curriculumLessonId,
'selectedVersionId': selectedVideoVersionId,
'hasVideo': lesson.hasVideo,
}, tag: 'VIDEO_CUBIT');
Future<void> loadLesson(CurriculumLessonItemModel lesson,
{SubjectModel? subject, String? selectedVideoVersionId}) async {
AppLogger.event('LoadLessonPlaybackRequested',
details: {
'lessonTitle': lesson.title,
'lessonId': lesson.id,
'curriculumLessonId': lesson.curriculumLessonId,
'selectedVersionId': selectedVideoVersionId,
'hasVideo': lesson.hasVideo,
},
tag: 'VIDEO_CUBIT');
_lastObservedPosition = -1;
emit(VideoPlaybackLoading());
final storageKey = _getLessonStorageKey(lesson);
@@ -108,32 +117,38 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
throw StateError('هذا الدرس غير منشور بعد ضمن حزمة المحتوى المعتمدة.');
}
final published = await _repo.getPublishedLessonVideos(versionId);
AppLogger.event('PublishedVideosLoaded', details: {
'count': published.length,
'teachers': published.map((p) => '${p.teacherName} (⭐ ${p.rating.toStringAsFixed(1)})').toList(),
}, tag: 'VIDEO_CUBIT');
if (published.isEmpty) throw StateError('لا توجد حصة منشورة ومصرح بها لهذا الدرس بعد.');
AppLogger.event('PublishedVideosLoaded',
details: {
'count': published.length,
'teachers': published
.map((p) =>
'${p.teacherName} (⭐ ${p.rating.toStringAsFixed(1)})')
.toList(),
},
tag: 'VIDEO_CUBIT');
if (published.isEmpty)
throw StateError('لا توجد حصة منشورة ومصرح بها لهذا الدرس بعد.');
final targetVersionId = selectedVideoVersionId ?? published.first.videoVersionId;
final targetVersionId =
selectedVideoVersionId ?? published.first.videoVersionId;
var playback = await _repo.getVideoVersionPlayback(targetVersionId);
AppLogger.event('PlaybackDataLoaded', details: {
'versionId': playback.videoVersionId,
'videoUrl': playback.videoUrl,
'durationSeconds': playback.durationSeconds,
'checkpointsCount': playback.checkpoints.length,
}, tag: 'VIDEO_CUBIT');
AppLogger.event('PlaybackDataLoaded',
details: {
'versionId': playback.videoVersionId,
'durationSeconds': playback.durationSeconds,
'checkpointsCount': playback.checkpoints.length,
},
tag: 'VIDEO_CUBIT');
if (playback.videoUrl.isEmpty) {
throw StateError('لم يربط الخادم فيديو R2 بهذا الدرس بعد.');
}
_watchSessionId = null; _watchSequence = 1; _lastWatchEventPosition = 0;
if (playback.videoVersionId != null && playback.videoVersionId!.isNotEmpty) {
try {
_watchSessionId = await _repo.startWatchSession(playback.videoVersionId!);
AppLogger.event('WatchSessionStarted', details: {'sessionId': _watchSessionId}, tag: 'VIDEO_CUBIT');
} catch (_) {}
}
// A watch session represents actual playback. The screen starts it only after
// the exact published video URL initializes successfully.
_watchSessionId = null;
_watchSequence = 1;
_lastWatchEventPosition = 0;
// Load saved resume position strictly per video
int resumePos = playback.lastPositionSeconds ?? 0;
@@ -142,14 +157,20 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
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();
final savedPassed =
prefs.getStringList('passed_checkpoints_$storageKey') ?? [];
passedIds = savedPassed
.map((s) => int.tryParse(s) ?? 0)
.where((id) => id > 0)
.toSet();
} catch (_) {}
AppLogger.event('VideoPlaybackStateEmitted', details: {
'resumePosition': resumePos,
'passedCheckpointsCount': passedIds.length,
}, tag: 'VIDEO_CUBIT');
AppLogger.event('VideoPlaybackStateEmitted',
details: {
'resumePosition': resumePos,
'passedCheckpointsCount': passedIds.length,
},
tag: 'VIDEO_CUBIT');
emit(VideoPlaybackReady(
playbackData: playback,
@@ -157,26 +178,33 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
subject: subject,
currentPositionSeconds: resumePos,
passedCheckpointIds: passedIds,
isPlaying: true,
// Playback becomes active only after the screen initializes the actual stream.
isPlaying: false,
));
} catch (e) {
AppLogger.error('Playback API unavailable (${lesson.title})', error: e, tag: 'VIDEO_CUBIT');
AppLogger.error('Playback API unavailable (${lesson.title})',
error: e, tag: 'VIDEO_CUBIT');
emit(VideoPlaybackError(e.toString()));
}
}
void updatePosition(int seconds) {
final currentState = state;
if (currentState is VideoPlaybackReady && currentState.activeCheckpoint == null) {
if (currentState is VideoPlaybackReady &&
currentState.activeCheckpoint == null) {
// Check if natural forward playback reaches a Socratic checkpoint
for (var cp in currentState.playbackData.checkpoints) {
final hitCheckpoint = !currentState.passedCheckpointIds.contains(cp.id) &&
_lastObservedPosition < cp.timestampSeconds &&
seconds >= cp.timestampSeconds &&
(seconds - _lastObservedPosition).abs() <= 3; // Natural sequential playback
final hitCheckpoint =
!currentState.passedCheckpointIds.contains(cp.id) &&
_lastObservedPosition < cp.timestampSeconds &&
seconds >= cp.timestampSeconds &&
(seconds - _lastObservedPosition).abs() <=
3; // Natural sequential playback
if (hitCheckpoint) {
AppLogger.log('🚨 Socratic Checkpoint Triggered at ${cp.timestampSeconds}s! (${cp.questionText})', tag: 'SOCRATIC_ENGINE');
AppLogger.log(
'🚨 Socratic Checkpoint Triggered at ${cp.timestampSeconds}s! (${cp.questionText})',
tag: 'SOCRATIC_ENGINE');
_lastObservedPosition = cp.timestampSeconds;
emit(currentState.copyWith(
currentPositionSeconds: cp.timestampSeconds,
@@ -196,14 +224,43 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
final session = _watchSessionId;
if (session == null) return;
_watchSessionId = null;
try { await _repo.recordWatchEvent(session, ++_watchSequence, 'end', _lastObservedPosition.clamp(0, 1 << 31) * 1000); } catch (_) {}
try {
await _repo.recordWatchEvent(session, ++_watchSequence, 'end',
_lastObservedPosition.clamp(0, 1 << 31) * 1000);
} catch (_) {}
}
Future<void> startWatchSessionAfterPlaybackReady() async {
final currentState = state;
final versionId = currentState is VideoPlaybackReady
? currentState.playbackData.videoVersionId
: null;
if (_watchSessionId != null || versionId == null || versionId.isEmpty)
return;
try {
_watchSequence = 1;
_lastWatchEventPosition = 0;
_watchSessionId = await _repo.startWatchSession(versionId);
AppLogger.event('WatchSessionStartedAfterVideoReady',
details: {
'sessionId': _watchSessionId,
'videoVersionId': versionId,
},
tag: 'VIDEO_CUBIT');
} catch (e) {
AppLogger.log(
'Watch session unavailable after playback initialization: $e',
tag: 'VIDEO_CUBIT');
}
}
void togglePlayPause() {
final currentState = state;
if (currentState is VideoPlaybackReady && currentState.activeCheckpoint == null) {
if (currentState is VideoPlaybackReady &&
currentState.activeCheckpoint == null) {
final nextState = !currentState.isPlaying;
AppLogger.event('VideoPlayPauseToggled', details: {'isPlaying': nextState}, tag: 'VIDEO_CUBIT');
AppLogger.event('VideoPlayPauseToggled',
details: {'isPlaying': nextState}, tag: 'VIDEO_CUBIT');
emit(currentState.copyWith(isPlaying: nextState));
}
}
@@ -218,7 +275,9 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
void play() {
final currentState = state;
if (currentState is VideoPlaybackReady && !currentState.isPlaying && currentState.activeCheckpoint == null) {
if (currentState is VideoPlaybackReady &&
!currentState.isPlaying &&
currentState.activeCheckpoint == null) {
AppLogger.event('VideoResumed', tag: 'VIDEO_CUBIT');
emit(currentState.copyWith(isPlaying: true));
}
@@ -230,23 +289,29 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
// Find the earliest unpassed checkpoint before or at this target
SocraticCheckpointModel? blockingCheckpoint;
for (var cp in currentState.playbackData.checkpoints) {
if (!currentState.passedCheckpointIds.contains(cp.id) && seconds > cp.timestampSeconds) {
if (blockingCheckpoint == null || cp.timestampSeconds < blockingCheckpoint.timestampSeconds) {
if (!currentState.passedCheckpointIds.contains(cp.id) &&
seconds > cp.timestampSeconds) {
if (blockingCheckpoint == null ||
cp.timestampSeconds < blockingCheckpoint.timestampSeconds) {
blockingCheckpoint = cp;
}
}
}
// If user tries to skip past an unpassed checkpoint, snap precisely to that checkpoint
final actualSeek = blockingCheckpoint != null ? blockingCheckpoint.timestampSeconds : seconds;
final actualSeek = blockingCheckpoint != null
? blockingCheckpoint.timestampSeconds
: seconds;
_lastObservedPosition = actualSeek;
AppLogger.event('VideoSeekExecuted', details: {
'targetSeconds': seconds,
'actualSeek': actualSeek,
'blockedByCheckpoint': blockingCheckpoint != null,
'checkpointQuestion': blockingCheckpoint?.questionText,
}, tag: 'VIDEO_CUBIT');
AppLogger.event('VideoSeekExecuted',
details: {
'targetSeconds': seconds,
'actualSeek': actualSeek,
'blockedByCheckpoint': blockingCheckpoint != null,
'checkpointQuestion': blockingCheckpoint?.questionText,
},
tag: 'VIDEO_CUBIT');
emit(currentState.copyWith(
currentPositionSeconds: actualSeek,
@@ -256,11 +321,13 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
}
}
Future<void> saveProgress({required int positionSeconds, required int watchedSeconds}) async {
Future<void> saveProgress(
{required int positionSeconds, required int watchedSeconds}) async {
final currentState = state;
if (currentState is! VideoPlaybackReady) return;
try {
final storageKey = _getLessonStorageKey(currentState.lessonItem, fallbackId: '${currentState.playbackData.lessonId}');
final storageKey = _getLessonStorageKey(currentState.lessonItem,
fallbackId: '${currentState.playbackData.lessonId}');
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('saved_video_pos_$storageKey', positionSeconds);
if (currentState.passedCheckpointIds.isNotEmpty) {
@@ -270,11 +337,17 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
);
}
if (currentState.playbackData.lessonId > 0) {
await _repo.saveProgress(lessonId: currentState.playbackData.lessonId, positionSeconds: positionSeconds, watchedSeconds: watchedSeconds);
await _repo.saveProgress(
lessonId: currentState.playbackData.lessonId,
positionSeconds: positionSeconds,
watchedSeconds: watchedSeconds);
}
if (_watchSessionId != null && positionSeconds - _lastWatchEventPosition >= 30) {
_watchSequence++; _lastWatchEventPosition = positionSeconds;
await _repo.recordWatchEvent(_watchSessionId!, _watchSequence, 'heartbeat', positionSeconds * 1000);
if (_watchSessionId != null &&
positionSeconds - _lastWatchEventPosition >= 30) {
_watchSequence++;
_lastWatchEventPosition = positionSeconds;
await _repo.recordWatchEvent(_watchSessionId!, _watchSequence,
'heartbeat', positionSeconds * 1000);
}
} catch (e) {
AppLogger.log('Progress sync deferred: $e', tag: 'VIDEO_CUBIT');
@@ -284,24 +357,30 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
/// Submit Socratic Checkpoint Answer
bool submitCheckpointAnswer(SocraticOptionModel selectedOption) {
final currentState = state;
if (currentState is VideoPlaybackReady && currentState.activeCheckpoint != null) {
if (currentState is VideoPlaybackReady &&
currentState.activeCheckpoint != null) {
final cp = currentState.activeCheckpoint!;
if (selectedOption.isCorrect) {
// Correct Answer -> Reward readiness score (+0.5%) & resume video
AppLogger.event('SocraticAnswerCorrect', details: {
'checkpointId': cp.id,
'question': cp.questionText,
'selected': selectedOption.text,
'bonus': 0.5,
}, tag: 'SOCRATIC_ENGINE');
final updatedPassed = Set<int>.from(currentState.passedCheckpointIds)..add(cp.id);
AppLogger.event('SocraticAnswerCorrect',
details: {
'checkpointId': cp.id,
'question': cp.questionText,
'selected': selectedOption.text,
'bonus': 0.5,
},
tag: 'SOCRATIC_ENGINE');
final updatedPassed = Set<int>.from(currentState.passedCheckpointIds)
..add(cp.id);
_lastObservedPosition = cp.timestampSeconds;
// Persist passed checkpoint for this specific video
final storageKey = _getLessonStorageKey(currentState.lessonItem, fallbackId: '${currentState.playbackData.lessonId}');
final storageKey = _getLessonStorageKey(currentState.lessonItem,
fallbackId: '${currentState.playbackData.lessonId}');
SharedPreferences.getInstance().then((prefs) {
prefs.setStringList('passed_checkpoints_$storageKey', updatedPassed.map((id) => id.toString()).toList());
prefs.setStringList('passed_checkpoints_$storageKey',
updatedPassed.map((id) => id.toString()).toList());
}).catchError((_) {});
emit(currentState.copyWith(
@@ -309,36 +388,47 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
isPlaying: true,
readinessBonusAdded: currentState.readinessBonusAdded + 0.5,
passedCheckpointIds: updatedPassed,
remediationNotice: 'إجابة نموذجية ممتازة! تم تعزيز مؤشر الجاهزية (+0.5%) 🚀',
remediationNotice:
'إجابة نموذجية ممتازة! تم تعزيز مؤشر الجاهزية (+0.5%) 🚀',
));
if (cp.id > 0) {
_repo.submitCheckpoint(examId: cp.id, questionId: cp.questionId, optionId: selectedOption.id).catchError((e) {
_repo
.submitCheckpoint(
examId: cp.id,
questionId: cp.questionId,
optionId: selectedOption.id)
.catchError((e) {
AppLogger.log('Checkpoint sync deferred: $e', tag: 'VIDEO_CUBIT');
});
}
return true;
} else {
// Wrong Answer -> Socratic Productive Struggle: Rewind video by N seconds from checkpoint
final rewindTo = (cp.timestampSeconds - cp.rewindSecondsOnFail).clamp(0, currentState.playbackData.durationSeconds);
final rewindTo = (cp.timestampSeconds - cp.rewindSecondsOnFail)
.clamp(0, currentState.playbackData.durationSeconds);
_lastObservedPosition = rewindTo;
AppLogger.event('SocraticAnswerIncorrect', details: {
'checkpointId': cp.id,
'question': cp.questionText,
'selected': selectedOption.text,
'rewindSeconds': cp.rewindSecondsOnFail,
'rewindTo': rewindTo,
}, tag: 'SOCRATIC_ENGINE');
AppLogger.event('SocraticAnswerIncorrect',
details: {
'checkpointId': cp.id,
'question': cp.questionText,
'selected': selectedOption.text,
'rewindSeconds': cp.rewindSecondsOnFail,
'rewindTo': rewindTo,
},
tag: 'SOCRATIC_ENGINE');
emit(currentState.copyWith(
clearActiveCheckpoint: true,
currentPositionSeconds: rewindTo,
isPlaying: true,
remediationNotice: 'تعثرت في هذا المفهوم. تم إرجاع الفيديو ${cp.rewindSecondsOnFail} ثانية لإعادة الاستماع بتركيز 🔄',
remediationNotice:
'تعثرت في هذا المفهوم. تم إرجاع الفيديو ${cp.rewindSecondsOnFail} ثانية لإعادة الاستماع بتركيز 🔄',
));
// Auto-record gap to Smart Error Notebook
final correctOpt = cp.options.firstWhere(
(o) => o.isCorrect,
orElse: () => const SocraticOptionModel(id: 0, text: '', isCorrect: false),
orElse: () =>
const SocraticOptionModel(id: 0, text: '', isCorrect: false),
);
ErrorNotebookRepository().logError(
subjectId: currentState.subject?.id ?? 'physics_10',
@@ -350,12 +440,19 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
correctAnswer: correctOpt.text,
hint: cp.hint,
errorCategory: 'conceptual',
lessonId: currentState.playbackData.lessonId > 0 ? currentState.playbackData.lessonId : null,
lessonId: currentState.playbackData.lessonId > 0
? currentState.playbackData.lessonId
: null,
options: cp.options.map((o) => o.text).toList(),
);
if (cp.id > 0 && cp.questionId > 0) {
_repo.submitCheckpoint(examId: cp.id, questionId: cp.questionId, optionId: selectedOption.id).catchError((e) {
_repo
.submitCheckpoint(
examId: cp.id,
questionId: cp.questionId,
optionId: selectedOption.id)
.catchError((e) {
AppLogger.log('Checkpoint sync deferred: $e', tag: 'VIDEO_CUBIT');
});
}
File diff suppressed because it is too large Load Diff
@@ -116,7 +116,7 @@ class _SubjectsGridScreenState extends State<SubjectsGridScreen> {
Icon(CupertinoIcons.checkmark_seal_fill, color: AppColors.saqelCyan, size: 14),
SizedBox(width: 6),
Text(
'المنهاج الوزاري الأردني 🇯🇴',
'فهرس الدروس المنشورة',
style: TextStyle(color: AppColors.saqelCyan, fontSize: 11, fontWeight: FontWeight.w700),
),
],
@@ -141,7 +141,9 @@ class _SubjectsGridScreenState extends State<SubjectsGridScreen> {
const SizedBox(height: 14),
LuxuryButton(
text: 'إعادة المحاولة',
onPressed: () => context.read<CurriculumCubit>().fetchSubjects(),
onPressed: () => context.read<CurriculumCubit>().fetchSubjects(
gradeLevel: GradeLevelModel.normalizeGradeKey(widget.initialGrade ?? 'grade_10'),
),
),
],
),
@@ -162,7 +164,7 @@ class _SubjectsGridScreenState extends State<SubjectsGridScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'المواد المقررة — ${GradeLevelModel.getGradeName(state.selectedGrade)} 📚',
'المواد المنشورة — ${GradeLevelModel.getGradeName(state.selectedGrade)} 📚',
style: AppTypography.displayLarge.copyWith(color: Colors.white, fontSize: 20),
),
const SizedBox(height: 4),
@@ -188,7 +190,8 @@ class _SubjectsGridScreenState extends State<SubjectsGridScreen> {
),
const SizedBox(height: 16),
// Official Academic Grade Level Banner (Strict Grade-Gate — الصف العاشر فقط)
// The catalogue lists reviewed publications; do not claim
// ministry certification without a separate source of proof.
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
@@ -212,12 +215,12 @@ class _SubjectsGridScreenState extends State<SubjectsGridScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${GradeLevelModel.getGradeName(state.selectedGrade)} — المسار الأكاديمي المعتمد',
'${GradeLevelModel.getGradeName(state.selectedGrade)} — الدروس المنشورة',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 13.5),
),
const SizedBox(height: 2),
const Text(
'خطة دراسية موحدة ومطابقة للمنهاج الوزاري الأردني المطور',
'تظهر هنا الدروس التي اكتملت مراجعتها ونُشرت على المنصة',
style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 11),
),
],
@@ -255,15 +258,17 @@ class _SubjectsGridScreenState extends State<SubjectsGridScreen> {
child: const Icon(CupertinoIcons.square_grid_2x2, size: 32, color: AppColors.saqelCyan),
),
const SizedBox(height: 16),
const Text(
'لا توجد مواد مضافة بعد في السيرفر',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 16),
Text(
state.subjects.isEmpty ? 'لا توجد دروس منشورة لهذا الصف بعد' : 'لا توجد مواد تطابق البحث',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 16),
),
const SizedBox(height: 6),
const Text(
'يتم إدراج المواد تلقائياً بمجرد رفع وتحليل كتب المناهج في الخادم',
Text(
state.subjects.isEmpty
? 'يمكنك العودة لاحقاً للاطلاع على الحزم بعد نشرها.'
: 'جرّب اسم مادة مختلفاً أو امسح البحث.',
textAlign: TextAlign.center,
style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 13),
style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 13),
),
],
),
@@ -7,20 +7,26 @@ import '../../widgets/luxury_widgets.dart';
/// Apple Cupertino Sheet for choosing a teacher when multiple published video versions exist for a lesson.
/// Conforms to Saqel product rule: "أكثر من حصة متاحة: قائمة المعلمين مرتبة بالتقييم الموثوق، مع عدد التقييمات والتصفح على دفعات."
class TeacherSelectionSheet extends StatelessWidget {
class TeacherSelectionSheet extends StatefulWidget {
final CurriculumLessonItemModel lesson;
final List<PublishedLessonVideoModel> videos;
final int? nextCursor;
final int availableCount;
const TeacherSelectionSheet({
super.key,
required this.lesson,
required this.videos,
required this.nextCursor,
required this.availableCount,
});
static Future<PublishedLessonVideoModel?> show(
BuildContext context, {
required CurriculumLessonItemModel lesson,
required List<PublishedLessonVideoModel> videos,
required int? nextCursor,
required int availableCount,
}) {
return showModalBottomSheet<PublishedLessonVideoModel>(
context: context,
@@ -29,20 +35,43 @@ class TeacherSelectionSheet extends StatelessWidget {
builder: (sheetContext) => TeacherSelectionSheet(
lesson: lesson,
videos: videos,
nextCursor: nextCursor,
availableCount: availableCount,
),
);
}
@override
Widget build(BuildContext context) {
// Sort server-authoritatively by rating desc, then rating count desc
final sortedVideos = List<PublishedLessonVideoModel>.from(videos)
..sort((a, b) {
final cmp = b.rating.compareTo(a.rating);
if (cmp != 0) return cmp;
return b.ratingCount.compareTo(a.ratingCount);
});
State<TeacherSelectionSheet> createState() => _TeacherSelectionSheetState();
}
class _TeacherSelectionSheetState extends State<TeacherSelectionSheet> {
late final List<PublishedLessonVideoModel> _videos = List.of(widget.videos);
late int? _nextCursor = widget.nextCursor;
bool _loading = false;
bool _failed = false;
Future<void> _loadMore() async {
final cursor = _nextCursor;
final lessonId = widget.lesson.curriculumLessonId;
if (_loading || cursor == null || lessonId == null) return;
setState(() { _loading = true; _failed = false; });
try {
final page = await CurriculumRepository().getPublishedLessonVideosPage(lessonId, cursor: cursor, limit: 20);
if (!mounted) return;
final known = _videos.map((video) => video.videoVersionId).toSet();
setState(() {
_videos.addAll(page.items.where((video) => known.add(video.videoVersionId)));
_nextCursor = page.nextCursor;
_loading = false;
});
} catch (_) {
if (mounted) setState(() { _loading = false; _failed = true; });
}
}
@override
Widget build(BuildContext context) {
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.82,
@@ -103,7 +132,7 @@ class TeacherSelectionSheet extends StatelessWidget {
),
const SizedBox(height: 2),
Text(
lesson.title,
widget.lesson.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
@@ -161,23 +190,21 @@ class TeacherSelectionSheet extends StatelessWidget {
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
shrinkWrap: true,
itemCount: sortedVideos.length,
itemCount: _videos.length + (_nextCursor != null ? 1 : 0),
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (ctx, idx) {
final video = sortedVideos[idx];
if (idx == _videos.length) {
return Center(child: TextButton(
onPressed: _loading ? null : _loadMore,
child: Text(_loading ? 'جارٍ تحميل المزيد…' : _failed ? 'تعذر التحميل — أعد المحاولة' : 'عرض المزيد (${_videos.length} من ${widget.availableCount})'),
));
}
final video = _videos[idx];
final isNew = video.ratingCount == 0;
final initials = video.teacherName.isNotEmpty
? video.teacherName.trim().characters.first
: 'م';
final approaches = [
'تركيز على خطوات الحل الوزاري والتحليل المفاهيمي',
'تبسيط القواعد مع أمثلة حياتية وتطبيقات واقعية',
'تدريبات مكثفة ونماذج امتحانات وزارية سابقة',
'شرح تفاعلي مرئي مع استنتاج القوانين خطوة بخطوة',
];
final approach = approaches[idx % approaches.length];
return LuxuryCard(
borderColor: idx == 0
? AppColors.saqelCyan.withAlpha(90)
@@ -344,8 +371,8 @@ class TeacherSelectionSheet extends StatelessWidget {
),
const SizedBox(height: 10),
// Pedagogical approach badge
Container(
// Only metadata supplied by the published video.
if (video.durationSeconds > 0 || video.publishedAt != null) Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 5),
decoration: BoxDecoration(
@@ -356,14 +383,17 @@ class TeacherSelectionSheet extends StatelessWidget {
child: Row(
children: [
const Icon(
CupertinoIcons.lightbulb_fill,
CupertinoIcons.time,
color: AppColors.guardianAmber,
size: 12,
),
const SizedBox(width: 6),
Expanded(
child: Text(
approach,
[
if (video.durationSeconds > 0) '${(video.durationSeconds / 60).ceil()} دقيقة',
if (video.publishedAt != null) 'نُشرت ${video.publishedAt!.year}-${video.publishedAt!.month.toString().padLeft(2, '0')}-${video.publishedAt!.day.toString().padLeft(2, '0')}',
].join(' · '),
style: const TextStyle(
color: AppColors.textSecondaryDark,
fontSize: 11,
@@ -42,7 +42,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
@override
void didUpdateWidget(covariant UnifiedHomeScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.user.gradeLevel != widget.user.gradeLevel || oldWidget.user.stream != widget.user.stream) {
if (oldWidget.user.gradeLevel != widget.user.gradeLevel ||
oldWidget.user.stream != widget.user.stream) {
_refreshData();
}
}
@@ -50,10 +51,10 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
void _refreshData() {
if (_currentRole == 'student') {
context.read<StudentCubit>().fetchLessons(
gradeLevel: widget.user.gradeLevel,
stream: widget.user.stream,
initialReadiness: widget.user.readinessScore ?? 0.0,
);
gradeLevel: widget.user.gradeLevel,
stream: widget.user.stream,
initialReadiness: widget.user.readinessScore ?? 0.0,
);
} else {
context.read<GuardianCubit>().fetchDashboard();
}
@@ -83,7 +84,9 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
decoration: const BoxDecoration(
color: AppColors.darkSurface,
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
border: Border(top: BorderSide(color: AppColors.darkCardBorder, width: 1.5)),
border: Border(
top: BorderSide(
color: AppColors.darkCardBorder, width: 1.5)),
),
child: SafeArea(
child: Column(
@@ -109,7 +112,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
color: AppColors.saqelCyan.withAlpha(30),
borderRadius: BorderRadius.circular(14),
),
child: const Icon(CupertinoIcons.book_fill, color: AppColors.saqelCyan, size: 24),
child: const Icon(CupertinoIcons.book_fill,
color: AppColors.saqelCyan, size: 24),
),
const SizedBox(width: 14),
const Expanded(
@@ -118,12 +122,17 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
children: [
Text(
'تحديد وتعديل الصف الدراسي 🎓',
style: TextStyle(color: Colors.white, fontSize: 17, fontWeight: FontWeight.w800),
style: TextStyle(
color: Colors.white,
fontSize: 17,
fontWeight: FontWeight.w800),
),
SizedBox(height: 3),
Text(
'اختر صفك لفتح المناهج الوزارية والشروحات المعتمدة',
style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 12),
style: TextStyle(
color: AppColors.textSecondaryDark,
fontSize: 12),
),
],
),
@@ -132,9 +141,21 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
),
const SizedBox(height: 20),
...[
('grade_10', 'الصف العاشر الأساسي (Grade 10)', 'يشمل الرياضيات المطور، الفيزياء، والإنجليزية'),
('grade_11', 'الصف الحادي عشر (الأول ثانوي)', 'المسار الأكاديمي للفرعين العلمي والأدبي'),
('grade_12', 'الصف الثاني عشر (التوجيهي الوزاري)', 'منهاج الثانوية العامة التوجيهي الوزاري'),
(
'grade_10',
'الصف العاشر الأساسي (Grade 10)',
'يشمل الرياضيات المطور، الفيزياء، والإنجليزية'
),
(
'grade_11',
'الصف الحادي عشر (الأول ثانوي)',
'المسار الأكاديمي للفرعين العلمي والأدبي'
),
(
'grade_12',
'الصف الثاني عشر (التوجيهي الوزاري)',
'منهاج الثانوية العامة التوجيهي الوزاري'
),
].map((g) {
final isSelected = tempGrade == g.$1;
return Padding(
@@ -143,44 +164,61 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
onTap: () {
setModalState(() {
tempGrade = g.$1;
if (tempGrade == 'grade_10') tempStream = 'general';
if (tempGrade == 'grade_10')
tempStream = 'general';
});
},
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: isSelected ? AppColors.saqelCyan.withAlpha(25) : AppColors.darkBackground,
color: isSelected
? AppColors.saqelCyan.withAlpha(25)
: AppColors.darkBackground,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isSelected ? AppColors.saqelCyan : AppColors.darkCardBorder,
color: isSelected
? AppColors.saqelCyan
: AppColors.darkCardBorder,
width: isSelected ? 1.5 : 1.0,
),
),
child: Row(
children: [
Icon(
isSelected ? CupertinoIcons.checkmark_circle_fill : CupertinoIcons.circle,
color: isSelected ? AppColors.saqelCyan : AppColors.textMutedDark,
isSelected
? CupertinoIcons.checkmark_circle_fill
: CupertinoIcons.circle,
color: isSelected
? AppColors.saqelCyan
: AppColors.textMutedDark,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
g.$2,
style: TextStyle(
color: isSelected ? Colors.white : AppColors.textSecondaryDark,
fontWeight: isSelected ? FontWeight.w800 : FontWeight.w600,
color: isSelected
? Colors.white
: AppColors.textSecondaryDark,
fontWeight: isSelected
? FontWeight.w800
: FontWeight.w600,
fontSize: 14,
),
),
const SizedBox(height: 2),
Text(
g.$3,
style: const TextStyle(color: AppColors.textMutedDark, fontSize: 11),
style: const TextStyle(
color: AppColors.textMutedDark,
fontSize: 11),
),
],
),
@@ -196,7 +234,9 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
text: 'حفظ الصف وتحديث المسار 🚀',
onPressed: () async {
Navigator.of(ctx).pop();
await context.read<AuthCubit>().updateGrade(tempGrade, stream: tempStream);
await context
.read<AuthCubit>()
.updateGrade(tempGrade, stream: tempStream);
if (mounted) {
_refreshData();
SaqelToast.showSuccess(
@@ -225,7 +265,9 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
body: Container(
decoration: BoxDecoration(
gradient: RadialGradient(
center: isStudent ? const Alignment(0.8, -0.6) : const Alignment(-0.8, -0.6),
center: isStudent
? const Alignment(0.8, -0.6)
: const Alignment(-0.8, -0.6),
radius: 1.3,
colors: isStudent
? const [Color(0xFF0A2540), AppColors.darkBackground]
@@ -277,7 +319,10 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
child: const Center(
child: Text(
'ص',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w900, color: Colors.black),
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w900,
color: Colors.black),
),
),
),
@@ -294,17 +339,20 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
GestureDetector(
onTap: () => _showGradeSelectionModal(context),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColors.saqelCyan.withAlpha(30),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.saqelCyan.withAlpha(80), width: 0.8),
border: Border.all(
color: AppColors.saqelCyan.withAlpha(80), width: 0.8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
GradeLevelModel.getGradeName(widget.user.gradeLevel ?? 'grade_10'),
GradeLevelModel.getGradeName(
widget.user.gradeLevel ?? 'grade_10'),
style: const TextStyle(
color: AppColors.saqelCyan,
fontSize: 10.5,
@@ -312,7 +360,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
),
),
const SizedBox(width: 4),
const Icon(CupertinoIcons.chevron_down, color: AppColors.saqelCyan, size: 10),
const Icon(CupertinoIcons.chevron_down,
color: AppColors.saqelCyan, size: 10),
],
),
),
@@ -320,7 +369,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
else
Text(
AppStrings.guardianPortal,
style: AppTypography.labelSmall.copyWith(color: AppColors.textMutedDark),
style: AppTypography.labelSmall
.copyWith(color: AppColors.textMutedDark),
),
],
),
@@ -336,8 +386,10 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
),
child: Row(
children: [
_buildRolePill('الطالب', isStudent, () => _switchRole('student')),
_buildRolePill('ولي الأمر', !isStudent, () => _switchRole('guardian')),
_buildRolePill(
'الطالب', isStudent, () => _switchRole('student')),
_buildRolePill(
'ولي الأمر', !isStudent, () => _switchRole('guardian')),
],
),
),
@@ -345,7 +397,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
// Logout Icon
IconButton(
icon: const Icon(CupertinoIcons.power, color: AppColors.errorRed, size: 20),
icon: const Icon(CupertinoIcons.power,
color: AppColors.errorRed, size: 20),
onPressed: () => context.read<AuthCubit>().logout(),
tooltip: AppStrings.logout,
),
@@ -388,9 +441,11 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(CupertinoIcons.exclamationmark_triangle, color: AppColors.errorRed, size: 40),
const Icon(CupertinoIcons.exclamationmark_triangle,
color: AppColors.errorRed, size: 40),
const SizedBox(height: 12),
Text(state.message, style: const TextStyle(color: Colors.white)),
Text(state.message,
style: const TextStyle(color: Colors.white)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _refreshData,
@@ -412,27 +467,37 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
decoration: BoxDecoration(
color: AppColors.saqelCyan.withAlpha(25),
shape: BoxShape.circle,
border: Border.all(color: AppColors.saqelCyan.withAlpha(80), width: 1.5),
border: Border.all(
color: AppColors.saqelCyan.withAlpha(80), width: 1.5),
),
child: const Icon(CupertinoIcons.book_fill, color: AppColors.saqelCyan, size: 36),
child: const Icon(CupertinoIcons.book_fill,
color: AppColors.saqelCyan, size: 36),
),
const SizedBox(height: 18),
const Text(
'المنهاج الوزاري المعتمد — صَقِل 🇯🇴',
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w800),
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w800),
),
const SizedBox(height: 8),
const Text(
'اختر من بين 16 مبحثاً دراسياً لبدء شروحات الفيديو التفاعلية، أوراق العمل، وبنك الأسئلة المعتمد',
'لا توجد حصص تشغيل منشورة لهذا الحساب حالياً. يمكنك تصفح الدروس والموارد التي اجتازت النشر من فهرس المنهاج.',
textAlign: TextAlign.center,
style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 13, height: 1.4),
style: TextStyle(
color: AppColors.textSecondaryDark,
fontSize: 13,
height: 1.4),
),
const SizedBox(height: 24),
LuxuryButton(
text: 'تصفح المناهج والمواد 📚',
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => SubjectsGridScreen(initialGrade: widget.user.gradeLevel)),
MaterialPageRoute(
builder: (_) => SubjectsGridScreen(
initialGrade: widget.user.gradeLevel)),
);
},
),
@@ -450,7 +515,9 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
InkWell(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => SubjectsGridScreen(initialGrade: widget.user.gradeLevel)),
MaterialPageRoute(
builder: (_) => SubjectsGridScreen(
initialGrade: widget.user.gradeLevel)),
);
},
borderRadius: BorderRadius.circular(20),
@@ -463,7 +530,9 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.saqelCyan.withAlpha(100), width: 1.5),
border: Border.all(
color: AppColors.saqelCyan.withAlpha(100),
width: 1.5),
boxShadow: [
BoxShadow(
color: AppColors.saqelCyan.withAlpha(30),
@@ -479,11 +548,15 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
height: 48,
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [AppColors.appleBlue, AppColors.saqelCyan],
colors: [
AppColors.appleBlue,
AppColors.saqelCyan
],
),
borderRadius: BorderRadius.circular(14),
),
child: const Icon(CupertinoIcons.square_grid_2x2_fill, color: Colors.black, size: 24),
child: const Icon(CupertinoIcons.square_grid_2x2_fill,
color: Colors.black, size: 24),
),
const SizedBox(width: 14),
const Expanded(
@@ -491,18 +564,24 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'تصفح المناهج والمواد (16 مادة) 📚',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 15),
'تصفح المناهج والمواد 📚',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
fontSize: 15),
),
SizedBox(height: 4),
Text(
'الرياضيات، الفيزياء، الإنجليزي، الكيمياء، والمزيد',
style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 12),
style: TextStyle(
color: AppColors.textSecondaryDark,
fontSize: 12),
),
],
),
),
const Icon(CupertinoIcons.chevron_back, color: AppColors.saqelCyan, size: 18),
const Icon(CupertinoIcons.chevron_back,
color: AppColors.saqelCyan, size: 18),
],
),
),
@@ -513,7 +592,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
InkWell(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const VocationalTrainingScreen()),
MaterialPageRoute(
builder: (_) => const VocationalTrainingScreen()),
);
},
borderRadius: BorderRadius.circular(20),
@@ -526,7 +606,9 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFF10B981).withAlpha(160), width: 1.5),
border: Border.all(
color: const Color(0xFF10B981).withAlpha(160),
width: 1.5),
boxShadow: [
BoxShadow(
color: const Color(0xFF10B981).withAlpha(35),
@@ -548,7 +630,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
),
borderRadius: BorderRadius.circular(14),
),
child: const Icon(CupertinoIcons.wrench_fill, color: Colors.black, size: 24),
child: const Icon(CupertinoIcons.wrench_fill,
color: Colors.black, size: 24),
),
const SizedBox(width: 14),
const Expanded(
@@ -557,17 +640,24 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
children: [
Text(
'مؤسسة التدريب المهني (VTC Hub) ⚡',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 14.5),
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
fontSize: 14.5),
),
SizedBox(height: 4),
Text(
'140 مهنة • 8 قطاعات صناعية • مختبر فحص المركبات الكهربائية (EV)',
style: TextStyle(color: Color(0xFF6EE7B7), fontSize: 11.5, fontWeight: FontWeight.w600),
style: TextStyle(
color: Color(0xFF6EE7B7),
fontSize: 11.5,
fontWeight: FontWeight.w600),
),
],
),
),
const Icon(CupertinoIcons.chevron_back, color: Color(0xFF10B981), size: 18),
const Icon(CupertinoIcons.chevron_back,
color: Color(0xFF10B981), size: 18),
],
),
),
@@ -578,7 +668,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
InkWell(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SmartErrorNotebookScreen()),
MaterialPageRoute(
builder: (_) => const SmartErrorNotebookScreen()),
);
},
borderRadius: BorderRadius.circular(20),
@@ -591,7 +682,9 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFF8B5CF6).withOpacity(0.4), width: 1.5),
border: Border.all(
color: const Color(0xFF8B5CF6).withOpacity(0.4),
width: 1.5),
boxShadow: [
BoxShadow(
color: const Color(0xFF8B5CF6).withOpacity(0.15),
@@ -611,7 +704,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(CupertinoIcons.book_circle_fill, color: Colors.white, size: 24),
child: const Icon(CupertinoIcons.book_circle_fill,
color: Colors.white, size: 24),
),
const SizedBox(width: 14),
const Expanded(
@@ -622,24 +716,32 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
children: [
Text(
'دفتر الأخطاء الذكي والمسار العلاجي 📓',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 14),
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
fontSize: 14),
),
SizedBox(width: 6),
Text(
'· نشط',
style: TextStyle(color: Color(0xFF34D399), fontWeight: FontWeight.w700, fontSize: 11),
style: TextStyle(
color: Color(0xFF34D399),
fontWeight: FontWeight.w700,
fontSize: 11),
),
],
),
SizedBox(height: 3),
Text(
'رصد الأسئلة المتعثرة وسد الفجوات التراكمية بالذكاء الاصطناعي',
style: TextStyle(color: Color(0xFF94A3B8), fontSize: 11.5),
style: TextStyle(
color: Color(0xFF94A3B8), fontSize: 11.5),
),
],
),
),
const Icon(CupertinoIcons.chevron_back, color: Color(0xFF8B5CF6), size: 18),
const Icon(CupertinoIcons.chevron_back,
color: Color(0xFF8B5CF6), size: 18),
],
),
),
@@ -658,17 +760,21 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
GestureDetector(
onTap: () => _showGradeSelectionModal(context),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: AppColors.saqelCyan.withAlpha(30),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: AppColors.saqelCyan.withAlpha(80), width: 0.8),
border: Border.all(
color: AppColors.saqelCyan.withAlpha(80),
width: 0.8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
GradeLevelModel.getGradeName(widget.user.gradeLevel ?? 'grade_10'),
GradeLevelModel.getGradeName(
widget.user.gradeLevel ?? 'grade_10'),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
@@ -676,7 +782,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
),
),
const SizedBox(width: 4),
const Icon(CupertinoIcons.pencil, color: AppColors.saqelCyan, size: 11),
const Icon(CupertinoIcons.pencil,
color: AppColors.saqelCyan, size: 11),
],
),
),
@@ -684,12 +791,15 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
const SizedBox(height: 8),
Text(
AppStrings.readinessScore,
style: AppTypography.headlineMedium.copyWith(color: Colors.white),
style: AppTypography.headlineMedium
.copyWith(color: Colors.white),
),
const SizedBox(height: 4),
Text(
'تقييم حقيقي مبني على الكويزات وفحوصات الفهم',
style: AppTypography.bodyMedium.copyWith(color: AppColors.textSecondaryDark, fontSize: 12),
style: AppTypography.bodyMedium.copyWith(
color: AppColors.textSecondaryDark,
fontSize: 12),
),
],
),
@@ -699,12 +809,16 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
height: 64,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: AppColors.saqelCyan, width: 3),
border:
Border.all(color: AppColors.saqelCyan, width: 3),
),
child: Center(
child: Text(
'${state.readinessScore.toStringAsFixed(0)}%',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900, color: Colors.white),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
color: Colors.white),
),
),
),
@@ -716,7 +830,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
// Real Lessons List from API
Text(
AppStrings.tawjihiCurriculum,
style: AppTypography.titleMedium.copyWith(color: Colors.white),
style:
AppTypography.titleMedium.copyWith(color: Colors.white),
),
const SizedBox(height: 12),
@@ -751,7 +866,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
color: AppColors.appleBlue.withAlpha(30),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(CupertinoIcons.play_fill, color: AppColors.appleBlue, size: 20),
child: const Icon(CupertinoIcons.play_fill,
color: AppColors.appleBlue, size: 20),
),
const SizedBox(width: 14),
Expanded(
@@ -771,14 +887,18 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
if (lesson.isAiVersion) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
padding: const EdgeInsets.symmetric(
horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: AppColors.adminViolet.withAlpha(40),
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'ذكاء اصطناعي ✨',
style: TextStyle(fontSize: 9, fontWeight: FontWeight.w700, color: Color(0xFFA78BFA)),
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.w700,
color: Color(0xFFA78BFA)),
),
),
],
@@ -789,12 +909,16 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
lesson.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: Colors.white),
),
const SizedBox(height: 2),
Text(
lesson.formattedDuration,
style: const TextStyle(fontSize: 11, color: AppColors.textMutedDark),
style: const TextStyle(
fontSize: 11, color: AppColors.textMutedDark),
),
],
),
@@ -818,9 +942,11 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(CupertinoIcons.exclamationmark_triangle, color: AppColors.errorRed, size: 40),
const Icon(CupertinoIcons.exclamationmark_triangle,
color: AppColors.errorRed, size: 40),
const SizedBox(height: 12),
Text(state.message, style: const TextStyle(color: Colors.white)),
Text(state.message,
style: const TextStyle(color: Colors.white)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _refreshData,
@@ -834,9 +960,11 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(CupertinoIcons.person_2, color: AppColors.textMutedDark, size: 48),
const Icon(CupertinoIcons.person_2,
color: AppColors.textMutedDark, size: 48),
const SizedBox(height: 12),
Text(AppStrings.noChildrenLinked, style: const TextStyle(color: AppColors.textSecondaryDark)),
Text(AppStrings.noChildrenLinked,
style: const TextStyle(color: AppColors.textSecondaryDark)),
],
),
);
@@ -850,7 +978,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
children: [
Text(
AppStrings.linkedChildren,
style: AppTypography.titleMedium.copyWith(color: Colors.white),
style:
AppTypography.titleMedium.copyWith(color: Colors.white),
),
const SizedBox(height: 10),
@@ -861,15 +990,20 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
final child = state.children[index];
return Expanded(
child: GestureDetector(
onTap: () => context.read<GuardianCubit>().selectChild(index),
onTap: () =>
context.read<GuardianCubit>().selectChild(index),
child: Container(
margin: EdgeInsets.only(left: index == 0 ? 8 : 0),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isSelected ? AppColors.guardianAmber.withAlpha(35) : AppColors.darkCard,
color: isSelected
? AppColors.guardianAmber.withAlpha(35)
: AppColors.darkCard,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isSelected ? AppColors.guardianAmber : AppColors.darkCardBorder,
color: isSelected
? AppColors.guardianAmber
: AppColors.darkCardBorder,
),
),
child: Column(
@@ -880,13 +1014,17 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: isSelected ? const Color(0xFFFBBF24) : Colors.white,
color: isSelected
? const Color(0xFFFBBF24)
: Colors.white,
),
),
const SizedBox(height: 2),
Text(
child.gradeLevel ?? 'طالب مسجل',
style: const TextStyle(fontSize: 10.5, color: AppColors.textMutedDark),
style: const TextStyle(
fontSize: 10.5,
color: AppColors.textMutedDark),
),
],
),
@@ -906,11 +1044,13 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
children: [
Text(
'تقرير الطالب: ${selectedChild.name}',
style: AppTypography.titleMedium.copyWith(color: Colors.white),
style: AppTypography.titleMedium
.copyWith(color: Colors.white),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.teacherEmerald.withAlpha(30),
borderRadius: BorderRadius.circular(6),
@@ -929,12 +1069,14 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
const SizedBox(height: 6),
Text(
'المدرسة: ${selectedChild.schoolName ?? 'مدرسة الثقافة العسكرية'}',
style: const TextStyle(fontSize: 12, color: AppColors.textMutedDark),
style: const TextStyle(
fontSize: 12, color: AppColors.textMutedDark),
),
const SizedBox(height: 16),
const Divider(color: AppColors.darkCardBorder),
const SizedBox(height: 10),
_buildGuardianStat('الامتحانات المنجزة بنجاح', '${selectedChild.examsPassed} من ${selectedChild.examsTotal}'),
_buildGuardianStat('الامتحانات المنجزة بنجاح',
'${selectedChild.examsPassed} من ${selectedChild.examsTotal}'),
InkWell(
onTap: () {
Navigator.of(context).push(
@@ -958,7 +1100,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
),
),
),
_buildGuardianStat('الرقم الوطني المشفر', selectedChild.nationalId ?? 'مسجل بالهاتف'),
_buildGuardianStat('الرقم الوطني المشفر',
selectedChild.nationalId ?? 'مسجل بالهاتف'),
],
),
),
@@ -972,7 +1115,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
builder: (ctx) => Directionality(
textDirection: TextDirection.rtl,
child: CupertinoAlertDialog(
title: const Text('ومضة التقرير الشهري لولي الأمر 📲'),
title:
const Text('ومضة التقرير الشهري لولي الأمر 📲'),
content: Text(
'🇯🇴 تقرير التحصيل الأكاديمي المعتمد\n'
'الطالب: ${selectedChild.name}\n'
@@ -999,7 +1143,8 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
borderColor: AppColors.whatsappGreen.withAlpha(90),
child: const Row(
children: [
Icon(CupertinoIcons.chat_bubble_2_fill, color: AppColors.whatsappGreen, size: 26),
Icon(CupertinoIcons.chat_bubble_2_fill,
color: AppColors.whatsappGreen, size: 26),
SizedBox(width: 12),
Expanded(
child: Column(
@@ -1009,24 +1154,33 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
children: [
Text(
'تقارير بوابة نبيه للواتساب 📲',
style: TextStyle(fontSize: 13.5, fontWeight: FontWeight.w800, color: Colors.white),
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w800,
color: Colors.white),
),
SizedBox(width: 6),
Text(
'· نشطة',
style: TextStyle(color: Color(0xFF34D399), fontWeight: FontWeight.w700, fontSize: 11),
style: TextStyle(
color: Color(0xFF34D399),
fontWeight: FontWeight.w700,
fontSize: 11),
),
],
),
SizedBox(height: 3),
Text(
'انقر لمعاينة ومضة الأداء الأكاديمي الشهرية المرسلة لولي الأمر',
style: TextStyle(fontSize: 11, color: AppColors.textSecondaryDark),
style: TextStyle(
fontSize: 11,
color: AppColors.textSecondaryDark),
),
],
),
),
Icon(CupertinoIcons.chevron_back, color: AppColors.whatsappGreen, size: 16),
Icon(CupertinoIcons.chevron_back,
color: AppColors.whatsappGreen, size: 16),
],
),
),
@@ -1045,9 +1199,15 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondaryDark)),
Text(label,
style: const TextStyle(
fontSize: 12, color: AppColors.textSecondaryDark)),
const Spacer(),
Text(value, style: const TextStyle(fontSize: 12.5, fontWeight: FontWeight.w700, color: Colors.white)),
Text(value,
style: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
color: Colors.white)),
],
),
);
@@ -27,6 +27,7 @@ class _SmartErrorNotebookScreenState extends State<SmartErrorNotebookScreen> {
final ErrorNotebookRepository _repository = ErrorNotebookRepository();
bool _isLoading = true;
bool _loadFailed = false;
ErrorNotebookSummary? _summary;
List<ErrorNotebookItem> _items = [];
@@ -42,15 +43,27 @@ class _SmartErrorNotebookScreenState extends State<SmartErrorNotebookScreen> {
Future<void> _loadNotebookData() async {
setState(() => _isLoading = true);
final data = (widget.isGuardianMode && widget.studentId != null)
? await _repository.getChildErrorNotebook(widget.studentId!)
: await _repository.getErrorNotebook();
if (mounted) {
setState(() {
_summary = data['summary'] as ErrorNotebookSummary?;
_items = (data['items'] as List<ErrorNotebookItem>?) ?? [];
_isLoading = false;
});
try {
final data = (widget.isGuardianMode && widget.studentId != null)
? await _repository.getChildErrorNotebook(widget.studentId!)
: await _repository.getErrorNotebook();
if (mounted) {
setState(() {
_summary = data['summary'] as ErrorNotebookSummary?;
_items = (data['items'] as List<ErrorNotebookItem>?) ?? [];
_loadFailed = false;
_isLoading = false;
});
}
} catch (_) {
if (mounted) {
setState(() {
_summary = null;
_items = [];
_loadFailed = true;
_isLoading = false;
});
}
}
}
@@ -146,6 +159,8 @@ class _SmartErrorNotebookScreenState extends State<SmartErrorNotebookScreen> {
? const Center(
child: CircularProgressIndicator(color: Color(0xFF38BDF8)),
)
: _loadFailed
? Center(child: TextButton(onPressed: _loadNotebookData, child: const Text('تعذر تحميل دفتر الأخطاء. اضغط لإعادة المحاولة.')))
: RefreshIndicator(
onRefresh: _loadNotebookData,
color: const Color(0xFF38BDF8),
@@ -647,7 +662,9 @@ class _SmartErrorNotebookScreenState extends State<SmartErrorNotebookScreen> {
),
)
: ElevatedButton.icon(
onPressed: () => _startRemediation(item),
onPressed: item.isInRemediation && !item.retentionIsDue
? null
: () => _startRemediation(item),
icon: Icon(
item.isInRemediation
? CupertinoIcons.arrow_2_circlepath_circle_fill
@@ -656,7 +673,9 @@ class _SmartErrorNotebookScreenState extends State<SmartErrorNotebookScreen> {
),
label: Text(
item.isInRemediation
? 'بدء اختبار الاسترجاع والتثبيت (تكرار متباعد 7 أيام) 🎯'
? (item.retentionIsDue
? 'بدء اختبار الاسترجاع بعد 7 أيام 🎯'
: 'اختبار الاسترجاع متاح بعد موعد المراجعة')
: 'بدء المسار العلاجي لسد هذه الفجوة (3 أسئلة تفريدية) 🚀',
style: const TextStyle(fontSize: 12.5, fontWeight: FontWeight.w800),
),
@@ -723,11 +742,14 @@ class _RemediationModalSheet extends StatefulWidget {
class _RemediationModalSheetState extends State<_RemediationModalSheet> {
bool _isLoadingQuiz = true;
List<RemedialQuestion> _questions = [];
String _attemptUuid = '';
final List<int> _answers = [];
int _currentQuestionIndex = 0;
int? _selectedOptionIndex;
bool _hasAnsweredCurrent = false;
int _correctAnswersCount = 0;
bool _isCompleted = false;
bool _passed = false;
bool _isSubmitting = false;
@override
void initState() {
@@ -736,44 +758,43 @@ class _RemediationModalSheetState extends State<_RemediationModalSheet> {
}
Future<void> _fetchQuiz() async {
final qs = await widget.repository.getRemediationQuiz(
final result = await widget.repository.getRemediationQuiz(
errorUuid: widget.errorItem.uuid,
topicName: widget.errorItem.topicName,
);
if (mounted) {
setState(() {
_questions = qs;
_questions = result['questions'] as List<RemedialQuestion>;
_attemptUuid = result['attempt_uuid'] as String;
_isLoadingQuiz = false;
});
}
}
void _submitAnswer() {
if (_selectedOptionIndex == null || _hasAnsweredCurrent) return;
final q = _questions[_currentQuestionIndex];
final isCorrect = _selectedOptionIndex == q.correctIndex;
setState(() {
_hasAnsweredCurrent = true;
if (isCorrect) {
_correctAnswersCount++;
}
});
}
void _nextQuestion() async {
Future<void> _nextQuestion() async {
if (_selectedOptionIndex == null || _isSubmitting) return;
_answers.add(_questions[_currentQuestionIndex].options[_selectedOptionIndex!].id);
if (_currentQuestionIndex < _questions.length - 1) {
setState(() {
_currentQuestionIndex++;
_selectedOptionIndex = null;
_hasAnsweredCurrent = false;
});
} else {
// Completed drill
setState(() => _isCompleted = true);
if (_correctAnswersCount >= 2) {
await widget.repository.resolveError(widget.errorItem.uuid);
setState(() => _isSubmitting = true);
final result = await widget.repository.resolveError(widget.errorItem.uuid, _attemptUuid, _answers);
if (!mounted) return;
if (result == null) {
_answers.removeLast();
setState(() => _isSubmitting = false);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('تعذر اعتماد المحاولة. حاول مرة أخرى.')));
return;
}
setState(() {
_correctAnswersCount = (result['correct_count'] as num?)?.toInt() ?? 0;
_passed = result['passed'] == true;
_isSubmitting = false;
_isCompleted = true;
});
if (_passed) {
widget.onRemediationCompleted();
}
}
@@ -795,6 +816,8 @@ class _RemediationModalSheetState extends State<_RemediationModalSheet> {
? const Center(
child: CircularProgressIndicator(color: Color(0xFF38BDF8)),
)
: _questions.isEmpty
? const Center(child: Text('لا تتوفر أسئلة مراجعة معتمدة لهذا المفهوم حالياً.', style: TextStyle(color: Colors.white70)))
: _isCompleted
? _buildCompletionState()
: _buildQuestionState(),
@@ -856,30 +879,19 @@ class _RemediationModalSheetState extends State<_RemediationModalSheet> {
child: ListView.builder(
itemCount: q.options.length,
itemBuilder: (ctx, idx) {
final optText = q.options[idx];
final optText = q.options[idx].text;
final isSel = _selectedOptionIndex == idx;
final isCorrectOpt = idx == q.correctIndex;
Color borderCol = const Color(0xFF1E293B);
Color bgCol = const Color(0xFF161F30);
if (_hasAnsweredCurrent) {
if (isCorrectOpt) {
borderCol = const Color(0xFF10B981);
bgCol = const Color(0xFF064E3B);
} else if (isSel) {
borderCol = const Color(0xFFEF4444);
bgCol = const Color(0xFF7F1D1D);
}
} else if (isSel) {
if (isSel) {
borderCol = const Color(0xFF38BDF8);
bgCol = const Color(0xFF0369A1);
}
return GestureDetector(
onTap: _hasAnsweredCurrent
? null
: () => setState(() => _selectedOptionIndex = idx),
onTap: () => setState(() => _selectedOptionIndex = idx),
child: Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14),
@@ -926,34 +938,11 @@ class _RemediationModalSheetState extends State<_RemediationModalSheet> {
),
),
// Explanation if answered
if (_hasAnsweredCurrent)
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: const Color(0xFF1E293B),
borderRadius: BorderRadius.circular(10),
),
child: Text(
q.explanation,
style: const TextStyle(
fontSize: 12,
color: Color(0xFFCBD5E1),
height: 1.4,
),
),
),
// Action Button
ElevatedButton(
onPressed: !_hasAnsweredCurrent
? (_selectedOptionIndex != null ? _submitAnswer : null)
: _nextQuestion,
onPressed: _selectedOptionIndex != null && !_isSubmitting ? _nextQuestion : null,
style: ElevatedButton.styleFrom(
backgroundColor: _hasAnsweredCurrent
? const Color(0xFF10B981)
: const Color(0xFF38BDF8),
backgroundColor: const Color(0xFF38BDF8),
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
@@ -961,11 +950,8 @@ class _RemediationModalSheetState extends State<_RemediationModalSheet> {
),
),
child: Text(
!_hasAnsweredCurrent
? 'تأكيد الإجابة'
: (_currentQuestionIndex < _questions.length - 1
? 'السؤال التالي ➔'
: 'إنهاء المسار العلاجي 🏆'),
_isSubmitting ? 'جارٍ تصحيح الإجابات...' : (_currentQuestionIndex < _questions.length - 1
? 'السؤال التالي ➔' : 'إنهاء وتصحيح المحاولة'),
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w800),
),
),
@@ -974,7 +960,7 @@ class _RemediationModalSheetState extends State<_RemediationModalSheet> {
}
Widget _buildCompletionState() {
final passed = _correctAnswersCount >= 2;
final passed = _passed;
return Center(
child: Column(
@@ -1005,7 +991,7 @@ class _RemediationModalSheetState extends State<_RemediationModalSheet> {
Text(
passed
? (widget.errorItem.isInRemediation
? 'أجبت عن $_correctAnswersCount من ${_questions.length} أسئلة بشكل سليم. تم تثبيت المفهوم في الذاكرة طويلة المدى وترحيله إلى المتقنات 🏆.'
? 'أجبت عن $_correctAnswersCount من ${_questions.length} أسئلة بشكل سليم في اختبار الاسترجاع بعد أسبوع.'
: 'أجبت عن $_correctAnswersCount من ${_questions.length} أسئلة. دخل المفهوم مرحلة التثبيت والتكرار المتباعد (المراجعة بعد 7 أيام لتأكيد الإتقان التام).')
: 'أجبت عن $_correctAnswersCount من ${_questions.length}. حاول مجدداً لضمان إتقان السؤال في الامتحان الوزاري.',
style: const TextStyle(fontSize: 13, color: Color(0xFF94A3B8)),
File diff suppressed because it is too large Load Diff
@@ -368,7 +368,9 @@ class Grade10LabsRegistry {
/// Whether to surface draft / unreleased virtual labs for local inspection and review.
/// Defaults to true in development so that the reviewer/teacher can immediately test the labs.
static bool previewModeEnabled = true;
// Student builds must show released labs only. Draft previews stay available
// through explicit authoring/review tooling, never through the student flow.
static bool previewModeEnabled = false;
/// Exact identity lookup strictly isolated per subject.
/// When [previewModeEnabled] is active or [allowPreview] is true, returns the lab for testing.
@@ -126,9 +126,6 @@ class _SubjectVirtualLabsViewState extends State<SubjectVirtualLabsView> {
final labs = candidateLabs;
if (labs.isEmpty) {
if (widget.specializedToolWidget != null) {
return widget.specializedToolWidget!;
}
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
@@ -154,16 +151,6 @@ class _SubjectVirtualLabsViewState extends State<SubjectVirtualLabsView> {
color: Color(0xFF94A3B8), fontSize: 11.5, height: 1.6),
),
const SizedBox(height: 14),
TextButton.icon(
onPressed: () => _openGallery(context),
icon: const Icon(CupertinoIcons.square_grid_2x2, size: 16),
label: const Text('معرض المعاينة التأليفية',
style:
TextStyle(fontWeight: FontWeight.w800, fontSize: 12.5)),
style: TextButton.styleFrom(
foregroundColor: AppColors.saqelCyan,
),
),
],
),
),
@@ -59,6 +59,7 @@ void main() {
rating: 4.8,
ratingCount: 95,
isNew: false,
durationSeconds: 1200,
),
const PublishedLessonVideoModel(
videoVersionId: 'v_khalid',
@@ -67,6 +68,7 @@ void main() {
rating: 4.9,
ratingCount: 140,
isNew: false,
durationSeconds: 1200,
),
];
@@ -82,6 +84,8 @@ void main() {
context,
lesson: lesson,
videos: videos,
nextCursor: null,
availableCount: videos.length,
);
},
child: const Text('افتح اختيار المعلم'),
@@ -180,7 +180,7 @@ void main() {
await t.pump();
expect(find.text('تجارب ومختبرات الفيزياء'), findsNothing);
expect(find.text('لا مختبرات منشورة لمبحث الفيزياء بعد'), findsOneWidget);
expect(find.text('معرض المعاينة التأليفية'), findsOneWidget);
expect(find.text('معرض المعاينة التأليفية'), findsNothing);
Grade10LabsRegistry.previewModeEnabled = true;
});
@@ -911,4 +911,3 @@ void main() {
});
}
@@ -35,7 +35,7 @@ class AppConfig {
static const String auditVideoEndpoint = '/api/teacher/audit-studio-video';
static const String uploadLessonEndpoint = '/api/teacher/lessons/upload';
static const String uploadVideoEndpoint = '/api/teacher/videos/upload-direct';
static const String curriculumTreeEndpoint = '/api/curriculum/tree';
static const String curriculumTreeEndpoint = '/api/teacher/curriculum/intake-tree';
static const String qnaEndpoint = '/api/teacher/qna';
static const String cliqPayoutEndpoint = '/api/teacher/payout/request';