Harden published curriculum and student flows
This commit is contained in:
@@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
+698
-591
File diff suppressed because it is too large
Load Diff
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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
+67
-81
@@ -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)),
|
||||
|
||||
+723
-949
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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user