Update Saqel Platform: 2026-09-08 13:43:36

This commit is contained in:
Hamza-Ayed
2026-09-08 13:43:36 +03:00
parent 0f424062fe
commit bb37b31cab
76 changed files with 3617 additions and 3059 deletions
@@ -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,
);