Update Saqel Platform: 2026-09-02 00:55:56
This commit is contained in:
@@ -6,11 +6,11 @@
|
||||
|
||||
## 🏛️ الهيكل التقني للمشروع (Tech Stack)
|
||||
|
||||
- **Backend:** PHP 8.3+ / Laravel 11 / RESTful APIs
|
||||
- **Backend:** PHP 8.3+ / Native PHP REST API with PDO and a lightweight internal Router
|
||||
- **Database:** SQLite (Local Development/Testing) / MySQL 8 (Production)
|
||||
- **AI Engine:** Google Gemini Flash API (Multimodal / Socratic Tutor / Concept Diagnosis)
|
||||
- **Video & DRM:** Bunny Stream (HLS + Dynamic Watermark + DRM)
|
||||
- **Deployment:** Docker & Docker Compose (`saqel_app`, `saqel_nginx`, `saqel_mysql`, `saqel_queue`)
|
||||
- **Deployment:** PHP-FPM/Nginx on the production server; Cloudflare R2 for video and static media
|
||||
|
||||
---
|
||||
|
||||
@@ -22,11 +22,14 @@ cd backend
|
||||
php artisan test
|
||||
```
|
||||
|
||||
### 2. تشغيل السيرفر عبر Docker:
|
||||
### 2. تشغيل الـAPI محليًا:
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
cd backend
|
||||
php -S 127.0.0.1:8000 -t public
|
||||
```
|
||||
|
||||
الإنتاج يعمل عبر PHP-FPM وNginx مباشرة، ولا يعتمد على Laravel أو Docker.
|
||||
|
||||
---
|
||||
|
||||
## 📂 مسار التوثيق والتحليلات
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+63
-20
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -25,27 +25,6 @@ class GuardianController
|
||||
[$guardianId]
|
||||
);
|
||||
|
||||
// If no children linked (for demo purposes, link up to 3 students so the multi-child UI can be tested)
|
||||
if (empty($children)) {
|
||||
$demoStudents = Database::select("SELECT id FROM students LIMIT 3");
|
||||
if (!empty($demoStudents)) {
|
||||
foreach ($demoStudents as $demoStudent) {
|
||||
Database::insert(
|
||||
"INSERT IGNORE INTO guardian_students (guardian_id, student_id, relationship_type) VALUES (?, ?, 'father')",
|
||||
[$guardianId, $demoStudent['id']]
|
||||
);
|
||||
}
|
||||
// Re-fetch
|
||||
$children = Database::select(
|
||||
"SELECT s.id, s.uuid, s.full_name, s.national_id, s.grade_level, s.stream
|
||||
FROM students s
|
||||
JOIN guardian_students gs ON gs.student_id = s.id
|
||||
WHERE gs.guardian_id = ?",
|
||||
[$guardianId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$dashboardData = [];
|
||||
|
||||
foreach ($children as $child) {
|
||||
|
||||
@@ -400,67 +400,9 @@ class VideoController
|
||||
}
|
||||
|
||||
if (!$lesson) {
|
||||
// Construct a complete, standalone live playback payload directly from the requested lesson
|
||||
$cleanTitle = str_replace(['_', '-'], ' ', $rawId);
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'lesson' => [
|
||||
'id' => 1,
|
||||
'course_id' => 0,
|
||||
'title' => !empty($rawId) ? $cleanTitle : 'الدرس التفاعلي المعتمد',
|
||||
'duration_seconds' => 900,
|
||||
'is_free_preview' => true,
|
||||
'storage_type' => 'api_upload'
|
||||
],
|
||||
'playback' => [
|
||||
'storage_type' => 'api_upload',
|
||||
'stream_url' => 'https://saqel.intaleqapp.com/api/videos/stream/demo-vector-lesson',
|
||||
'hls_url' => 'https://saqel.intaleqapp.com/api/videos/stream/demo-vector-lesson',
|
||||
'video_url' => 'https://saqel.intaleqapp.com/api/videos/stream/demo-vector-lesson',
|
||||
'is_direct' => true
|
||||
],
|
||||
'available_versions' => [
|
||||
[
|
||||
'lesson_id' => 1,
|
||||
'is_ai' => true,
|
||||
'teacher_name' => 'الذكاء الاصطناعي الوزاري',
|
||||
'school_name' => 'وزارة التربية والتعليم',
|
||||
'label' => 'شرح الذكاء الاصطناعي المعتمد 🤖',
|
||||
'is_recommended' => true,
|
||||
'playback' => [
|
||||
'video_url' => 'https://saqel.intaleqapp.com/api/videos/stream/demo-vector-lesson'
|
||||
]
|
||||
]
|
||||
],
|
||||
'chapters' => [],
|
||||
'checkpoints' => [
|
||||
[
|
||||
'exam_id' => 1,
|
||||
'timestamp_seconds' => 15,
|
||||
'rewind_on_fail_seconds' => 30,
|
||||
'question_text' => 'فحص الفهم السقراطي: ما هو المفهوم الأساسي المشروح في هذا الدرس؟',
|
||||
'explanation' => 'التركيز على نتاجات التعلم يضمن استيعاب المتطلبات الوزارية بدقة.',
|
||||
'options' => [
|
||||
['id' => 1, 'text' => 'تطبيق القواعد والمفاهيم العلمية/الرياضية بدقة', 'is_correct' => true],
|
||||
['id' => 2, 'text' => 'تجاهل الخطوات التحليلية', 'is_correct' => false],
|
||||
['id' => 3, 'text' => 'الحفظ المجرد دون فهم سياقي', 'is_correct' => false],
|
||||
]
|
||||
],
|
||||
[
|
||||
'exam_id' => 2,
|
||||
'timestamp_seconds' => 45,
|
||||
'rewind_on_fail_seconds' => 25,
|
||||
'question_text' => 'فحص الفهم التعمقي: كيف يتم التحقق من صحة الحل وتجنب الأخطاء الشائعة؟',
|
||||
'explanation' => 'التعويض المباشر والتحليل المنطقي يثبتان صحة النتائج.',
|
||||
'options' => [
|
||||
['id' => 4, 'text' => 'بالتعويض المباشر في المسألة الأصلية والتحقق من التكافؤ', 'is_correct' => true],
|
||||
['id' => 5, 'text' => 'بالاعتماد على التخمين', 'is_correct' => false],
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
$response->status(404)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'الدرس غير موجود أو لم يتم نشر الفيديو الخاص به بعد'
|
||||
]);
|
||||
return;
|
||||
}
|
||||
@@ -497,7 +439,8 @@ class VideoController
|
||||
}
|
||||
|
||||
$checkpoints[] = [
|
||||
'exam_id' => (int)$ex['exam_id'],
|
||||
'exam_id' => (int)$ex['exam_id'],
|
||||
'question_id' => $q ? (int)$q['id'] : 0,
|
||||
'timestamp_seconds' => (int)$ex['timestamp_seconds'],
|
||||
'rewind_on_fail_seconds' => (int)$ex['rewind_on_fail_seconds'],
|
||||
'question_text' => $q['question_text'] ?? 'سؤال فحص فهم الفكرة:',
|
||||
@@ -512,34 +455,6 @@ class VideoController
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($checkpoints)) {
|
||||
$checkpoints = [
|
||||
[
|
||||
'exam_id' => 1,
|
||||
'timestamp_seconds' => 15,
|
||||
'rewind_on_fail_seconds' => 30,
|
||||
'question_text' => 'فحص الفهم الأولي: ما هو المفهوم الأساسي المشروح في هذه الفقرة من الدرس؟',
|
||||
'explanation' => 'التركيز على نتاجات التعلم يضمن استيعاب المتطلبات الوزارية بدقة.',
|
||||
'options' => [
|
||||
['id' => 1, 'text' => 'تطبيق القواعد والمفاهيم العلمية/الرياضية بدقة', 'is_correct' => true],
|
||||
['id' => 2, 'text' => 'تجاهل الخطوات التحليلية', 'is_correct' => false],
|
||||
['id' => 3, 'text' => 'الحفظ المجرد دون فهم سياقي', 'is_correct' => false],
|
||||
]
|
||||
],
|
||||
[
|
||||
'exam_id' => 2,
|
||||
'timestamp_seconds' => 45,
|
||||
'rewind_on_fail_seconds' => 25,
|
||||
'question_text' => 'فحص الفهم التعمقي: كيف يتم التحقق من صحة الحل وتجنب الأخطاء الشائعة؟',
|
||||
'explanation' => 'التعويض المباشر والتحليل المنطقي يثبتان صحة النتائج.',
|
||||
'options' => [
|
||||
['id' => 4, 'text' => 'بالتعويض المباشر في المسألة الأصلية والتحقق من التكافؤ', 'is_correct' => true],
|
||||
['id' => 5, 'text' => 'بالاعتماد على التخمين', 'is_correct' => false],
|
||||
]
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
$storageType = $lesson['storage_type'] ?? 'bunny_stream';
|
||||
$playbackInfo = [];
|
||||
|
||||
@@ -553,7 +468,11 @@ class VideoController
|
||||
];
|
||||
} else {
|
||||
// Bunny Stream Signed Playback
|
||||
$bunnyId = $lesson['bunny_video_id'] ?: 'mock-bunny-guid-2026';
|
||||
$bunnyId = $lesson['bunny_video_id'] ?: '';
|
||||
if ($bunnyId === '') {
|
||||
$response->status(409)->json(['status' => 'error', 'message' => 'الفيديو لم يجهز للبث بعد']);
|
||||
return;
|
||||
}
|
||||
$signedData = VideoService::generateBunnySignedPlayback($bunnyId, 10800); // 3-hour token
|
||||
$playbackInfo = array_merge(['storage_type' => 'bunny_stream'], $signedData);
|
||||
}
|
||||
@@ -587,7 +506,8 @@ class VideoController
|
||||
'video_url' => $ver['ai_video_url'] ?: ($ver['hls_url'] ?: ('/api/videos/stream/' . $ver['video_uuid']))
|
||||
];
|
||||
} else {
|
||||
$bId = $ver['bunny_video_id'] ?: 'mock-bunny-guid-2026';
|
||||
$bId = $ver['bunny_video_id'] ?: '';
|
||||
if ($bId === '') continue;
|
||||
$signed = VideoService::generateBunnySignedPlayback($bId, 10800);
|
||||
$vPlayback = array_merge(['storage_type' => 'bunny_stream', 'video_url' => $signed['hls_url']], $signed);
|
||||
}
|
||||
@@ -637,6 +557,38 @@ class VideoController
|
||||
]);
|
||||
}
|
||||
|
||||
/** Save real playback progress; the client never owns the readiness score. */
|
||||
public function saveProgress(Request $request, Response $response): void
|
||||
{
|
||||
VideoService::ensureSchema();
|
||||
$lessonId = (int)$request->getParam('id');
|
||||
$body = $request->getBody();
|
||||
$position = max(0, (int)($body['position_seconds'] ?? 0));
|
||||
$watched = max(0, (int)($body['watched_seconds'] ?? $position));
|
||||
$lesson = Database::selectOne('SELECT id, duration_seconds FROM lessons WHERE id = ? LIMIT 1', [$lessonId]);
|
||||
if (!$lesson) {
|
||||
$response->status(404)->json(['status' => 'error', 'message' => 'الدرس غير موجود']);
|
||||
return;
|
||||
}
|
||||
|
||||
$duration = max(1, (int)$lesson['duration_seconds']);
|
||||
$percentage = min(100, round(($position / $duration) * 100, 2));
|
||||
$completed = $percentage >= 90 ? 1 : 0;
|
||||
Database::query(
|
||||
"INSERT INTO lesson_progress (student_id, lesson_id, position_seconds, watched_seconds, completion_percentage, is_completed, last_seen_at, completed_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NOW(), CASE WHEN ? = 1 THEN NOW() ELSE NULL END)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
position_seconds = VALUES(position_seconds),
|
||||
watched_seconds = GREATEST(watched_seconds, VALUES(watched_seconds)),
|
||||
completion_percentage = VALUES(completion_percentage),
|
||||
is_completed = GREATEST(is_completed, VALUES(is_completed)),
|
||||
last_seen_at = NOW(),
|
||||
completed_at = CASE WHEN is_completed = 1 OR VALUES(is_completed) = 1 THEN COALESCE(completed_at, NOW()) ELSE completed_at END",
|
||||
[$request->user_id, $lessonId, $position, $watched, $percentage, $completed, $completed]
|
||||
);
|
||||
$response->json(['status' => 'success', 'data' => ['completion_percentage' => $percentage, 'is_completed' => (bool)$completed]]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Webhook listener for Bunny Stream encoding notifications
|
||||
* POST /api/webhooks/bunny
|
||||
|
||||
@@ -158,6 +158,17 @@ class VideoService
|
||||
$r2Key = "videos/{$courseId}/{$targetFileName}";
|
||||
$r2VideoUrl = self::uploadToR2($targetPath, $r2Key, $mime);
|
||||
|
||||
// The player must receive a complete HLS package, not only the source MP4.
|
||||
// Upload the playlist and every segment using the same relative layout so
|
||||
// relative segment references in index.m3u8 continue to resolve on R2.
|
||||
if (!empty($hlsResult['hls_url'])) {
|
||||
$hlsDir = dirname(__DIR__, 2) . '/storage/hls/' . $courseId . '/' . $videoUuid;
|
||||
$r2HlsPlaylist = self::uploadHlsDirectoryToR2($hlsDir, $courseId, $videoUuid);
|
||||
if ($r2HlsPlaylist) {
|
||||
$hlsResult['hls_url'] = $r2HlsPlaylist;
|
||||
}
|
||||
}
|
||||
|
||||
$thumbnailPath = dirname(__DIR__, 2) . '/storage/hls/' . $courseId . '/' . $videoUuid . '/thumbnail.jpg';
|
||||
if (file_exists($thumbnailPath)) {
|
||||
$r2ThumbKey = "hls/{$courseId}/{$videoUuid}/thumbnail.jpg";
|
||||
@@ -393,6 +404,8 @@ class VideoService
|
||||
{
|
||||
$config = self::getBunnyConfig();
|
||||
if (empty($config['api_key'])) {
|
||||
throw new \RuntimeException('Bunny Stream غير مهيأ: أضف مفاتيح الوصول قبل إنشاء فيديو سحابي.');
|
||||
/*
|
||||
$mockGuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||
mt_rand(0, 0xffff),
|
||||
@@ -409,6 +422,7 @@ class VideoService
|
||||
'direct_embed' => "https://{$config['embed_host']}/embed/{$config['library_id']}/{$mockGuid}",
|
||||
'hls_playlist' => "https://{$config['pull_zone_host']}/{$mockGuid}/playlist.m3u8"
|
||||
];
|
||||
*/
|
||||
}
|
||||
|
||||
$url = "https://video.bunnycdn.com/library/{$config['library_id']}/videos";
|
||||
@@ -581,10 +595,33 @@ class VideoService
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode >= 200 && $httpCode < 300) {
|
||||
if ($httpCode >= 200 && $httpCode < 300 && !empty($config['public_url'])) {
|
||||
return rtrim($config['public_url'], '/') . '/' . ltrim($r2Key, '/');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Upload a complete HLS directory and return the public playlist URL. */
|
||||
private static function uploadHlsDirectoryToR2(string $directory, int $courseId, string $videoUuid): ?string
|
||||
{
|
||||
if (!is_dir($directory)) return null;
|
||||
|
||||
$playlistUrl = null;
|
||||
foreach (glob($directory . '/*') ?: [] as $filePath) {
|
||||
if (!is_file($filePath)) continue;
|
||||
$extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION));
|
||||
$contentType = match ($extension) {
|
||||
'm3u8' => 'application/vnd.apple.mpegurl',
|
||||
'ts' => 'video/mp2t',
|
||||
'jpg', 'jpeg' => 'image/jpeg',
|
||||
default => 'application/octet-stream',
|
||||
};
|
||||
$key = "hls/{$courseId}/{$videoUuid}/" . basename($filePath);
|
||||
$url = self::uploadToR2($filePath, $key, $contentType);
|
||||
if ($extension === 'm3u8' && $url) $playlistUrl = $url;
|
||||
}
|
||||
|
||||
return $playlistUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,7 +226,11 @@ CREATE TABLE IF NOT EXISTS `lessons` (
|
||||
|
||||
-- المعلم الحقيقي (Teacher Video)
|
||||
`video_uuid` VARCHAR(64) DEFAULT NULL,
|
||||
`storage_type` ENUM('bunny_stream', 'api_upload', 'external_url') NOT NULL DEFAULT 'bunny_stream',
|
||||
`local_path` VARCHAR(500) DEFAULT NULL,
|
||||
`hls_url` VARCHAR(500) DEFAULT NULL,
|
||||
`thumbnail_url` VARCHAR(500) DEFAULT NULL,
|
||||
`encoding_status` ENUM('pending', 'processing', 'ready', 'failed') NOT NULL DEFAULT 'ready',
|
||||
`timeline_chapters_json` JSON DEFAULT NULL,
|
||||
`bunny_video_id` VARCHAR(100) DEFAULT NULL,
|
||||
`duration_seconds` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
@@ -249,6 +253,46 @@ CREATE TABLE IF NOT EXISTS `lessons` (
|
||||
CONSTRAINT `fk_lessons_course` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ------------------------------------------------------------------------------
|
||||
-- 10.6. Table: lesson_progress (تقدم المشاهدة الفعلي لكل طالب)
|
||||
-- ------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `lesson_progress` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`student_id` BIGINT UNSIGNED NOT NULL,
|
||||
`lesson_id` BIGINT UNSIGNED NOT NULL,
|
||||
`position_seconds` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`watched_seconds` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`completion_percentage` DECIMAL(5,2) NOT NULL DEFAULT 0.00,
|
||||
`is_completed` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`last_seen_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`completed_at` TIMESTAMP NULL DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_progress_student_lesson` (`student_id`, `lesson_id`),
|
||||
KEY `idx_progress_lesson` (`lesson_id`),
|
||||
CONSTRAINT `fk_progress_student` FOREIGN KEY (`student_id`) REFERENCES `students` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_progress_lesson` FOREIGN KEY (`lesson_id`) REFERENCES `lessons` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ------------------------------------------------------------------------------
|
||||
-- 10.7. Table: student_mastery_analytics (المؤشر التراكمي للطالب)
|
||||
-- ------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `student_mastery_analytics` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`student_id` BIGINT UNSIGNED NOT NULL,
|
||||
`course_id` BIGINT UNSIGNED NOT NULL,
|
||||
`subject_id` BIGINT UNSIGNED NOT NULL,
|
||||
`mastery_percentage` DECIMAL(5,2) NOT NULL DEFAULT 0.00,
|
||||
`tawjihi_readiness_score` DECIMAL(5,2) NOT NULL DEFAULT 0.00,
|
||||
`exams_passed_count` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`exams_total_count` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `idx_mastery_student_course` (`student_id`, `course_id`),
|
||||
CONSTRAINT `fk_mastery_student` FOREIGN KEY (`student_id`) REFERENCES `students` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_mastery_course` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_mastery_subject` FOREIGN KEY (`subject_id`) REFERENCES `subjects` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ------------------------------------------------------------------------------
|
||||
-- 10.5. Table: lesson_resources (المصادر المرفقة للدرس: كتب، ملخصات، أوراق عمل PDF)
|
||||
-- ------------------------------------------------------------------------------
|
||||
@@ -361,6 +405,7 @@ CREATE TABLE IF NOT EXISTS `exam_attempts` (
|
||||
`time_spent_seconds` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`weak_topics_json` JSON DEFAULT NULL,
|
||||
`ai_diagnostic_report` TEXT DEFAULT NULL,
|
||||
`completed_at` TIMESTAMP NULL DEFAULT NULL,
|
||||
`created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_attempts_student` (`student_id`),
|
||||
@@ -369,6 +414,25 @@ CREATE TABLE IF NOT EXISTS `exam_attempts` (
|
||||
CONSTRAINT `fk_attempts_exam` FOREIGN KEY (`exam_id`) REFERENCES `exams` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ------------------------------------------------------------------------------
|
||||
-- 15.5. Table: student_question_answers (تفاصيل إجابات الطالب)
|
||||
-- ------------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `student_question_answers` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`attempt_id` BIGINT UNSIGNED NOT NULL,
|
||||
`student_id` BIGINT UNSIGNED NOT NULL,
|
||||
`question_id` BIGINT UNSIGNED NOT NULL,
|
||||
`selected_option_id` BIGINT UNSIGNED DEFAULT NULL,
|
||||
`is_correct` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`points_awarded` DECIMAL(6,2) NOT NULL DEFAULT 0.00,
|
||||
`time_spent_seconds` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_sqa_attempt` (`attempt_id`),
|
||||
CONSTRAINT `fk_sqa_attempt` FOREIGN KEY (`attempt_id`) REFERENCES `exam_attempts` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_sqa_student` FOREIGN KEY (`student_id`) REFERENCES `students` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_sqa_question` FOREIGN KEY (`question_id`) REFERENCES `questions` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ------------------------------------------------------------------------------
|
||||
-- 16. Table: teacher_reviews (تقييمات الطلاب المحصنة بالأوزان وخاصية كشف الكيد)
|
||||
-- ------------------------------------------------------------------------------
|
||||
|
||||
@@ -128,6 +128,7 @@ $router->get('/api/exams', [\App\Controllers\ExamControlle
|
||||
$router->get('/api/exams/{id}', [\App\Controllers\ExamController::class, 'getExamDetails'], [\App\Middlewares\AuthMiddleware::class]);
|
||||
$router->post('/api/exams/{id}/submit', [\App\Controllers\ExamController::class, 'submitExam'], [\App\Middlewares\AuthMiddleware::class]);
|
||||
$router->get('/api/student/progress/mastery', [\App\Controllers\ExamController::class, 'getMastery'], [\App\Middlewares\AuthMiddleware::class]);
|
||||
$router->post('/api/student/lessons/{id}/progress', [\App\Controllers\VideoController::class, 'saveProgress'], [\App\Middlewares\AuthMiddleware::class]);
|
||||
|
||||
// Multi-Teacher Marketplace & Fair Reputation Routes (AI Telemetry + Anti-Brigade Defense)
|
||||
$router->get('/api/teachers', [\App\Controllers\TeacherController::class, 'getMarketplaceTeachers']);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# حالة تنفيذ منصة صَقِل
|
||||
|
||||
آخر تحديث: 2026-09-02
|
||||
|
||||
هذه الوثيقة تفصل بين ما هو موجود في الكود وما هو جزء من الرؤية المستقبلية.
|
||||
|
||||
## منفذ فعليًا
|
||||
|
||||
- تطبيق Flutter موحد للطالب وولي الأمر.
|
||||
- تسجيل الدخول عبر OTP والرقم الوطني مع تخزين آمن للتوكن.
|
||||
- شجرة مناهج ديناميكية من الخادم ومحتوى فعلي للصف العاشر.
|
||||
- رفع الفيديو، تحويله إلى HLS عبر FFmpeg، ورفع حزمة HLS إلى Cloudflare R2.
|
||||
- تشغيل الفيديو عبر HLS من R2 أو المسار المحلي عند عدم توفر R2.
|
||||
- فحوص سقراطية مرتبطة بالدرس من قاعدة البيانات.
|
||||
- بوابات Web للطالب والمعلم وولي الأمر واستوديو المناهج.
|
||||
- API للامتحانات والمحاولات والتحليلات والمحادثة وتقييم المعلم.
|
||||
|
||||
## منفذ جزئيًا
|
||||
|
||||
- تطبيق المعلم Flutter: shell ترحيبي فقط؛ الاستخدام الحالي للمعلم عبر Web Studio.
|
||||
- امتحانات Flutter: واجهة أولية تحتاج ربطًا كاملًا بواجهات `/api/exams`.
|
||||
- تنزيل موارد PDF: واجهة تحتاج ربطًا فعليًا بالملف.
|
||||
- مؤشر الجاهزية: مصدره الخادم في التحليلات، ويحتاج استكمال عرضه وتحديثه في الشاشات.
|
||||
- حماية الفيديو: HLS/R2 متوفران، أما DRM ومنع التسجيل فيحتاجان تكاملًا إنتاجيًا.
|
||||
|
||||
## غير منفذ بعد
|
||||
|
||||
- تطبيق الإدارة Flutter.
|
||||
- RAG كامل مع Embeddings ومصادر إجابة قابلة للتتبع.
|
||||
- تنبيهات ولي الأمر الفورية داخل التطبيق.
|
||||
- خوارزمية تكيفية إنتاجية متعددة المفاهيم.
|
||||
|
||||
## المعمارية المعتمدة
|
||||
|
||||
- Backend: Native PHP 8.3+، PDO، Router داخلي، PHP-FPM/Nginx.
|
||||
- Storage: Cloudflare R2 لحزمة HLS والملفات، مع تخزين محلي مؤقت أثناء التحويل.
|
||||
- Apps: `student_app` موحد للطالب وولي الأمر، `teacher_app` مستقل، و`admin_app` مستقل.
|
||||
- لا يعتمد التشغيل على Laravel أو Docker.
|
||||
@@ -23,7 +23,7 @@
|
||||
│ HTTPS / REST API / JWT + Device Fingerprint
|
||||
│
|
||||
┌────────────────────▼────────────────────┐
|
||||
│ Laravel 11 API Gateway (PHP 8.3) │
|
||||
│ Native PHP API Gateway (PHP 8.3+) │
|
||||
│ (Zero-Trust Auth / Queues / Logic) │
|
||||
└────────┬───────────────┬────────────────┘
|
||||
│ │
|
||||
@@ -80,13 +80,13 @@ saqel/
|
||||
│ ├── student_app/ # تطبيق صَقِل الموحد (الطالب: كويزات وفيديوهات + ولي الأمر: تقارير الأبناء)
|
||||
│ ├── teacher_app/ # تطبيق المعلم (رفع المواد، كويزات، تقارير المبيعات والأرباح)
|
||||
│ └── admin_app/ # تطبيق الإدارة ومديري المدارس (B2B Management)
|
||||
├── backend/ # مشروع Laravel 11 / Native PHP 8.3 API Gateway
|
||||
├── backend/ # Native PHP 8.3 API Gateway + PDO + Router
|
||||
│ ├── app/Controllers/ # وحدات التحكم بالـ APIs
|
||||
│ ├── app/Services/ # خدمات AI، و Nabeh WhatsApp Gateway، و Curriculum
|
||||
│ └── database/ # مخطط قاعدة البيانات
|
||||
├── docs/ # وثائق التخطيط والتحليل
|
||||
│ └── PLATFORM_MASTER_PLAN.md # هذا المرجع المعماري الشامل
|
||||
├── docker-compose.yml # حاويات: saqel_app, saqel_nginx, saqel_mysql, saqel_queue
|
||||
├── docs/ # وثائق الخطة وحالة التنفيذ
|
||||
└── README.md # دليل التشغيل السريع
|
||||
```
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
## 1. الملخص التنفيذي والرؤية الاستثمارية (Executive Summary & Moat)
|
||||
منصة **صَقِل (Saqel)** هي البنية التحتية التعليمية الذكية الأحدث في المملكة الأردنية الهاشمية والشرق الأوسط (MENA). تجمع المنصة بين:
|
||||
1. **التطبيق الموحد الذكي (Unified Dynamic App Shell):** تطبيق واحد متعدد الهويات يعمل على (Web, Windows, Tablets, Mobile) ويدعم التبديل السلس بين الأدوار (طالب، معلم، ولي أمر).
|
||||
1. **التطبيق الموحد الذكي للطالب والأسرة:** تطبيق واحد يعمل على (Web, Windows, Tablets, Mobile) ويدعم التبديل بين الطالب وولي الأمر فقط. تطبيق المعلم وتطبيق الإدارة مستقلان.
|
||||
2. **التعلم السقراطي النشط وسد الثغرات (Productive Struggle & AI Remediation):** إرجاع الفيديو عند الخطأ وتوليد فحوصات وتلميحات موجهة لمعالجة نقاط ضعف الطالب اللحظية.
|
||||
3. **المعمارية المؤسسية الهجينة (B2B2C Multi-School Ecosystem):** ربط مدارس الثقافة العسكرية والمدارس الخاصة عبر كشوفات الأرقام الوطنية (`school_rosters`).
|
||||
4. **بوابة ولي الأمر والأبناء المتعددين (Guardian Portal & Multi-Child Graph):** متابعة جاهزية الأبناء وسجل معالجة نقاط الضعف من شاشة واحدة (`/guardian`).
|
||||
@@ -55,4 +55,4 @@ php backend/curriculum_converter.php
|
||||
## 5. البنية السحابية وهندسة البث (Cloud Infrastructure & Zero-Egress R2)
|
||||
- **محرك البث:** تقطيع فيديوهات HLS عبر FFmpeg، والتخزين السحابي على Cloudflare R2 بدون رسوم خروج بيانات.
|
||||
- **محرك المحادثات الفورية:** خادم Workerman WebSocket على المنفذ 8080 لتوصيل الرسائل والإشعارات في زمن 0ms.
|
||||
- **الأداء العالي:** تشغيل مباشر عبر PHP 8.4 و PHP-FPM دون حاجة لـ Docker.
|
||||
- **الأداء العالي:** تشغيل مباشر عبر PHP 8.3+ وPHP-FPM وNginx دون Laravel أو Docker.
|
||||
|
||||
Reference in New Issue
Block a user