Update Saqel Platform: 2026-09-02 00:55:56

This commit is contained in:
Hamza-Ayed
2026-09-02 00:55:56 +03:00
parent 1c0cd88a98
commit f4ab43d49a
18 changed files with 409 additions and 214 deletions
@@ -86,19 +86,26 @@ class GuardianChildModel {
});
factory GuardianChildModel.fromJson(Map<String, dynamic> json) {
final student = json['student'] is Map
? Map<String, dynamic>.from(json['student'])
: <String, dynamic>{};
final metrics = json['metrics'] is Map
? Map<String, dynamic>.from(json['metrics'])
: <String, dynamic>{};
final source = <String, dynamic>{...student, ...metrics, ...json};
return GuardianChildModel(
id: json['id'] is int ? json['id'] : int.tryParse(json['id']?.toString() ?? '0') ?? 0,
uuid: json['uuid']?.toString() ?? '',
name: json['full_name']?.toString() ?? json['name']?.toString() ?? 'الطالب',
nationalId: json['national_id']?.toString(),
gradeLevel: json['grade_level']?.toString(),
stream: json['stream']?.toString(),
schoolName: json['school_name']?.toString() ?? 'مدرسة معتمدة',
readinessScore: json['tawjihi_readiness_score'] != null
? double.tryParse(json['tawjihi_readiness_score'].toString()) ?? 0.0
: (json['readiness_score'] != null ? double.tryParse(json['readiness_score'].toString()) ?? 0.0 : 0.0),
examsPassed: json['exams_passed_count'] is int ? json['exams_passed_count'] : int.tryParse(json['exams_passed_count']?.toString() ?? '0') ?? 0,
examsTotal: json['exams_total_count'] is int ? json['exams_total_count'] : int.tryParse(json['exams_total_count']?.toString() ?? '0') ?? 0,
id: source['id'] is int ? source['id'] : int.tryParse(source['id']?.toString() ?? '0') ?? 0,
uuid: source['uuid']?.toString() ?? '',
name: source['full_name']?.toString() ?? source['name']?.toString() ?? 'الطالب',
nationalId: source['national_id']?.toString(),
gradeLevel: source['grade_level']?.toString(),
stream: source['stream']?.toString(),
schoolName: source['school_name']?.toString() ?? 'مدرسة معتمدة',
readinessScore: source['readiness_score'] != null
? double.tryParse(source['readiness_score'].toString()) ?? 0.0
: (source['tawjihi_readiness_score'] != null ? double.tryParse(source['tawjihi_readiness_score'].toString()) ?? 0.0 : 0.0),
examsPassed: source['exams_passed_count'] is int ? source['exams_passed_count'] : int.tryParse(source['exams_passed_count']?.toString() ?? '0') ?? 0,
examsTotal: source['exams_total_count'] is int ? source['exams_total_count'] : int.tryParse(source['exams_total_count']?.toString() ?? '0') ?? 0,
);
}
}
@@ -1,6 +1,15 @@
import '../../core/config/app_config.dart';
String _absolutePlaybackUrl(String value) {
if (value.isEmpty || value.startsWith('http://') || value.startsWith('https://')) return value;
final base = AppConfig.baseUrl.replaceAll(RegExp(r'/+$'), '');
return '$base/${value.replaceFirst(RegExp(r'^/+'), '')}';
}
/// Model representing an In-Video Socratic Gatekeeping Checkpoint (فحص الفهم السقراطي)
class SocraticCheckpointModel {
final int id;
final int questionId;
final String questionText;
final int timestampSeconds;
final int rewindSecondsOnFail;
@@ -10,6 +19,7 @@ class SocraticCheckpointModel {
const SocraticCheckpointModel({
required this.id,
this.questionId = 0,
required this.questionText,
required this.timestampSeconds,
this.rewindSecondsOnFail = 45,
@@ -29,7 +39,12 @@ class SocraticCheckpointModel {
}
return SocraticCheckpointModel(
id: (json['id'] as num?)?.toInt() ?? 0,
id: json['id'] is num
? (json['id'] as num).toInt()
: int.tryParse((json['id'] ?? json['exam_id'] ?? '0').toString()) ?? 0,
questionId: json['question_id'] is num
? (json['question_id'] as num).toInt()
: int.tryParse(json['question_id']?.toString() ?? '0') ?? 0,
questionText: json['question']?.toString() ?? json['question_text']?.toString() ?? 'سؤال فحص الفهم السقراطي',
timestampSeconds: (json['timestamp_seconds'] as num?)?.toInt() ?? 120,
rewindSecondsOnFail: (json['rewind_seconds'] as num?)?.toInt() ?? 45,
@@ -105,9 +120,9 @@ class LessonPlaybackData {
}
}
final vidUrl = playback['hls_url']?.toString() ??
playback['stream_url']?.toString() ??
playback['video_url']?.toString() ?? '';
final vidUrl = _absolutePlaybackUrl(
playback['hls_url']?.toString() ?? playback['stream_url']?.toString() ?? playback['video_url']?.toString() ?? '',
);
return LessonPlaybackData(
lessonId: (lesson['id'] as num?)?.toInt() ?? 0,
@@ -143,9 +158,9 @@ class LessonVersionModel {
factory LessonVersionModel.fromJson(Map<String, dynamic> json) {
final playback = json['playback'] is Map ? json['playback'] as Map : {};
final vidUrl = playback['hls_url']?.toString() ??
playback['stream_url']?.toString() ??
playback['video_url']?.toString() ?? '';
final vidUrl = _absolutePlaybackUrl(
playback['hls_url']?.toString() ?? playback['stream_url']?.toString() ?? playback['video_url']?.toString() ?? '',
);
return LessonVersionModel(
lessonId: (json['lesson_id'] as num?)?.toInt() ?? 0,
@@ -168,8 +168,8 @@ class GuardianRepository {
Future<List<GuardianChildModel>> getDashboardChildren() async {
final res = await _api.get(AppConfig.guardianDashboardEndpoint);
if (res is Map && res['data'] is List) {
return (res['data'] as List)
if (res is Map && res['data'] is Map && (res['data']['children'] is List)) {
return (res['data']['children'] as List)
.map((item) => GuardianChildModel.fromJson(Map<String, dynamic>.from(item)))
.toList();
}
@@ -137,4 +137,19 @@ class CurriculumRepository {
throw ApiException('فشل جلب بيانات تشغيل الدرس من الخادم');
}
Future<void> saveProgress({required int lessonId, required int positionSeconds, required int watchedSeconds}) async {
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 {
await _api.post('/api/exams/$examId/submit', body: {
'answers': [
{'question_id': questionId, 'selected_option_id': optionId}
],
});
}
}
@@ -87,50 +87,8 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
isPlaying: true,
));
} catch (e) {
AppLogger.log('Playback API notice for ${lesson.title}: $e (Generating dynamic Socratic session)', tag: 'VIDEO_CUBIT');
// Create dynamic fallback Socratic session with lesson checkpoints
final fallbackPlayback = LessonPlaybackData(
lessonId: 1,
title: lesson.title,
durationSeconds: lesson.durationSeconds > 0 ? lesson.durationSeconds : 600,
videoUrl: 'https://saqel.intaleqapp.com/api/videos/stream/demo-vector-lesson',
availableVersions: const [
LessonVersionModel(
lessonId: 1,
isAi: true,
teacherName: 'المعلم الافتراضي بالذكاء الاصطناعي',
schoolName: 'وزارة التربية والتعليم',
label: 'شرح المنهاج المعتمد 🤖',
isRecommended: true,
videoUrl: '',
),
],
checkpoints: [
SocraticCheckpointModel(
id: 1,
questionText: lesson.outcomes.isNotEmpty
? 'وفقاً لنتاجات هذا الدرس (${lesson.outcomes.first}): ما هو المفهوم الجوهري الواجب إتقانه هنا؟'
: 'ما هو المبدأ الأساسي المشروح في هذا المبحث؟',
timestampSeconds: 15,
rewindSecondsOnFail: 20,
hint: 'راجع نتاجات التعلم الموضحة أسفل الفيديو بدقة.',
pedagogicalExplanation: 'الإتقان السقراطي يتطلب الفهم المفاهيمي العميق قبل الانتقال للمسائل الحسابية.',
options: const [
SocraticOptionModel(id: 1, text: 'الفهم المنهجي والتطبيق المباشر للقواعد', isCorrect: true),
SocraticOptionModel(id: 2, text: 'الحفظ المجرد دون فهم', isCorrect: false),
SocraticOptionModel(id: 3, text: 'تخطي المفهوم', isCorrect: false),
],
),
],
);
emit(VideoPlaybackReady(
playbackData: fallbackPlayback,
lessonItem: lesson,
subject: subject,
currentPositionSeconds: 0,
isPlaying: true,
));
AppLogger.log('Playback API failed for ${lesson.title}: $e', tag: 'VIDEO_CUBIT');
emit(VideoPlaybackError('لا يوجد فيديو منشور لهذا الدرس حاليًا. يرجى المحاولة لاحقًا.'));
}
}
@@ -170,6 +128,20 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
}
}
Future<void> saveProgress({required int positionSeconds, required int watchedSeconds}) async {
final currentState = state;
if (currentState is! VideoPlaybackReady || currentState.playbackData.lessonId <= 0) return;
try {
await _repo.saveProgress(
lessonId: currentState.playbackData.lessonId,
positionSeconds: positionSeconds,
watchedSeconds: watchedSeconds,
);
} catch (e) {
AppLogger.log('Progress sync deferred: $e', tag: 'VIDEO_CUBIT');
}
}
/// Submit Socratic Checkpoint Answer
bool submitCheckpointAnswer(SocraticOptionModel selectedOption) {
final currentState = state;
@@ -187,6 +159,11 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
passedCheckpointIds: updatedPassed,
remediationNotice: 'إجابة نموذجية ممتازة! تم تعزيز مؤشر الجاهزية (+0.5%) 🚀',
));
if (cp.id > 0) {
_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
@@ -198,6 +175,11 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
isPlaying: true,
remediationNotice: 'تعثرت في هذا المفهوم. تم إرجاع الفيديو ${cp.rewindSecondsOnFail} ثانية لإعادة الاستماع بتركيز 🔄',
));
if (cp.id > 0 && cp.questionId > 0) {
_repo.submitCheckpoint(examId: cp.id, questionId: cp.questionId, optionId: selectedOption.id).catchError((e) {
AppLogger.log('Checkpoint sync deferred: $e', tag: 'VIDEO_CUBIT');
});
}
return false;
}
}
@@ -10,6 +10,7 @@ import '../../../logic/cubits/auth_cubit.dart';
import '../../../logic/cubits/video_playback_cubit.dart';
import '../../widgets/luxury_widgets.dart';
import '../../widgets/socratic_dialog.dart';
import 'package:video_player/video_player.dart';
class SocraticVideoPlayerScreen extends StatefulWidget {
final CurriculumLessonItemModel lesson;
@@ -28,6 +29,8 @@ class SocraticVideoPlayerScreen extends StatefulWidget {
class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> with SingleTickerProviderStateMixin {
Timer? _playbackTicker;
late AnimationController _watermarkController;
VideoPlayerController? _videoController;
bool _isVideoInitialized = false;
@override
void initState() {
@@ -40,14 +43,14 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
duration: const Duration(seconds: 18),
)..repeat(reverse: true);
// Ticker simulating video playback time progression and Socratic checkpoint gatekeeping
// Read the real player clock and periodically persist progress.
_playbackTicker = Timer.periodic(const Duration(seconds: 1), (timer) {
final cubit = context.read<VideoPlaybackCubit>();
final state = cubit.state;
if (state is VideoPlaybackReady && state.isPlaying && state.activeCheckpoint == null) {
if (state.currentPositionSeconds < state.playbackData.durationSeconds) {
cubit.updatePosition(state.currentPositionSeconds + 1);
}
if (state is VideoPlaybackReady && _videoController?.value.isInitialized == true) {
final position = _videoController!.value.position.inSeconds;
if (state.activeCheckpoint == null) cubit.updatePosition(position);
cubit.saveProgress(positionSeconds: position, watchedSeconds: position);
}
});
}
@@ -56,6 +59,7 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
void dispose() {
_playbackTicker?.cancel();
_watermarkController.dispose();
_videoController?.dispose();
super.dispose();
}
@@ -111,6 +115,31 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
body: BlocConsumer<VideoPlaybackCubit, VideoPlaybackState>(
listener: (context, state) {
if (state is VideoPlaybackReady) {
// Initialize Video Player if not yet initialized
if (_videoController == null) {
_videoController = VideoPlayerController.networkUrl(Uri.parse(state.playbackData.videoUrl))
..initialize().then((_) {
if (mounted) {
setState(() {
_isVideoInitialized = true;
});
if (state.isPlaying && state.activeCheckpoint == null) {
_videoController!.play();
}
}
});
} else if (_isVideoInitialized) {
// Sync play/pause state
if (state.isPlaying && state.activeCheckpoint == null) {
_videoController!.play();
} else {
_videoController!.pause();
}
if (state.currentPositionSeconds != _videoController!.value.position.inSeconds) {
_videoController!.seekTo(Duration(seconds: state.currentPositionSeconds));
}
}
// Trigger Socratic Checkpoint Modal automatically when active
if (state.activeCheckpoint != null) {
showDialog(
@@ -187,20 +216,35 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
borderRadius: BorderRadius.circular(24),
child: Stack(
children: [
// Video Background Scene Simulation & Visualizer
Container(
decoration: const BoxDecoration(
gradient: RadialGradient(
center: Alignment(0.0, -0.3),
radius: 1.3,
colors: [
Color(0xFF0F2342),
Color(0xFF060B14),
],
),
),
child: Stack(
children: [
// Video Background Scene
Stack(
children: [
if (_videoController != null && _isVideoInitialized)
Positioned.fill(
child: FittedBox(
fit: BoxFit.cover,
child: SizedBox(
width: _videoController!.value.size.width,
height: _videoController!.value.size.height,
child: VideoPlayer(_videoController!),
),
),
)
else
Positioned.fill(
child: Container(
decoration: const BoxDecoration(
gradient: RadialGradient(
center: Alignment(0.0, -0.3),
radius: 1.3,
colors: [
Color(0xFF0F2342),
Color(0xFF060B14),
],
),
),
),
),
// Grid Overlay Effect
Positioned.fill(
child: Opacity(
@@ -315,7 +359,6 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
),
],
),
),
// Dynamic Forensic Anti-Piracy Watermark (Moves continuously)
AnimatedBuilder(
@@ -8,9 +8,11 @@ import Foundation
import device_info_plus
import flutter_secure_storage_macos
import shared_preferences_foundation
import video_player_avfoundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin"))
}
+62 -6
View File
@@ -73,6 +73,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
csslib:
dependency: transitive
description:
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
cupertino_icons:
dependency: "direct main"
description:
@@ -216,6 +224,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.2"
html:
dependency: transitive
description:
name: html
sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699"
url: "https://pub.dev"
source: hosted
version: "0.15.7"
http:
dependency: "direct main"
description:
@@ -316,10 +332,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.18"
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
@@ -332,10 +348,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.18.0"
nested:
dependency: transitive
description:
@@ -561,10 +577,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.9"
version: "0.7.11"
typed_data:
dependency: transitive
description:
@@ -581,6 +597,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
video_player:
dependency: "direct main"
description:
name: video_player
sha256: "48a7bdaa38a3d50ec10c78627abdbfad863fdf6f0d6e08c7c3c040cfd80ae36f"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
video_player_android:
dependency: transitive
description:
name: video_player_android
sha256: "877a6c7ba772456077d7bfd71314629b3fe2b73733ce503fc77c3314d43a0ca0"
url: "https://pub.dev"
source: hosted
version: "2.9.5"
video_player_avfoundation:
dependency: transitive
description:
name: video_player_avfoundation
sha256: "436fd029bd1c1e303b2d95ebd76948893f3c28dab286e7235ba9dd7b22533bf0"
url: "https://pub.dev"
source: hosted
version: "2.11.1"
video_player_platform_interface:
dependency: transitive
description:
name: video_player_platform_interface
sha256: "92c0fbabe20c788e71fd10d26cea998d0d253282e65d145aed0818731cf593ce"
url: "https://pub.dev"
source: hosted
version: "6.9.0"
video_player_web:
dependency: transitive
description:
name: video_player_web
sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
vm_service:
dependency: transitive
description:
+1
View File
@@ -34,6 +34,7 @@ dependencies:
device_info_plus: ^10.1.0
google_fonts: ^6.2.1
intl: ^0.19.0
video_player: ^2.11.1
dev_dependencies:
flutter_test: