Update Saqel Platform: 2026-09-08 13:43:36
This commit is contained in:
@@ -19,10 +19,7 @@ class ApiClient {
|
||||
'Accept': 'application/json',
|
||||
};
|
||||
|
||||
final deviceId = await _storage.getDeviceId();
|
||||
if (deviceId != null) {
|
||||
headers['X-Device-Fingerprint'] = deviceId;
|
||||
}
|
||||
headers['X-Device-Fingerprint'] = 'saqel_student_flutter';
|
||||
|
||||
if (requiresAuth) {
|
||||
final token = await _storage.getToken();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../data/models/user_model.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
@@ -26,41 +25,7 @@ class StorageService {
|
||||
static const String _keyIdentityToken = 'saqel_identity_token';
|
||||
static const String _keyDeviceId = 'saqel_device_id';
|
||||
|
||||
// Obfuscation mask to prevent plain-text discovery in storage files
|
||||
static const String _storageSalt = 'saqel_v1_defense_salt_2026';
|
||||
|
||||
String _encrypt(String input) {
|
||||
if (input.isEmpty) return input;
|
||||
final bytes = utf8.encode(input);
|
||||
final saltBytes = utf8.encode(_storageSalt);
|
||||
final encrypted = List<int>.generate(bytes.length, (i) => bytes[i] ^ saltBytes[i % saltBytes.length]);
|
||||
return base64Encode(encrypted);
|
||||
}
|
||||
|
||||
String _decrypt(String input) {
|
||||
if (input.isEmpty) return input;
|
||||
try {
|
||||
final bytes = base64Decode(input);
|
||||
final saltBytes = utf8.encode(_storageSalt);
|
||||
final decrypted = List<int>.generate(bytes.length, (i) => bytes[i] ^ saltBytes[i % saltBytes.length]);
|
||||
return utf8.decode(decrypted);
|
||||
} catch (_) {
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _writeSafe(String key, String value) async {
|
||||
final encVal = _encrypt(value);
|
||||
|
||||
// 1. Persistent disk storage
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(key, encVal);
|
||||
} catch (e) {
|
||||
AppLogger.log('Prefs write notice ($key): $e', tag: 'STORAGE');
|
||||
}
|
||||
|
||||
// 2. Hardware Secure Storage (if available)
|
||||
try {
|
||||
await _secureStorage.write(key: key, value: value);
|
||||
} on PlatformException catch (e) {
|
||||
@@ -69,31 +34,14 @@ class StorageService {
|
||||
}
|
||||
|
||||
Future<String?> _readSafe(String key) async {
|
||||
// 1. Try Keychain first
|
||||
try {
|
||||
final val = await _secureStorage.read(key: key);
|
||||
if (val != null && val.isNotEmpty) return val;
|
||||
} catch (_) {}
|
||||
|
||||
// 2. Fall back to encrypted persistent storage
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(key);
|
||||
if (raw != null && raw.isNotEmpty) {
|
||||
return _decrypt(raw);
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.log('Prefs read notice ($key): $e', tag: 'STORAGE');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _deleteSafe(String key) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(key);
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
await _secureStorage.delete(key: key);
|
||||
} catch (_) {}
|
||||
|
||||
@@ -33,6 +33,7 @@ class AuthRepository {
|
||||
'phone_number': phoneNumber,
|
||||
'otp': otp,
|
||||
'role': role,
|
||||
'device_fingerprint': 'saqel_student_flutter',
|
||||
},
|
||||
requiresAuth: false,
|
||||
);
|
||||
|
||||
@@ -75,7 +75,7 @@ class CurriculumRepository {
|
||||
}
|
||||
});
|
||||
|
||||
return available.isNotEmpty ? available : GradeLevelModel.allK12Grades;
|
||||
return available;
|
||||
}
|
||||
|
||||
/// Fetch subjects for a specific grade level dynamically from Live Backend
|
||||
@@ -98,12 +98,6 @@ class CurriculumRepository {
|
||||
final shortKey = gradeLevel.replaceAll('grade_', '');
|
||||
if (tree.containsKey(shortKey) && tree[shortKey] is Map) {
|
||||
gradeData = Map<String, dynamic>.from(tree[shortKey]);
|
||||
} else if (tree.isNotEmpty) {
|
||||
// Fall back to first available grade if requested grade has no uploaded content yet
|
||||
final firstKey = tree.keys.first;
|
||||
if (tree[firstKey] is Map) {
|
||||
gradeData = Map<String, dynamic>.from(tree[firstKey]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,245 +1,45 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../models/error_notebook_model.dart';
|
||||
|
||||
/// ==============================================================================
|
||||
/// SAQEL ENTERPRISE - ERROR NOTEBOOK REPOSITORY & REMEDIATION SERVICE
|
||||
/// ==============================================================================
|
||||
class ErrorNotebookRepository {
|
||||
static const String baseUrl = 'http://127.0.0.1:8000';
|
||||
final ApiClient _api;
|
||||
|
||||
/// Fetch Error Notebook items & statistics
|
||||
Future<Map<String, dynamic>> getErrorNotebook({
|
||||
String? subject,
|
||||
String? status,
|
||||
}) async {
|
||||
try {
|
||||
final uri = Uri.parse('$baseUrl/api/student/error-notebook').replace(
|
||||
queryParameters: {
|
||||
if (subject != null && subject.isNotEmpty) 'subject': subject,
|
||||
if (status != null && status.isNotEmpty) 'status': status,
|
||||
},
|
||||
);
|
||||
ErrorNotebookRepository({ApiClient? api}) : _api = api ?? ApiClient();
|
||||
|
||||
final response = await http
|
||||
.get(uri, headers: {'Accept': 'application/json'})
|
||||
.timeout(const Duration(seconds: 3));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final decoded = json.decode(response.body);
|
||||
if (decoded['status'] == 'success' && decoded['data'] != null) {
|
||||
final data = decoded['data'];
|
||||
final summary = ErrorNotebookSummary.fromJson(data['summary'] ?? {});
|
||||
final rawItems = data['items'] as List? ?? [];
|
||||
final items = rawItems
|
||||
.map((e) => ErrorNotebookItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
return {
|
||||
'summary': summary,
|
||||
'items': items,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Fallback to high-fidelity local data
|
||||
}
|
||||
|
||||
return _getLocalFallbackData();
|
||||
}
|
||||
|
||||
/// Fetch 3-question targeted remediation drill for a troubled concept
|
||||
Future<List<RemedialQuestion>> getRemediationQuiz({
|
||||
required String errorUuid,
|
||||
required String topicName,
|
||||
}) async {
|
||||
try {
|
||||
final uri = Uri.parse('$baseUrl/api/student/error-notebook/remediation-quiz').replace(
|
||||
queryParameters: {
|
||||
'error_uuid': errorUuid,
|
||||
'topic_name': topicName,
|
||||
},
|
||||
);
|
||||
|
||||
final response = await http
|
||||
.get(uri, headers: {'Accept': 'application/json'})
|
||||
.timeout(const Duration(seconds: 3));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final decoded = json.decode(response.body);
|
||||
if (decoded['status'] == 'success' && decoded['data'] != null) {
|
||||
final rawQuestions = decoded['data']['questions'] as List? ?? [];
|
||||
return rawQuestions
|
||||
.map((q) => RemedialQuestion.fromJson(q as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// Local fallback
|
||||
}
|
||||
|
||||
return _getLocalFallbackRemediationQuestions(topicName);
|
||||
}
|
||||
|
||||
/// Mark error as mastered upon completing remedial drill
|
||||
Future<bool> resolveError(String errorUuid) async {
|
||||
try {
|
||||
final uri = Uri.parse('$baseUrl/api/student/error-notebook/resolve');
|
||||
final response = await http.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: json.encode({'error_uuid': errorUuid}),
|
||||
).timeout(const Duration(seconds: 3));
|
||||
|
||||
return response.statusCode == 200;
|
||||
} catch (_) {
|
||||
return true; // Optimistic update
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> _getLocalFallbackData() {
|
||||
final items = [
|
||||
ErrorNotebookItem(
|
||||
id: 1,
|
||||
uuid: 'err-phy-001',
|
||||
subjectId: 'physics_10',
|
||||
subjectName: 'الفيزياء',
|
||||
topicName: 'جمع وتحليل المتجهات والضرب القياسي',
|
||||
sourceType: 'socratic_checkpoint',
|
||||
questionText:
|
||||
'متجهان A و B مقدار كل منهما 6 وحدات، والزاوية بينهما 90 درجة. ما حاصل ضربهما القياسي (A · B)؟',
|
||||
studentWrongAnswer: '36 وحدة',
|
||||
correctAnswer: 'صفر',
|
||||
socraticHint:
|
||||
'تذكر أن الضرب القياسي يعتمد على جيب التمام: A · B = |A| |B| cos(θ). وجيب تمام الزاوية 90 درجة يساوي صفراً، لذلك ينعدم الضرب القياسي لمتجهين متعامدين تماماً.',
|
||||
errorCategory: 'conceptual',
|
||||
status: 'pending_remediation',
|
||||
remediationAttemptsCount: 0,
|
||||
createdAt: 'منذ يومين',
|
||||
),
|
||||
ErrorNotebookItem(
|
||||
id: 2,
|
||||
uuid: 'err-math-002',
|
||||
subjectId: 'math_10',
|
||||
subjectName: 'الرياضيات',
|
||||
topicName: 'المعنى الهندسي للمشتقة الأولى وميل المماس',
|
||||
sourceType: 'adaptive_exam',
|
||||
questionText:
|
||||
'ما هو التفسير الهندسي للمشتقة الأولى f\'(x₀) عند النقطة (x₀, y₀) الواقعة على منحنى الاقتران؟',
|
||||
studentWrongAnswer: 'معادلة المستقيم القاطع المار بالنقطتين',
|
||||
correctAnswer: 'ميل خط المماس لمنحنى الاقتران عند تلك النقطة',
|
||||
socraticHint:
|
||||
'القاطع يحتاج نقطتين، ولكن بأخذ النهاية عندما تقترب النقطتان من بعضهما، يتحول القاطع إلى مماس، وتكون المشتقة الأولى هي ميل هذا المماس حصراً.',
|
||||
errorCategory: 'conceptual',
|
||||
status: 'pending_remediation',
|
||||
remediationAttemptsCount: 1,
|
||||
createdAt: 'منذ 3 أيام',
|
||||
),
|
||||
ErrorNotebookItem(
|
||||
id: 3,
|
||||
uuid: 'err-eng-003',
|
||||
subjectId: 'english_10',
|
||||
subjectName: 'اللغة الإنجليزية',
|
||||
topicName: 'Definite & Indefinite Articles (a, an, the)',
|
||||
sourceType: 'unit_exam',
|
||||
questionText:
|
||||
'Choose the correct article: "Dr. Zaid is ____ honest researcher who dedicated his life to education."',
|
||||
studentWrongAnswer: 'a',
|
||||
correctAnswer: 'an',
|
||||
socraticHint:
|
||||
'We choose (an) based on the vowel SOUND, not the spelling letter! Since "honest" starts with a silent "h" and a vowel sound (/ˈɒn.ɪst/), we must use "an honest".',
|
||||
errorCategory: 'rushed',
|
||||
status: 'mastered',
|
||||
remediationAttemptsCount: 2,
|
||||
masteredAt: 'اليوم',
|
||||
createdAt: 'منذ 4 أيام',
|
||||
),
|
||||
ErrorNotebookItem(
|
||||
id: 4,
|
||||
uuid: 'err-arb-004',
|
||||
subjectId: 'arabic_10',
|
||||
subjectName: 'اللغة العربية',
|
||||
topicName: 'إنّ وأخواتها وأنواع الخبر',
|
||||
sourceType: 'socratic_checkpoint',
|
||||
questionText: 'في جملة (لعلّ النصرَ قريبٌ)، ما إعراب كلمة (النصرَ)؟',
|
||||
studentWrongAnswer: 'فاعل مرفوع بالضمة',
|
||||
correctAnswer: 'اسم لعلّ منصوب وعلامة نصبه الفتحة الظاهرة',
|
||||
socraticHint:
|
||||
'لعلّ من أخوات إنّ، وهي حروف ناسخة تدخل على الجملة الاسمية فتنصب المبتدأ ويسمى اسمها، وترفع الخبر ويسمى خبرها.',
|
||||
errorCategory: 'conceptual',
|
||||
status: 'mastered',
|
||||
remediationAttemptsCount: 1,
|
||||
masteredAt: 'أمس',
|
||||
createdAt: 'منذ 5 أيام',
|
||||
),
|
||||
];
|
||||
|
||||
final summary = ErrorNotebookSummary(
|
||||
totalErrors: 4,
|
||||
masteredCount: 2,
|
||||
pendingCount: 2,
|
||||
masteryPercentage: 50.0,
|
||||
bySubject: {
|
||||
'الفيزياء': 1,
|
||||
'الرياضيات': 1,
|
||||
'اللغة الإنجليزية': 1,
|
||||
'اللغة العربية': 1,
|
||||
Future<Map<String, dynamic>> getErrorNotebook({String? subject, String? status}) async {
|
||||
final decoded = await _api.get(
|
||||
'/api/student/error-notebook',
|
||||
queryParams: {
|
||||
if (subject != null && subject.isNotEmpty) 'subject': subject,
|
||||
if (status != null && status.isNotEmpty) 'status': status,
|
||||
},
|
||||
);
|
||||
|
||||
final data = Map<String, dynamic>.from(decoded['data'] as Map? ?? const {});
|
||||
final items = (data['items'] as List? ?? const [])
|
||||
.map((item) => ErrorNotebookItem.fromJson(Map<String, dynamic>.from(item as Map)))
|
||||
.toList();
|
||||
return {
|
||||
'summary': summary,
|
||||
'summary': ErrorNotebookSummary.fromJson(Map<String, dynamic>.from(data['summary'] as Map? ?? const {})),
|
||||
'items': items,
|
||||
};
|
||||
}
|
||||
|
||||
List<RemedialQuestion> _getLocalFallbackRemediationQuestions(String topic) {
|
||||
return [
|
||||
RemedialQuestion(
|
||||
id: 1,
|
||||
question:
|
||||
'إذا كانت محصلة القوى المؤثرة على جسم تساوي صفراً (ΣF = 0)، فماذا يحدث لحركته؟',
|
||||
options: [
|
||||
'يتوقف الجسم فوراً عن الحركة في جميع الأحوال',
|
||||
'يتحرك بتسارع ثابت متزايد',
|
||||
'يبقى ساكناً أو يستمر بالحركة بسرعة متجهة ثابتة في خط مستقيم',
|
||||
'تتناقص سرعته تدريجياً حتى يتوقف',
|
||||
],
|
||||
correctIndex: 2,
|
||||
explanation:
|
||||
'هذا نص القانون الأول لنيوتن (القصور الذاتي): الجسم يحافظ على حالته الحركية ما لم تؤثر عليه قوة محصلة.',
|
||||
),
|
||||
RemedialQuestion(
|
||||
id: 2,
|
||||
question:
|
||||
'أثرت قوة أفقية مقدارها 20 نيوتن على جسم كتلته 4 كغ على سطح أملس. ما هو تسارع الجسم؟',
|
||||
options: [
|
||||
'5 م/ث²',
|
||||
'80 م/ث²',
|
||||
'0.2 م/ث²',
|
||||
'16 م/ث²',
|
||||
],
|
||||
correctIndex: 0,
|
||||
explanation:
|
||||
'تطبيق مباشر لقانون نيوتن الثاني: a = F / m = 20 / 4 = 5 م/ث².',
|
||||
),
|
||||
RemedialQuestion(
|
||||
id: 3,
|
||||
question:
|
||||
'ما الفرق بين الكمية القياسية والكمية المتجهة في التعبير الفيزيائي الدقيق؟',
|
||||
options: [
|
||||
'الكمية القياسية دائماً موجبة والمتجهة دائماً سالبة',
|
||||
'الكمية القياسية تُحدد بالمقدار والوحدة فقط، بينما المتجهة تتطلب مقداراً ووحدة واتجاهاً محدداً',
|
||||
'لا يوجد فرق، كلاهما يُقاس بنفس الطريقة',
|
||||
'الكمية المتجهة تُقاس في الفضاء فقط',
|
||||
],
|
||||
correctIndex: 1,
|
||||
explanation:
|
||||
'الكمية القياسية مثل الكتلة والزمن، بينما المتجهة مثل القوة والسرعة المتجهة تتطلب تحديد الاتجاه بدقة.',
|
||||
),
|
||||
];
|
||||
Future<List<RemedialQuestion>> getRemediationQuiz({required String errorUuid, required String topicName}) async {
|
||||
final decoded = await _api.get(
|
||||
'/api/student/error-notebook/remediation-quiz',
|
||||
queryParams: {'error_uuid': errorUuid, 'topic_name': topicName},
|
||||
);
|
||||
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();
|
||||
}
|
||||
|
||||
Future<bool> resolveError(String errorUuid) async {
|
||||
final decoded = await _api.post(
|
||||
'/api/student/error-notebook/resolve',
|
||||
body: {'error_uuid': errorUuid},
|
||||
);
|
||||
return decoded is Map && decoded['status'] == 'success';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,23 +67,6 @@ class GuardianCubit extends Cubit<GuardianState> {
|
||||
: _guardianRepo = guardianRepo ?? GuardianRepository(),
|
||||
super(GuardianInitial());
|
||||
|
||||
List<GuardianChildModel> _getFallbackChildren() {
|
||||
return [
|
||||
GuardianChildModel(
|
||||
id: 1,
|
||||
uuid: 'std-10-sama-01',
|
||||
name: 'سما حمزة',
|
||||
nationalId: '2009102450',
|
||||
gradeLevel: 'الصف العاشر الأساسي',
|
||||
stream: 'المسار الأكاديمي (علمي)',
|
||||
schoolName: 'مدرسة الملك عبد الله الثاني للتميز',
|
||||
readinessScore: 92.0,
|
||||
examsPassed: 18,
|
||||
examsTotal: 20,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> fetchDashboard() async {
|
||||
AppLogger.log('Fetching guardian dashboard children from API...', tag: 'GUARDIAN_CUBIT');
|
||||
emit(GuardianLoading());
|
||||
@@ -91,13 +74,13 @@ class GuardianCubit extends Cubit<GuardianState> {
|
||||
final children = await _guardianRepo.getDashboardChildren();
|
||||
AppLogger.log('Fetched ${children.length} linked children from API', tag: 'GUARDIAN_CUBIT');
|
||||
if (children.isEmpty) {
|
||||
emit(GuardianLoaded(children: _getFallbackChildren(), selectedChildIndex: 0));
|
||||
emit(GuardianEmpty());
|
||||
} else {
|
||||
emit(GuardianLoaded(children: children, selectedChildIndex: 0));
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.error('Fetch guardian dashboard failed, using resilient student link', error: e, tag: 'GUARDIAN_CUBIT');
|
||||
emit(GuardianLoaded(children: _getFallbackChildren(), selectedChildIndex: 0));
|
||||
AppLogger.error('Fetch guardian dashboard failed', error: e, tag: 'GUARDIAN_CUBIT');
|
||||
emit(GuardianError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -192,17 +192,11 @@ class ExamCubit extends Cubit<ExamState> {
|
||||
}).toList();
|
||||
|
||||
try {
|
||||
ExamSubmissionResultModel result;
|
||||
try {
|
||||
result = await _repository.submitExam(
|
||||
s.exam.id,
|
||||
answers: formattedAnswers,
|
||||
timeSpentSeconds: timeSpent,
|
||||
);
|
||||
} catch (_) {
|
||||
// Safe offline evaluation fallback if API token not present
|
||||
result = _evaluateLocally(s.exam, s.selectedAnswers);
|
||||
}
|
||||
final result = await _repository.submitExam(
|
||||
s.exam.id,
|
||||
answers: formattedAnswers,
|
||||
timeSpentSeconds: timeSpent,
|
||||
);
|
||||
|
||||
emit(ExamCompleted(
|
||||
exam: s.exam,
|
||||
@@ -214,59 +208,4 @@ class ExamCubit extends Cubit<ExamState> {
|
||||
}
|
||||
}
|
||||
|
||||
ExamSubmissionResultModel _evaluateLocally(ExamModel exam, Map<int, int> selectedAnswers) {
|
||||
int earnedScore = 0;
|
||||
int totalScore = 0;
|
||||
List<String> weakTopics = [];
|
||||
List<DetailedAnswerModel> detailedAnswers = [];
|
||||
|
||||
for (var q in exam.questions) {
|
||||
totalScore += q.points;
|
||||
final selectedOptId = selectedAnswers[q.id];
|
||||
QuestionOptionModel? correctOption;
|
||||
try {
|
||||
correctOption = q.options.firstWhere((opt) => opt.isCorrect);
|
||||
} catch (_) {
|
||||
correctOption = q.options.isNotEmpty ? q.options.first : null;
|
||||
}
|
||||
|
||||
final isCorrect = selectedOptId != null && correctOption != null && selectedOptId == correctOption.id;
|
||||
final points = isCorrect ? q.points : 0;
|
||||
earnedScore += points;
|
||||
|
||||
if (!isCorrect) {
|
||||
if (!weakTopics.contains(q.topicTag)) {
|
||||
weakTopics.add(q.topicTag);
|
||||
}
|
||||
}
|
||||
|
||||
detailedAnswers.add(DetailedAnswerModel(
|
||||
questionId: q.id,
|
||||
selectedOptionId: selectedOptId ?? 0,
|
||||
isCorrect: isCorrect,
|
||||
pointsAwarded: points,
|
||||
explanation: q.explanationText ?? 'راجع نص القاعدة في كتاب الوزارة',
|
||||
aiHint: q.aiHint,
|
||||
));
|
||||
}
|
||||
|
||||
final pct = totalScore > 0 ? (earnedScore / totalScore) * 100 : 0.0;
|
||||
final passed = pct >= exam.passingPercentage;
|
||||
final report = passed
|
||||
? 'أداء ممتاز! حققت نسبة إتقان ${pct.toStringAsFixed(1)}%. لديك استيعاب عميق للمفاهيم الأساسية.'
|
||||
: 'تم رصد تعثر في مفاهيم: ${weakTopics.join('، ')}. ننصح بمشاهدة مقاطع الشرح المركزة لمعالجة الثغرات.';
|
||||
|
||||
return ExamSubmissionResultModel(
|
||||
attemptId: DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
score: earnedScore,
|
||||
totalScore: totalScore,
|
||||
percentage: pct,
|
||||
passed: passed,
|
||||
rewindSeconds: passed ? 0 : 45,
|
||||
aiDiagnosticReport: report,
|
||||
weakTopics: weakTopics,
|
||||
tawjihiReadinessScore: (pct * 0.7) + (passed ? 25.0 : 10.0),
|
||||
detailedAnswers: detailedAnswers,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,9 +95,7 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
|
||||
try {
|
||||
var playback = await _repo.getLessonPlayback(lesson.markdownFilePath ?? lesson.id, subjectId: subject?.id);
|
||||
if (playback.videoUrl.isEmpty) {
|
||||
playback = playback.copyWith(
|
||||
videoUrl: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
|
||||
);
|
||||
throw StateError('لم يربط الخادم فيديو R2 بهذا الدرس بعد.');
|
||||
}
|
||||
|
||||
// Load saved resume position strictly per video
|
||||
@@ -120,118 +118,11 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
|
||||
isPlaying: true,
|
||||
));
|
||||
} catch (e) {
|
||||
AppLogger.log('Playback API unavailable (${lesson.title}): $e — Switching to offline Socratic fallback', tag: 'VIDEO_CUBIT');
|
||||
final fallback = _buildResilientLessonPlayback(lesson, subject: subject);
|
||||
|
||||
// Load saved resume position strictly per video
|
||||
int resumePos = fallback.lastPositionSeconds ?? 0;
|
||||
Set<int> passedIds = {};
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final localPos = prefs.getInt('saved_video_pos_$storageKey') ?? 0;
|
||||
if (localPos > resumePos) resumePos = localPos;
|
||||
final savedPassed = prefs.getStringList('passed_checkpoints_$storageKey') ?? [];
|
||||
passedIds = savedPassed.map((s) => int.tryParse(s) ?? 0).where((id) => id > 0).toSet();
|
||||
} catch (_) {}
|
||||
|
||||
emit(VideoPlaybackReady(
|
||||
playbackData: fallback,
|
||||
lessonItem: lesson,
|
||||
subject: subject,
|
||||
currentPositionSeconds: resumePos,
|
||||
passedCheckpointIds: passedIds,
|
||||
isPlaying: true,
|
||||
));
|
||||
AppLogger.error('Playback API unavailable (${lesson.title})', error: e, tag: 'VIDEO_CUBIT');
|
||||
emit(VideoPlaybackError(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
LessonPlaybackData _buildResilientLessonPlayback(CurriculumLessonItemModel lesson, {SubjectModel? subject}) {
|
||||
final title = lesson.title.toLowerCase();
|
||||
final isEng = (subject?.id ?? '').contains('english') || title.contains('english') || title.contains('unit 01');
|
||||
final isPhys = (subject?.id ?? '').contains('physic') || title.contains('فيزياء') || title.contains('متجه');
|
||||
|
||||
List<SocraticCheckpointModel> points = [];
|
||||
|
||||
if (isEng) {
|
||||
points = [
|
||||
const SocraticCheckpointModel(
|
||||
id: 101,
|
||||
questionText: 'According to the reading passage, in how many seconds do humans make subconscious judgments?',
|
||||
timestampSeconds: 20,
|
||||
hint: 'Remember the rule of first impressions in psychological studies.',
|
||||
pedagogicalExplanation: 'Behavioral research confirms that people form initial impressions within the first 7 seconds.',
|
||||
options: [
|
||||
SocraticOptionModel(id: 1, text: 'Within 7 seconds', isCorrect: true),
|
||||
SocraticOptionModel(id: 2, text: 'Within 5 minutes', isCorrect: false),
|
||||
SocraticOptionModel(id: 3, text: 'After prolonged conversation', isCorrect: false),
|
||||
],
|
||||
),
|
||||
const SocraticCheckpointModel(
|
||||
id: 102,
|
||||
questionText: 'Which article should precede the singular noun "university"?',
|
||||
timestampSeconds: 50,
|
||||
hint: 'Consider the initial phonetic sound rather than the written letter.',
|
||||
pedagogicalExplanation: 'Although "university" starts with the vowel letter "u", it begins with the consonant sound /juː/, so we use "a".',
|
||||
options: [
|
||||
SocraticOptionModel(id: 4, text: 'a (e.g. a university)', isCorrect: true),
|
||||
SocraticOptionModel(id: 5, text: 'an (e.g. an university)', isCorrect: false),
|
||||
],
|
||||
),
|
||||
];
|
||||
} else if (isPhys) {
|
||||
points = [
|
||||
const SocraticCheckpointModel(
|
||||
id: 201,
|
||||
questionText: 'ما هي النتيجة الصحيحة للضرب القياسي لمتجهين متعامدين (θ = 90°)؟',
|
||||
timestampSeconds: 20,
|
||||
hint: 'تذكر أن الضرب النقطي يعتمد على جيب التمام cos(θ).',
|
||||
pedagogicalExplanation: 'بما أن cos(90°) = 0، فإن الضرب القياسي لمتجهين متعامدين ينعدم تماماً ويساوي صفراً.',
|
||||
options: [
|
||||
SocraticOptionModel(id: 1, text: 'ينعدم الناتج (يساوي صفراً)', isCorrect: true),
|
||||
SocraticOptionModel(id: 2, text: 'يساوي حاصل ضرب مقداريهما', isCorrect: false),
|
||||
SocraticOptionModel(id: 3, text: 'يساوي متجهاً رأسياً جديداً', isCorrect: false),
|
||||
],
|
||||
),
|
||||
];
|
||||
} else {
|
||||
points = [
|
||||
const SocraticCheckpointModel(
|
||||
id: 301,
|
||||
questionText: 'قبل تحليل المعادلة x³ + 4x² = 5x، ما هي الخطوة الجبرية الإلزامية الأولى؟',
|
||||
timestampSeconds: 20,
|
||||
hint: 'احذر من قسمة طرفي المعادلة على المتغير x فتفقد أحد الجذور.',
|
||||
pedagogicalExplanation: 'يجب نقل الحد 5x إلى الطرف الأيسر ليصبح الطرف الأيمن صفراً، ثم إخراج العامل المشترك x.',
|
||||
options: [
|
||||
SocraticOptionModel(id: 1, text: 'نقل 5x للطرف الأيسر وجعل الطرف الأيمن صفراً', isCorrect: true),
|
||||
SocraticOptionModel(id: 2, text: 'القسمة المباشرة على x في الطرفين', isCorrect: false),
|
||||
SocraticOptionModel(id: 3, text: 'أخذ الجذر التكعيبي لكافة الحدود', isCorrect: false),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return LessonPlaybackData(
|
||||
lessonId: int.tryParse(lesson.id.replaceAll(RegExp(r'[^0-9]'), '')) ?? 101,
|
||||
title: lesson.title,
|
||||
durationSeconds: lesson.durationSeconds > 0 ? lesson.durationSeconds : 1200,
|
||||
videoUrl: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
|
||||
storageType: 'hls_stream',
|
||||
checkpoints: points,
|
||||
lastPositionSeconds: 0,
|
||||
availableVersions: const [
|
||||
LessonVersionModel(
|
||||
lessonId: 101,
|
||||
isAi: true,
|
||||
teacherName: 'منصة صَقِل التعليمية المعتمدة',
|
||||
schoolName: 'المركز الرقمي المعتمد',
|
||||
label: 'الشرح الرقمي الرسمي المعتمد',
|
||||
isRecommended: true,
|
||||
videoUrl: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void updatePosition(int seconds) {
|
||||
final currentState = state;
|
||||
if (currentState is VideoPlaybackReady && currentState.activeCheckpoint == null) {
|
||||
|
||||
@@ -71,12 +71,13 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
|
||||
}
|
||||
|
||||
void _initPlayer(String videoUrl, int resumePos, bool shouldPlay) {
|
||||
final effectiveUrl = videoUrl.isNotEmpty
|
||||
? videoUrl
|
||||
: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4';
|
||||
if (videoUrl.isEmpty) {
|
||||
setState(() => _videoInitError = 'لا يوجد رابط فيديو حقيقي لهذا الدرس.');
|
||||
return;
|
||||
}
|
||||
|
||||
_videoController?.dispose();
|
||||
_videoController = VideoPlayerController.networkUrl(Uri.parse(effectiveUrl))
|
||||
_videoController = VideoPlayerController.networkUrl(Uri.parse(videoUrl))
|
||||
..initialize().then((_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -401,9 +402,7 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
|
||||
_isVideoInitialized = false;
|
||||
});
|
||||
_initPlayer(
|
||||
state.playbackData.videoUrl.isNotEmpty
|
||||
? state.playbackData.videoUrl
|
||||
: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
|
||||
state.playbackData.videoUrl,
|
||||
state.currentPositionSeconds,
|
||||
true,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user