Update Saqel Platform: 2026-09-02 00:55:56
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user