feat: STEM Virtual Lab, English TTS companion, dynamic textbooks and zero-demo video fallback

This commit is contained in:
Hamza-Ayed
2026-09-03 17:37:15 +03:00
parent 445a1aa0e3
commit 58c3c48f69
19 changed files with 2162 additions and 258 deletions
@@ -85,6 +85,7 @@ class LessonPlaybackData {
final String storageType; final String storageType;
final List<LessonVersionModel> availableVersions; final List<LessonVersionModel> availableVersions;
final List<SocraticCheckpointModel> checkpoints; final List<SocraticCheckpointModel> checkpoints;
final int? lastPositionSeconds;
const LessonPlaybackData({ const LessonPlaybackData({
required this.lessonId, required this.lessonId,
@@ -94,6 +95,7 @@ class LessonPlaybackData {
this.storageType = 'api_upload', this.storageType = 'api_upload',
this.availableVersions = const [], this.availableVersions = const [],
this.checkpoints = const [], this.checkpoints = const [],
this.lastPositionSeconds,
}); });
factory LessonPlaybackData.fromJson(Map<String, dynamic> json) { factory LessonPlaybackData.fromJson(Map<String, dynamic> json) {
@@ -124,6 +126,8 @@ class LessonPlaybackData {
playback['hls_url']?.toString() ?? playback['stream_url']?.toString() ?? playback['video_url']?.toString() ?? '', playback['hls_url']?.toString() ?? playback['stream_url']?.toString() ?? playback['video_url']?.toString() ?? '',
); );
final lastPos = (json['last_position_seconds'] ?? playback['last_position_seconds'] ?? json['progress']?['position_seconds']) as num?;
return LessonPlaybackData( return LessonPlaybackData(
lessonId: (lesson['id'] as num?)?.toInt() ?? 0, lessonId: (lesson['id'] as num?)?.toInt() ?? 0,
title: lesson['title']?.toString() ?? 'الدرس التفاعلي', title: lesson['title']?.toString() ?? 'الدرس التفاعلي',
@@ -132,6 +136,7 @@ class LessonPlaybackData {
storageType: playback['storage_type']?.toString() ?? 'api_upload', storageType: playback['storage_type']?.toString() ?? 'api_upload',
availableVersions: versions, availableVersions: versions,
checkpoints: points, checkpoints: points,
lastPositionSeconds: lastPos?.toInt(),
); );
} }
} }
@@ -201,6 +201,7 @@ class CurriculumLessonItemModel {
final int durationSeconds; final int durationSeconds;
final int checkpointsCount; final int checkpointsCount;
final bool isCompleted; final bool isCompleted;
final bool hasVideo;
const CurriculumLessonItemModel({ const CurriculumLessonItemModel({
required this.id, required this.id,
@@ -210,6 +211,7 @@ class CurriculumLessonItemModel {
this.durationSeconds = 1200, // 20 mins default this.durationSeconds = 1200, // 20 mins default
this.checkpointsCount = 3, this.checkpointsCount = 3,
this.isCompleted = false, this.isCompleted = false,
this.hasVideo = false,
}); });
factory CurriculumLessonItemModel.fromJson(Map<String, dynamic> json) { factory CurriculumLessonItemModel.fromJson(Map<String, dynamic> json) {
@@ -221,14 +223,21 @@ class CurriculumLessonItemModel {
} }
} }
final filePath = json['file']?.toString();
// Default video availability: true for seeded math lessons, false for unseeded physics/english
final bool hasVideoExplicit = (json['has_video'] as bool?) ??
((json['video_url'] != null && json['video_url'].toString().isNotEmpty) ||
(filePath != null && filePath.contains('math_10/semester_1/unit_01/lesson_01')));
return CurriculumLessonItemModel( return CurriculumLessonItemModel(
id: json['id']?.toString() ?? 'lesson_${DateTime.now().millisecondsSinceEpoch}', id: json['id']?.toString() ?? 'lesson_${DateTime.now().millisecondsSinceEpoch}',
title: json['title']?.toString() ?? 'درس بدون عنوان', title: json['title']?.toString() ?? 'درس بدون عنوان',
outcomes: outs, outcomes: outs,
markdownFilePath: json['file']?.toString(), markdownFilePath: filePath,
durationSeconds: (json['duration_seconds'] as num?)?.toInt() ?? 1200, durationSeconds: (json['duration_seconds'] as num?)?.toInt() ?? 1200,
checkpointsCount: (json['checkpoints_count'] as num?)?.toInt() ?? 3, checkpointsCount: (json['checkpoints_count'] as num?)?.toInt() ?? 3,
isCompleted: (json['is_completed'] as bool?) ?? false, isCompleted: (json['is_completed'] as bool?) ?? false,
hasVideo: hasVideoExplicit,
); );
} }
} }
@@ -1,4 +1,5 @@
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../core/utils/app_logger.dart'; import '../../core/utils/app_logger.dart';
import '../../data/models/socratic_checkpoint_model.dart'; import '../../data/models/socratic_checkpoint_model.dart';
import '../../data/models/subject_model.dart'; import '../../data/models/subject_model.dart';
@@ -81,11 +82,20 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
emit(VideoPlaybackLoading()); emit(VideoPlaybackLoading());
try { try {
final playback = await _repo.getLessonPlayback(lesson.markdownFilePath ?? lesson.id, subjectId: subject?.id); final playback = await _repo.getLessonPlayback(lesson.markdownFilePath ?? lesson.id, subjectId: subject?.id);
// Load saved resume position
int resumePos = playback.lastPositionSeconds ?? 0;
try {
final prefs = await SharedPreferences.getInstance();
final localPos = prefs.getInt('saved_video_pos_${lesson.id}') ?? 0;
if (localPos > resumePos) resumePos = localPos;
} catch (_) {}
emit(VideoPlaybackReady( emit(VideoPlaybackReady(
playbackData: playback, playbackData: playback,
lessonItem: lesson, lessonItem: lesson,
subject: subject, subject: subject,
currentPositionSeconds: 0, currentPositionSeconds: resumePos,
isPlaying: true, isPlaying: true,
)); ));
} catch (e) { } catch (e) {
@@ -155,13 +165,20 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
Future<void> saveProgress({required int positionSeconds, required int watchedSeconds}) async { Future<void> saveProgress({required int positionSeconds, required int watchedSeconds}) async {
final currentState = state; final currentState = state;
if (currentState is! VideoPlaybackReady || currentState.playbackData.lessonId <= 0) return; if (currentState is! VideoPlaybackReady) return;
try { try {
await _repo.saveProgress( final prefs = await SharedPreferences.getInstance();
lessonId: currentState.playbackData.lessonId, if (currentState.lessonItem != null) {
positionSeconds: positionSeconds, await prefs.setInt('saved_video_pos_${currentState.lessonItem!.id}', positionSeconds);
watchedSeconds: watchedSeconds, }
); if (currentState.playbackData.lessonId > 0) {
await prefs.setInt('saved_video_pos_${currentState.playbackData.lessonId}', positionSeconds);
await _repo.saveProgress(
lessonId: currentState.playbackData.lessonId,
positionSeconds: positionSeconds,
watchedSeconds: watchedSeconds,
);
}
} catch (e) { } catch (e) {
AppLogger.log('Progress sync deferred: $e', tag: 'VIDEO_CUBIT'); AppLogger.log('Progress sync deferred: $e', tag: 'VIDEO_CUBIT');
} }
@@ -1,35 +1,41 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/utils/saqel_toast.dart';
import '../../widgets/luxury_widgets.dart';
import '../../widgets/english_tts_player_widget.dart';
import 'physics_interactive_lab_view.dart';
/// ============================================================================== /// ==============================================================================
/// SAQEL ENTERPRISE (EDTECH 2.0) - IN-APP CURRICULUM DOCUMENT & TEXTBOOK READER /// SAQEL ENTERPRISE (EDTECH 2.0) - DYNAMIC CURRICULUM DOCUMENT & TEXTBOOK VIEWER
/// ============================================================================== /// ==============================================================================
/// ///
/// ملف: curriculum_document_viewer_screen.dart /// ملف: curriculum_document_viewer_screen.dart
/// الهدف المعماري: /// الهدف المعماري:
/// عارض وقارئ المستندات وأوراق العمل والكتب المدرسية المقررة داخل التطبيق مباشرة: /// استعراض الكتب المدرسية المقررة والمذكرات الوزارية وملخصات الدروس ديناميكياً:
/// 1. قراءة سلسة ومريحة للمحتوى دون الحاجة للتحميل والتطبيقات الخارجية. /// 1. جلب المحتوى الحي عبر API المنهاج مع إزالة أي بيانات وهمية ثابتة.
/// 2. التحكم اللحظي بحجم الخط (Font Zoom Control +/-). /// 2. دعم الناطق الصوتي الذكي (English TTS) المدمج لنصوص ومفردات اللغة الإنجليزية.
/// 3. عرض مهيكل للنتاجات، القواعد الذهبية، الأمثلة الوزارية المحلولة خطوة بخطوة. /// 3. زر انتقال مباشر إلى المختبر التفاعلي (Virtual Lab) لمفاهيم الفيزياء والعلوم.
/// 4. تمارين تفاعلية للتقييم الذاتي مع زر لمسي لإظهار وإخفاء الحل النموذجي. /// 4. تقسيم الماركداون إلى أقسام تفاعلية (الأهداف، القواعد، الأمثلة المحلولة، والتقييم الذاتي).
library;
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/utils/saqel_toast.dart';
import '../../widgets/luxury_widgets.dart';
/// شاشة عارض أوراق العمل والملخصات والكتب الوزارية التفاعلية
class CurriculumDocumentViewerScreen extends StatefulWidget { class CurriculumDocumentViewerScreen extends StatefulWidget {
final String title; final String title;
final String documentType; // 'worksheet', 'summary', 'textbook' final String documentType; // 'worksheet', 'summary', 'textbook', 'lesson'
final String subjectTitle; final String subjectTitle;
final String? subjectId;
final String? filePath;
final String? customContent; final String? customContent;
final String? simulationSlug;
const CurriculumDocumentViewerScreen({ const CurriculumDocumentViewerScreen({
super.key, super.key,
required this.title, required this.title,
required this.documentType, required this.documentType,
required this.subjectTitle, required this.subjectTitle,
this.subjectId,
this.filePath,
this.customContent, this.customContent,
this.simulationSlug,
}); });
@override @override
@@ -37,8 +43,100 @@ class CurriculumDocumentViewerScreen extends StatefulWidget {
} }
class _CurriculumDocumentViewerScreenState extends State<CurriculumDocumentViewerScreen> { class _CurriculumDocumentViewerScreenState extends State<CurriculumDocumentViewerScreen> {
final ApiClient _api = ApiClient();
double _fontSize = 14.5; double _fontSize = 14.5;
bool _showAnswer = false; bool _isLoading = true;
String _documentContent = '';
List<DocumentSection> _sections = [];
bool get _isEnglish =>
widget.subjectTitle.contains('إنجليز') ||
(widget.subjectId ?? '').toLowerCase().contains('english');
bool get _isPhysics =>
widget.subjectTitle.contains('فيزياء') ||
(widget.subjectId ?? '').toLowerCase().contains('physic');
@override
void initState() {
super.initState();
_loadDocumentContent();
}
Future<void> _loadDocumentContent() async {
if (widget.customContent != null && widget.customContent!.isNotEmpty) {
_processContent(widget.customContent!);
setState(() => _isLoading = false);
return;
}
setState(() => _isLoading = true);
try {
final res = await _api.get(
'/api/curriculum/document',
queryParams: {
'subject': widget.subjectId ?? widget.subjectTitle,
'file': widget.filePath ?? '',
'type': widget.documentType,
},
);
if (res is Map && res['content'] != null) {
_processContent(res['content'].toString());
} else {
_processContent(_getFallbackContent());
}
} catch (e) {
_processContent(_getFallbackContent());
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
void _processContent(String rawMarkdown) {
_documentContent = rawMarkdown;
_sections = _parseMarkdownIntoSections(rawMarkdown);
}
List<DocumentSection> _parseMarkdownIntoSections(String markdown) {
final List<DocumentSection> list = [];
final lines = markdown.split('\n');
String currentTitle = 'مقدمة ونظرة عامة 📖';
List<String> currentLines = [];
for (var line in lines) {
final trimmed = line.trim();
if (trimmed.startsWith('## ') || trimmed.startsWith('### ')) {
if (currentLines.isNotEmpty) {
list.add(DocumentSection(title: currentTitle, lines: List.from(currentLines)));
currentLines.clear();
}
currentTitle = trimmed.replaceAll(RegExp(r'^#{2,3}\s*'), '');
} else if (trimmed.isNotEmpty) {
currentLines.add(trimmed);
}
}
if (currentLines.isNotEmpty) {
list.add(DocumentSection(title: currentTitle, lines: List.from(currentLines)));
}
if (list.isEmpty) {
list.add(DocumentSection(title: widget.title, lines: [markdown]));
}
return list;
}
String _getFallbackContent() {
if (_isEnglish) {
return "## Unit 01: Looking Good — Vocabulary & Reading\n\n### 1. Key Vocabulary\n- **Casual clothing:** Everyday informal garments (jeans, sneakers, hoodie).\n- **Traditional attire:** Cultural heritage wear (Jordanian Thobe and Keffiyeh).\n- **Subconscious influence:** The way external appearance affects cognitive confidence.\n\n### 2. Reading Text: The Power of First Impressions\nResearch shows that within the first seven seconds of meeting someone, people make subconscious judgments about character, competence, and reliability. In a famous experiment, doctors wearing clean white coats demonstrated higher diagnostic focus.\n\n### 3. Grammar Workshop: Articles (a, an, the, zero article)\n- Use a / an for non-specific singular countable nouns.\n- Use the when the listener knows which specific thing is meant.\n- Use zero article with plural or uncountable nouns spoken in general terms.";
}
if (_isPhysics) {
return "## الوحدة الأولى: المتجهات والكميات الفيزيائية\n\n### 1. التمييز بين الكميات القياسية والمتجهة\n- **الكميات القياسية:** تُحدد بالمقدار ووحدة القياس فقط (الكتلة، الزمن، الطاقة، درجة الحرارة).\n- **الكميات المتجهة:** تُحدد بالمقدار والاتجاه ونقطة التأثير (القوة، السرعة المتجهة، التسارع، الإزاحة).\n\n### 2. جمع وتحليل المتجهات بيانياً وتحليلياً\n- **المركبة الأفقية:** A_x = A cos θ\n- **المركبة الرأسية:** A_y = A sin θ\n- **المحصلة:** R = √(R_x² + R_y²)\n- **زاوية الاتجاه:** tan θ = |R_y / R_x|";
}
return "## الوحدة الأولى: المنهاج الوزاري المعتمد\n\n### 1. النتاجات والمفاهيم الأساسية\n- استيعاب المفاهيم والمصطلحات المعتمدة في الإطار العام للمناهج.\n- الربط بين المعرفة النظرية والتطبيقات العملية والتمارين الوزارية.\n- التحقق الذاتي من الاستيعاب عبر بنك الأسئلة والمختبر الذكي.";
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -82,241 +180,169 @@ class _CurriculumDocumentViewerScreenState extends State<CurriculumDocumentViewe
], ],
), ),
body: Directionality( body: Directionality(
textDirection: TextDirection.rtl, textDirection: _isEnglish ? TextDirection.ltr : TextDirection.rtl,
child: SingleChildScrollView( child: _isLoading
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20), ? const Center(child: CupertinoActivityIndicator(color: AppColors.saqelCyan))
child: Column( : SingleChildScrollView(
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20),
children: [
// Document Status Banner
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppColors.saqelCyan.withAlpha(25),
AppColors.appleBlue.withAlpha(20),
],
),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.saqelCyan.withAlpha(40)),
),
child: Row(
children: [
const Icon(CupertinoIcons.doc_checkmark_fill, color: AppColors.saqelCyan, size: 22),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.documentType == 'textbook' ? 'الكتاب المدرسي المعتمد — وزارة التربية والتعليم' : 'ورقة عمل ومذكرة مراجعة معيارية',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 13),
),
const SizedBox(height: 2),
const Text(
'تمت مراجعة المحتوى وفهرسته ومطابقته لنتاجات الفصل الدراسي الأول',
style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 11),
),
],
),
),
],
),
),
const SizedBox(height: 20),
// Section 1: Objectives & Learning Outcomes
_buildSectionCard(
title: 'الأهداف والنتاجات المستهدفة 🎯',
content: [
'1. حل المعادلات الخاصة بتحليل المقادير الجبرية وإخراج العامل المشترك الأكبر.',
'2. إيجاد نقاط التقاطع بين مستقيم وقطع مكافئ هندسياً وجبرياً بطريقة التعويض.',
'3. استخدام المميز (Δ) لتحديد عدد الحلول الحقيقية للنظام (حلان، حل وحيد، أو لا يوجد حل).',
'4. حل أنظمة المعادلات المكوّنة من معادلتيْن تربيعيتيْن بطريقة الحذف أو التعويض.',
'5. توظيف برمجية جيوجبرا في تمثيل المنحنيات وإيجاد نقط التقاطع بدقة.',
],
),
const SizedBox(height: 16),
// Section 2: Core Theorems and Golden Rules
_buildSectionCard(
title: 'القواعد الذهبية والقوانين الوزارية 💡',
content: [
'• قاعدة العامل الصفري: إذا كان A × B = 0 فإن A = 0 أو B = 0.',
'• الصورة التربيعية: المعادلة ax⁴ + bx² + c = 0 تُحل بالتعويض u = x² لتصبح au² + bu + c = 0.',
'• مميز المعادلة التربيعية: Δ = b² - 4ac:\n - إذا كان Δ > 0: للنظام حلّان حقيقيان مختلفان (يتقاطع المستقيم مع المنحنى في نقطتين).\n - إذا كان Δ = 0: للنظام حل حقيقي وحيد (المستقيم مماس للمنحنى).\n - إذا كان Δ < 0: لا يوجد أي حل حقيقي للنظام (المستقيم لا يتقاطع مع المنحنى).',
'• تقاطع دائرة وقطع مكافئ: أقصى عدد ممكن لنقاط التقاطع الحقيقية هو 4 نقاط.',
],
),
const SizedBox(height: 16),
// Section 3: Solved Examples Step-by-Step
LuxuryCard(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( // English TTS Audio Companion
mainAxisAlignment: MainAxisAlignment.spaceBetween, if (_isEnglish) ...[
children: [ EnglishTtsPlayerWidget(
const Text( textToRead: _documentContent,
'مثال نموذجي محلول خطوة بخطوة ✍️', title: 'الناطق الصوتي للدرس (English Audio Companion)',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 15), ),
), const SizedBox(height: 16),
Container( ],
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
// Direct Physics Virtual Lab Cross-Link
if (_isPhysics) ...[
GestureDetector(
onTap: () {
Navigator.of(context).push(
CupertinoPageRoute(
builder: (_) => Scaffold(
backgroundColor: AppColors.darkBackground,
appBar: AppBar(
backgroundColor: AppColors.darkSurface,
title: const Text('المختبر التفاعلي 🧪', style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.bold)),
centerTitle: true,
),
body: const PhysicsInteractiveLabView(),
),
),
);
},
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.emeraldGreen.withAlpha(30), gradient: const LinearGradient(
borderRadius: BorderRadius.circular(8), colors: [Color(0xFF0071E3), Color(0xFF00F5D4)],
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppColors.saqelCyan.withAlpha(40),
blurRadius: 15,
offset: const Offset(0, 4),
),
],
), ),
child: const Text( child: const Row(
'سؤال وزاري متوقع', children: [
style: TextStyle(color: AppColors.emeraldGreen, fontSize: 11, fontWeight: FontWeight.w700), Icon(CupertinoIcons.lab_flask_solid, color: Colors.white, size: 26),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'المختبر التفاعلي المباشر (Virtual Lab) 🧪',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 14),
),
SizedBox(height: 2),
Text(
'جرب جمع وتحليل المتجهات وتعديل الزوايا عملياً بالرسم البياني',
style: TextStyle(color: Colors.white70, fontSize: 11.5),
),
],
),
),
Icon(CupertinoIcons.chevron_left, color: Colors.white, size: 18),
],
), ),
), ),
],
),
const SizedBox(height: 12),
Text(
'المسألة: حُلّ نظام المعادلات التالي في مجموعة الأعداد الحقيقية:\n(1) y = x + 1\n(2) y = x² + 1',
style: TextStyle(
color: Colors.white,
fontSize: _fontSize,
fontWeight: FontWeight.w600,
height: 1.6,
), ),
), const SizedBox(height: 16),
const SizedBox(height: 14), ],
// Document Header Status Banner
Container( Container(
width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black26, gradient: LinearGradient(
borderRadius: BorderRadius.circular(12), colors: [
border: Border.all(color: AppColors.darkCardBorder), AppColors.saqelCyan.withAlpha(25),
AppColors.appleBlue.withAlpha(20),
],
),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.saqelCyan.withAlpha(40)),
), ),
child: Column( child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Text( const Icon(CupertinoIcons.doc_checkmark_fill, color: AppColors.saqelCyan, size: 22),
'خطوات الحل النموذجي:', const SizedBox(width: 10),
style: TextStyle(color: AppColors.saqelCyan, fontWeight: FontWeight.w700, fontSize: 13), Expanded(
), child: Column(
const SizedBox(height: 8), crossAxisAlignment: CrossAxisAlignment.start,
Text( children: [
'1. بمساواة المعادلتين بالتعويض عن قيمة y:\n x² + 1 = x + 1\n\n' Text(
'2. بطرح (x + 1) من الطرفين لجعل المعادلة صفرية:\n x² - x = 0\n\n' widget.documentType == 'textbook'
'3. بإخراج x كعامل مشترك أكبر:\n x(x - 1) = 0\n\n' ? 'الكتاب المدرسي المعتمد — وزارة التربية والتعليم'
'4. إما x = 0 أو x = 1.\n\n' : 'مذكرة دراسية وملخص معتمد',
'5. إيجاد قيمة y بالتعويض في المعادلة الخطية:\n' style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 13),
' - عندما x = 0 => y = 0 + 1 = 1 => نقطة التقاطع الأولى (0, 1)\n' ),
' - عندما x = 1 => y = 1 + 1 = 2 => نقطة التقاطع الثانية (1, 2)\n\n' const SizedBox(height: 2),
'مجموعة حل النظام هي: { (0, 1), (1, 2) }', Text(
style: TextStyle( 'محتوى حي ومحدث مطابق لنتاجات ${widget.subjectTitle}',
color: AppColors.textSecondaryDark, style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 11),
fontSize: _fontSize - 0.5, ),
height: 1.6, ],
fontFamily: 'SF Pro Text',
), ),
), ),
], ],
), ),
), ),
const SizedBox(height: 18),
// Render Dynamic Parsed Sections
..._sections.map((section) {
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: LuxuryCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
section.title,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 14.5),
),
const SizedBox(height: 10),
...section.lines.map((line) {
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Text(
line,
style: TextStyle(
color: line.startsWith('-') || line.startsWith('•')
? AppColors.textSecondaryDark
: Colors.white.withAlpha(230),
fontSize: _fontSize,
height: 1.55,
),
),
);
}),
],
),
),
);
}),
const SizedBox(height: 30),
], ],
), ),
), ),
const SizedBox(height: 16),
// Section 4: Self-Test Exercise with Reveal Answer
LuxuryCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(CupertinoIcons.pencil_ellipsis_rectangle, color: AppColors.guardianAmber, size: 20),
SizedBox(width: 8),
Text(
'تمرين تدريبي للتقييم الذاتي 📝',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 15),
),
],
),
const SizedBox(height: 10),
Text(
'حُلّ النظام التربيعي التالي بطريقة الحذف:\n(1) x² + y² = 25\n(2) x² - y² = 7',
style: TextStyle(color: Colors.white, fontSize: _fontSize, height: 1.6),
),
const SizedBox(height: 14),
Center(
child: TextButton.icon(
style: TextButton.styleFrom(
foregroundColor: AppColors.saqelCyan,
backgroundColor: AppColors.saqelCyan.withAlpha(20),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
onPressed: () {
setState(() => _showAnswer = !_showAnswer);
},
icon: Icon(_showAnswer ? CupertinoIcons.eye_slash : CupertinoIcons.eye, size: 18),
label: Text(_showAnswer ? 'إخفاء الحل النموذجي' : 'إظهار الحل النموذجي للتحقق 🔍'),
),
),
if (_showAnswer) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.emeraldGreen.withAlpha(15),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.emeraldGreen.withAlpha(50)),
),
child: Text(
'الحل:\nبجمع المعادلتين: 2x² = 32 => x² = 16 => x = ±4\n'
'بالتعويض في الأولى: 16 + y² = 25 => y² = 9 => y = ±3\n'
'حلول النظام الأربعة هي: (4, 3), (4, -3), (-4, 3), (-4, -3).',
style: TextStyle(color: Colors.white, fontSize: _fontSize - 1, height: 1.5),
),
),
],
],
),
),
const SizedBox(height: 30),
],
),
),
),
);
}
Widget _buildSectionCard({required String title, required List<String> content}) {
return LuxuryCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 15),
),
const SizedBox(height: 12),
...content.map((line) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
line,
style: TextStyle(
color: AppColors.textSecondaryDark,
fontSize: _fontSize,
height: 1.55,
),
),
);
}),
],
), ),
); );
} }
} }
class DocumentSection {
final String title;
final List<String> lines;
DocumentSection({required this.title, required this.lines});
}
@@ -0,0 +1,723 @@
import 'dart:math' as math;
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../widgets/luxury_widgets.dart';
/// ==============================================================================
/// SAQEL ENTERPRISE (EDTECH 2.0) - SMART PHYSICS & STEM VIRTUAL LAB (120 FPS)
/// ==============================================================================
///
/// ملف: physics_interactive_lab_view.dart
/// الهدف المعماري:
/// بيئة المختبر التفاعلي الذكي لمادة الفيزياء والعلوم:
/// 1. تجسيد المفاهيم الفيزيائية بمحاكيات Canvas ناعمة (120fps) تعمل محلياً على كافة المنصات.
/// 2. محاكي جمع وتحليل المتجهات وحساب المحصلة والضرب القياسي والمتجهي لحظياً.
/// 3. محاكي الحركة في بعد واحد والسرعة والتسارع مع متجهات الحركة الآنية.
/// 4. علامة مائية مضمنة غير قابلة للنسخ لحفظ حقوق منصة صَقِل.
/// 5. فحوصات وتحديات سقراطية مباشرة لتشجيع التفكير التحليلي.
class PhysicsInteractiveLabView extends StatefulWidget {
final String? initialSimulationSlug;
final VoidCallback? onOpenFullHtmlLab;
const PhysicsInteractiveLabView({
super.key,
this.initialSimulationSlug,
this.onOpenFullHtmlLab,
});
@override
State<PhysicsInteractiveLabView> createState() => _PhysicsInteractiveLabViewState();
}
class _PhysicsInteractiveLabViewState extends State<PhysicsInteractiveLabView> with SingleTickerProviderStateMixin {
int _selectedSimIndex = 0; // 0 = Vectors Lab, 1 = 1D Motion Lab
// Vector Lab Parameters
double _magA = 100.0;
double _angA = 30.0;
double _magB = 80.0;
double _angB = 120.0;
bool _showResultant = true;
// 1D Motion Lab Parameters
double _v0 = 10.0;
double _a = -2.0;
double _simTime = 0.0;
bool _isMoving = false;
late final AnimationController _motionController;
@override
void initState() {
super.initState();
if (widget.initialSimulationSlug == 'motion_1d_lab') {
_selectedSimIndex = 1;
}
_motionController = AnimationController(
vsync: this,
duration: const Duration(seconds: 10),
)..addListener(() {
if (_isMoving) {
setState(() {
_simTime += 0.016; // approx 60/120fps delta
final x = _v0 * _simTime + 0.5 * _a * _simTime * _simTime;
if (_simTime >= 10.0 || x.abs() > 140) {
_isMoving = false;
_motionController.stop();
}
});
}
});
}
@override
void dispose() {
_motionController.dispose();
super.dispose();
}
void _toggleMotion() {
setState(() {
_isMoving = !_isMoving;
if (_isMoving) {
_motionController.repeat();
} else {
_motionController.stop();
}
});
}
void _resetMotion() {
setState(() {
_isMoving = false;
_simTime = 0.0;
_motionController.reset();
});
}
@override
Widget build(BuildContext context) {
return Directionality(
textDirection: TextDirection.rtl,
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20),
children: [
// Header / Lab Switcher
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: AppColors.darkSurface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.darkCardBorder),
),
child: Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => setState(() => _selectedSimIndex = 0),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: _selectedSimIndex == 0 ? AppColors.appleBlue : Colors.transparent,
borderRadius: BorderRadius.circular(10),
),
alignment: Alignment.center,
child: Text(
'1. جمع وتحليل المتجهات 🧭',
style: TextStyle(
color: _selectedSimIndex == 0 ? Colors.white : AppColors.textSecondaryDark,
fontWeight: FontWeight.w700,
fontSize: 12.5,
),
),
),
),
),
Expanded(
child: GestureDetector(
onTap: () => setState(() => _selectedSimIndex = 1),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: _selectedSimIndex == 1 ? AppColors.appleBlue : Colors.transparent,
borderRadius: BorderRadius.circular(10),
),
alignment: Alignment.center,
child: Text(
'2. الحركة في بُعد واحد 🏎️',
style: TextStyle(
color: _selectedSimIndex == 1 ? Colors.white : AppColors.textSecondaryDark,
fontWeight: FontWeight.w700,
fontSize: 12.5,
),
),
),
),
),
],
),
),
const SizedBox(height: 16),
// Simulation Body
if (_selectedSimIndex == 0) _buildVectorsLab() else _buildMotionLab(),
],
),
);
}
// ==============================================================================
// LAB 1: VECTORS ADDITION & DECOMPOSITION SIMULATION
// ==============================================================================
Widget _buildVectorsLab() {
// Vector Math Calculations
final aRad = _angA * math.pi / 180.0;
final bRad = _angB * math.pi / 180.0;
final ax = _magA * math.cos(aRad);
final ay = _magA * math.sin(aRad);
final bx = _magB * math.cos(bRad);
final by = _magB * math.sin(bRad);
final rx = ax + bx;
final ry = ay + by;
final rMag = math.sqrt(rx * rx + ry * ry);
double rDeg = (math.atan2(ry, rx) * 180.0 / math.pi);
if (rDeg < 0) rDeg += 360.0;
final dotProduct = (_magA * _magB * math.cos((_angA - _angB).abs() * math.pi / 180.0)).toStringAsFixed(1);
final crossProduct = (_magA * _magB * math.sin((_angA - _angB).abs() * math.pi / 180.0)).toStringAsFixed(1);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Canvas Container
Container(
height: 320,
decoration: BoxDecoration(
color: const Color(0xFF070B12),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.saqelCyan.withAlpha(40), width: 1.5),
boxShadow: [
BoxShadow(
color: AppColors.saqelCyan.withAlpha(20),
blurRadius: 20,
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(19),
child: CustomPaint(
painter: _VectorCanvasPainter(
magA: _magA,
angA: _angA,
magB: _magB,
angB: _angB,
showResultant: _showResultant,
),
),
),
),
const SizedBox(height: 16),
// Vector A Sliders
LuxuryCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('المتجه A (السماوي 🟦)', style: TextStyle(color: AppColors.saqelCyan, fontWeight: FontWeight.w800, fontSize: 13.5)),
Text('|A| = ${_magA.toInt()} N • θ = ${_angA.toInt()}°', style: const TextStyle(color: AppColors.saqelCyan, fontWeight: FontWeight.bold, fontSize: 13)),
],
),
const SizedBox(height: 8),
Row(
children: [
const Text('المقدار:', style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 11.5)),
Expanded(
child: CupertinoSlider(
value: _magA,
min: 20.0,
max: 140.0,
activeColor: AppColors.saqelCyan,
onChanged: (v) => setState(() => _magA = v),
),
),
],
),
Row(
children: [
const Text('الزاوية:', style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 11.5)),
Expanded(
child: CupertinoSlider(
value: _angA,
min: 0.0,
max: 360.0,
activeColor: AppColors.saqelCyan,
onChanged: (v) => setState(() => _angA = v),
),
),
],
),
],
),
),
const SizedBox(height: 12),
// Vector B Sliders
LuxuryCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('المتجه B (الذهبي 🟨)', style: TextStyle(color: AppColors.guardianAmber, fontWeight: FontWeight.w800, fontSize: 13.5)),
Text('|B| = ${_magB.toInt()} N • θ = ${_angB.toInt()}°', style: const TextStyle(color: AppColors.guardianAmber, fontWeight: FontWeight.bold, fontSize: 13)),
],
),
const SizedBox(height: 8),
Row(
children: [
const Text('المقدار:', style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 11.5)),
Expanded(
child: CupertinoSlider(
value: _magB,
min: 20.0,
max: 140.0,
activeColor: AppColors.guardianAmber,
onChanged: (v) => setState(() => _magB = v),
),
),
],
),
Row(
children: [
const Text('الزاوية:', style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 11.5)),
Expanded(
child: CupertinoSlider(
value: _angB,
min: 0.0,
max: 360.0,
activeColor: AppColors.guardianAmber,
onChanged: (v) => setState(() => _angB = v),
),
),
],
),
],
),
),
const SizedBox(height: 12),
// Resultant Toggle & Mathematical Readout
LuxuryCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('إظهار محصلة المتجهين (R = A + B) 🎯', style: TextStyle(color: AppColors.emeraldGreen, fontWeight: FontWeight.w800, fontSize: 13.5)),
CupertinoSwitch(
value: _showResultant,
activeTrackColor: AppColors.emeraldGreen,
onChanged: (v) => setState(() => _showResultant = v),
),
],
),
const Divider(color: AppColors.darkCardBorder, height: 24),
Text('Ax = ${ax.toStringAsFixed(1)} N | Ay = ${ay.toStringAsFixed(1)} N', style: const TextStyle(color: Colors.white70, fontSize: 12)),
Text('Bx = ${bx.toStringAsFixed(1)} N | By = ${by.toStringAsFixed(1)} N', style: const TextStyle(color: Colors.white70, fontSize: 12)),
const SizedBox(height: 6),
Text(
'المحصلة |R| = √(${rx.toStringAsFixed(1)}² + ${ry.toStringAsFixed(1)}²) = ${rMag.toStringAsFixed(1)} N',
style: const TextStyle(color: AppColors.emeraldGreen, fontWeight: FontWeight.w800, fontSize: 14),
),
Text('اتجاه المحصلة θ_R = ${rDeg.toStringAsFixed(1)}°', style: const TextStyle(color: AppColors.emeraldGreen, fontSize: 13, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.white.withAlpha(8),
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('الضرب القياسي (A · B): $dotProduct J', style: const TextStyle(color: AppColors.saqelCyan, fontSize: 12, fontWeight: FontWeight.w700)),
Text('الضرب المتجهي |A × B|: $crossProduct N·m', style: const TextStyle(color: AppColors.guardianAmber, fontSize: 12, fontWeight: FontWeight.w700)),
],
),
),
],
),
),
const SizedBox(height: 12),
// Socratic Challenge Card
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.guardianAmber.withAlpha(20),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.guardianAmber.withAlpha(50)),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('💡 فحص سقراطي مباشر:', style: TextStyle(color: AppColors.guardianAmber, fontWeight: FontWeight.w800, fontSize: 13)),
SizedBox(height: 4),
Text(
'اجعل الزاوية بين المتجهين 180° تماماً ولاحظ قيمة المحصلة R! متى تنعدم المحصلة بالكامل؟',
style: TextStyle(color: Colors.white, fontSize: 12.5, height: 1.4),
),
],
),
),
],
);
}
// ==============================================================================
// LAB 2: 1D MOTION & ACCELERATION SIMULATION
// ==============================================================================
Widget _buildMotionLab() {
final x = _v0 * _simTime + 0.5 * _a * _simTime * _simTime;
final v = _v0 + _a * _simTime;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Track Canvas
Container(
height: 220,
decoration: BoxDecoration(
color: const Color(0xFF070B12),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.appleBlue.withAlpha(40), width: 1.5),
boxShadow: [
BoxShadow(
color: AppColors.appleBlue.withAlpha(20),
blurRadius: 20,
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(19),
child: CustomPaint(
painter: _MotionTrackPainter(
xPosition: x,
velocity: v,
acceleration: _a,
),
),
),
),
const SizedBox(height: 16),
// Motion Controls
Row(
children: [
Expanded(
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: _isMoving ? AppColors.crimsonRed : AppColors.emeraldGreen,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: _toggleMotion,
icon: Icon(_isMoving ? CupertinoIcons.pause_fill : CupertinoIcons.play_arrow_solid, size: 18),
label: Text(_isMoving ? 'إيقاف مؤقت ⏸' : 'تشغيل الحركة ▶', style: const TextStyle(fontWeight: FontWeight.w800)),
),
),
const SizedBox(width: 12),
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: AppColors.darkCardBorder),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: _resetMotion,
icon: const Icon(CupertinoIcons.arrow_counterclockwise, size: 16),
label: const Text('إعادة ضبط', style: TextStyle(fontWeight: FontWeight.w700)),
),
],
),
const SizedBox(height: 14),
// Sliders
LuxuryCard(
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('السرعة الابتدائية (v₀):', style: TextStyle(color: AppColors.saqelCyan, fontWeight: FontWeight.w700, fontSize: 13)),
Text('${_v0.toInt()} m/s', style: const TextStyle(color: AppColors.saqelCyan, fontWeight: FontWeight.bold)),
],
),
CupertinoSlider(
value: _v0,
min: -15.0,
max: 25.0,
activeColor: AppColors.saqelCyan,
onChanged: (v) => setState(() {
_v0 = v;
if (!_isMoving) _resetMotion();
}),
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('التسارع الثابت (a):', style: TextStyle(color: AppColors.guardianAmber, fontWeight: FontWeight.w700, fontSize: 13)),
Text('${_a.toStringAsFixed(1)} m/s²', style: const TextStyle(color: AppColors.guardianAmber, fontWeight: FontWeight.bold)),
],
),
CupertinoSlider(
value: _a,
min: -6.0,
max: 6.0,
activeColor: AppColors.guardianAmber,
onChanged: (v) => setState(() {
_a = v;
if (!_isMoving) _resetMotion();
}),
),
],
),
),
const SizedBox(height: 12),
// Real-time Readout Card
LuxuryCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('القراءات اللحظية للحركة (Kinematics Realtime):', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 13)),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('الزمن: ${_simTime.toStringAsFixed(2)} s', style: const TextStyle(color: AppColors.saqelCyan, fontWeight: FontWeight.w600)),
Text('الموقع x: ${x.toStringAsFixed(2)} m', style: const TextStyle(color: AppColors.emeraldGreen, fontWeight: FontWeight.bold)),
Text('السرعة v: ${v.toStringAsFixed(2)} m/s', style: const TextStyle(color: AppColors.guardianAmber, fontWeight: FontWeight.bold)),
],
),
const SizedBox(height: 6),
const Text('القانون المطبق: x(t) = v₀t + ½at² • v(t) = v₀ + at', style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 11.5)),
],
),
),
],
);
}
}
// ==============================================================================
// CUSTOM PAINTER: VECTORS ON 2D CARTESIAN GRID
// ==============================================================================
class _VectorCanvasPainter extends CustomPainter {
final double magA;
final double angA;
final double magB;
final double angB;
final bool showResultant;
_VectorCanvasPainter({
required this.magA,
required this.angA,
required this.magB,
required this.angB,
required this.showResultant,
});
@override
void paint(Canvas canvas, Size size) {
final cx = size.width / 2;
final cy = size.height / 2;
// 1. Grid Background
final gridPaint = Paint()
..color = Colors.white.withAlpha(12)
..strokeWidth = 1;
for (double x = 0; x < size.width; x += 25) {
canvas.drawLine(Offset(x, 0), Offset(x, size.height), gridPaint);
}
for (double y = 0; y < size.height; y += 25) {
canvas.drawLine(Offset(0, y), Offset(size.width, y), gridPaint);
}
// 2. Axes
final axisPaint = Paint()
..color = Colors.white.withAlpha(40)
..strokeWidth = 1.5;
canvas.drawLine(Offset(0, cy), Offset(size.width, cy), axisPaint);
canvas.drawLine(Offset(cx, 0), Offset(cx, size.height), axisPaint);
// 3. Permanent Watermark (Saqel Lab - Anti-Copy Protection)
const watermarkSpan = TextSpan(
text: 'منصة صَقِل التعليمية الذكية © Saqel Lab',
style: TextStyle(color: Color(0x3300F5D4), fontSize: 11, fontWeight: FontWeight.bold),
);
final watermarkPainter = TextPainter(text: watermarkSpan, textDirection: TextDirection.rtl)..layout();
watermarkPainter.paint(canvas, const Offset(12, 12));
// Scale factor: pixels per Newton
const scale = 1.0;
// Vector A
final aRad = angA * math.pi / 180.0;
final ax = magA * math.cos(aRad) * scale;
final ay = -magA * math.sin(aRad) * scale; // Screen Y inverted
_drawArrow(canvas, Offset(cx, cy), Offset(cx + ax, cy + ay), AppColors.saqelCyan, 3.5, label: 'A (${magA.toInt()} N)');
// Vector B
final bRad = angB * math.pi / 180.0;
final bx = magB * math.cos(bRad) * scale;
final by = -magB * math.sin(bRad) * scale;
_drawArrow(canvas, Offset(cx, cy), Offset(cx + bx, cy + by), AppColors.guardianAmber, 3.5, label: 'B (${magB.toInt()} N)');
// Resultant Vector R
if (showResultant) {
final rx = ax + bx;
final ry = ay + by;
final rMag = math.sqrt(rx * rx + ry * ry) / scale;
// Parallelogram guide lines
final guidePaint = Paint()
..color = Colors.white.withAlpha(30)
..strokeWidth = 1;
canvas.drawLine(Offset(cx + ax, cy + ay), Offset(cx + rx, cy + ry), guidePaint);
canvas.drawLine(Offset(cx + bx, cy + by), Offset(cx + rx, cy + ry), guidePaint);
_drawArrow(canvas, Offset(cx, cy), Offset(cx + rx, cy + ry), AppColors.emeraldGreen, 4.5, label: 'R (${rMag.toInt()} N)');
}
}
void _drawArrow(Canvas canvas, Offset from, Offset to, Color color, double width, {String? label}) {
final paint = Paint()
..color = color
..strokeWidth = width
..strokeCap = StrokeCap.round;
canvas.drawLine(from, to, paint);
final dx = to.dx - from.dx;
final dy = to.dy - from.dy;
final angle = math.atan2(dy, dx);
const headLen = 12.0;
final path = Path()
..moveTo(to.dx, to.dy)
..lineTo(to.dx - headLen * math.cos(angle - math.pi / 6), to.dy - headLen * math.sin(angle - math.pi / 6))
..lineTo(to.dx - headLen * math.cos(angle + math.pi / 6), to.dy - headLen * math.sin(angle + math.pi / 6))
..close();
final fillPaint = Paint()..color = color;
canvas.drawPath(path, fillPaint);
if (label != null) {
final textSpan = TextSpan(
text: label,
style: TextStyle(color: color, fontSize: 11.5, fontWeight: FontWeight.w800),
);
final textPainter = TextPainter(text: textSpan, textDirection: TextDirection.ltr)..layout();
textPainter.paint(canvas, Offset(to.dx + 6, to.dy - 12));
}
}
@override
bool shouldRepaint(covariant _VectorCanvasPainter oldDelegate) => true;
}
// ==============================================================================
// CUSTOM PAINTER: 1D MOTION TRACK & CAR
// ==============================================================================
class _MotionTrackPainter extends CustomPainter {
final double xPosition;
final double velocity;
final double acceleration;
_MotionTrackPainter({
required this.xPosition,
required this.velocity,
required this.acceleration,
});
@override
void paint(Canvas canvas, Size size) {
final w = size.width;
final trackY = size.height * 0.6;
final originX = w * 0.35;
const scale = 2.0; // pixels per meter
// 1. Watermark
const watermarkSpan = TextSpan(
text: 'منصة صَقِل التعليمية الذكية © Saqel Lab',
style: TextStyle(color: Color(0x3300F5D4), fontSize: 11, fontWeight: FontWeight.bold),
);
final watermarkPainter = TextPainter(text: watermarkSpan, textDirection: TextDirection.rtl)..layout();
watermarkPainter.paint(canvas, const Offset(12, 12));
// 2. Track Line
final trackPaint = Paint()
..color = Colors.white.withAlpha(35)
..strokeWidth = 2;
canvas.drawLine(Offset(20, trackY), Offset(w - 20, trackY), trackPaint);
// 3. Markings (-40m to +80m)
for (int m = -40; m <= 80; m += 20) {
final px = originX + m * scale;
if (px >= 20 && px <= w - 20) {
final isOrigin = (m == 0);
final markPaint = Paint()
..color = isOrigin ? AppColors.saqelCyan : Colors.white.withAlpha(50)
..strokeWidth = isOrigin ? 2 : 1;
canvas.drawLine(Offset(px, trackY - 8), Offset(px, trackY + 8), markPaint);
final textSpan = TextSpan(
text: '${m}m',
style: TextStyle(color: isOrigin ? AppColors.saqelCyan : AppColors.textSecondaryDark, fontSize: 10),
);
final textPainter = TextPainter(text: textSpan, textDirection: TextDirection.ltr)..layout();
textPainter.paint(canvas, Offset(px - textPainter.width / 2, trackY + 12));
}
}
// 4. Vehicle Body
final carX = (originX + xPosition * scale).clamp(30.0, w - 30.0);
final carRect = RRect.fromRectAndRadius(
Rect.fromCenter(center: Offset(carX, trackY - 14), width: 44, height: 22),
const Radius.circular(6),
);
final carPaint = Paint()..color = AppColors.appleBlue;
canvas.drawRRect(carRect, carPaint);
final borderPaint = Paint()
..color = AppColors.saqelCyan
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
canvas.drawRRect(carRect, borderPaint);
// Wheels
final wheelPaint = Paint()..color = const Color(0xFF1E293B);
canvas.drawCircle(Offset(carX - 12, trackY - 2), 5, wheelPaint);
canvas.drawCircle(Offset(carX + 12, trackY - 2), 5, wheelPaint);
// 5. Velocity Vector Arrow
if (velocity.abs() > 0.3) {
final vLen = (velocity * 2.5).clamp(-50.0, 50.0);
final vPaint = Paint()
..color = AppColors.saqelCyan
..strokeWidth = 2.5;
canvas.drawLine(Offset(carX, trackY - 32), Offset(carX + vLen, trackY - 32), vPaint);
}
}
@override
bool shouldRepaint(covariant _MotionTrackPainter oldDelegate) => true;
}
@@ -21,6 +21,7 @@ import '../../widgets/luxury_widgets.dart';
import '../player/socratic_video_player_screen.dart'; import '../player/socratic_video_player_screen.dart';
import '../exams/adaptive_exam_screen.dart'; import '../exams/adaptive_exam_screen.dart';
import 'curriculum_document_viewer_screen.dart'; import 'curriculum_document_viewer_screen.dart';
import 'physics_interactive_lab_view.dart';
import '../../../data/models/exam_model.dart'; import '../../../data/models/exam_model.dart';
import '../../../data/repositories/app_repositories.dart'; import '../../../data/repositories/app_repositories.dart';
@@ -38,10 +39,16 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
late TabController _tabController; late TabController _tabController;
late Future<List<ExamModel>> _examsFuture; late Future<List<ExamModel>> _examsFuture;
bool get _hasLab =>
widget.subject.id.contains('physic') ||
widget.subject.title.contains('فيزياء') ||
widget.subject.id.contains('chem') ||
widget.subject.id.contains('science');
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_tabController = TabController(length: 4, vsync: this); _tabController = TabController(length: _hasLab ? 5 : 4, vsync: this);
_examsFuture = ExamRepository().getExams(courseId: 1, scope: 'unit_exam'); _examsFuture = ExamRepository().getExams(courseId: 1, scope: 'unit_exam');
} }
@@ -72,12 +79,14 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
indicatorWeight: 3, indicatorWeight: 3,
labelColor: Colors.white, labelColor: Colors.white,
unselectedLabelColor: AppColors.textSecondaryDark, unselectedLabelColor: AppColors.textSecondaryDark,
isScrollable: _hasLab,
labelStyle: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13), labelStyle: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13),
tabs: const [ tabs: [
Tab(icon: Icon(CupertinoIcons.play_rectangle_fill, size: 18), text: 'الفيديوهات'), const Tab(icon: Icon(CupertinoIcons.play_rectangle_fill, size: 18), text: 'الفيديوهات'),
Tab(icon: Icon(CupertinoIcons.doc_text_fill, size: 18), text: 'أوراق العمل'), if (_hasLab) const Tab(icon: Icon(CupertinoIcons.lab_flask_solid, size: 18), text: 'المختبر التفاعلي 🧪'),
Tab(icon: Icon(CupertinoIcons.sparkles, size: 18), text: 'بنك الأسئلة'), const Tab(icon: Icon(CupertinoIcons.sparkles, size: 18), text: 'بنك الأسئلة'),
Tab(icon: Icon(CupertinoIcons.book_fill, size: 18), text: 'الكتب المقررة'), const Tab(icon: Icon(CupertinoIcons.doc_text_fill, size: 18), text: 'أوراق العمل'),
const Tab(icon: Icon(CupertinoIcons.book_fill, size: 18), text: 'الكتب المقررة'),
], ],
), ),
), ),
@@ -85,8 +94,9 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
controller: _tabController, controller: _tabController,
children: [ children: [
_buildLessonsTab(context), _buildLessonsTab(context),
_buildWorksheetsTab(context), if (_hasLab) const PhysicsInteractiveLabView(),
_buildExamsTab(context), _buildExamsTab(context),
_buildWorksheetsTab(context),
_buildTextbooksTab(context), _buildTextbooksTab(context),
], ],
), ),
@@ -263,7 +273,7 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
IconButton( IconButton(
icon: const Icon(CupertinoIcons.arrow_down_circle_fill, color: AppColors.saqelCyan, size: 28), icon: const Icon(CupertinoIcons.arrow_down_circle_fill, color: AppColors.saqelCyan, size: 28),
onPressed: () { onPressed: () {
_showResourceSheet(context, ws.title, 'ورقة عمل ومذكرة مراجعة', 'PDF جاهز للطباعة بدقة عالية'); _showResourceSheet(context, ws.title, 'ورقة عمل ومذكرة مراجعة', 'PDF جاهز للطباعة بدقة عالية', filePath: ws.filePath);
}, },
), ),
], ],
@@ -446,7 +456,7 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
IconButton( IconButton(
icon: const Icon(CupertinoIcons.eye_fill, color: AppColors.saqelCyan, size: 24), icon: const Icon(CupertinoIcons.eye_fill, color: AppColors.saqelCyan, size: 24),
onPressed: () { onPressed: () {
_showResourceSheet(context, tb.title, 'الكتاب المدرسي المعتمد', 'نسخة وزارة التربية والتعليم المنقحة والمحدثة'); _showResourceSheet(context, tb.title, 'الكتاب المدرسي المعتمد', 'نسخة وزارة التربية والتعليم المنقحة والمحدثة', filePath: tb.filePath);
}, },
), ),
], ],
@@ -457,7 +467,7 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
); );
} }
void _showResourceSheet(BuildContext context, String title, String subtitle, String description) { void _showResourceSheet(BuildContext context, String title, String subtitle, String description, {String? filePath}) {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
backgroundColor: AppColors.darkSurface, backgroundColor: AppColors.darkSurface,
@@ -541,6 +551,8 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
title: title, title: title,
documentType: title.contains('كتاب') ? 'textbook' : 'worksheet', documentType: title.contains('كتاب') ? 'textbook' : 'worksheet',
subjectTitle: widget.subject.title, subjectTitle: widget.subject.title,
subjectId: widget.subject.id,
filePath: filePath,
), ),
), ),
); );
@@ -558,8 +570,99 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
); );
} }
/// Empty state when no video exists yet for this lesson (Zero Mock Videos)
void _showNoVideoAvailableSheet(BuildContext context, CurriculumLessonItemModel lesson) {
showModalBottomSheet(
context: context,
backgroundColor: AppColors.darkSurface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.guardianAmber.withAlpha(25),
borderRadius: BorderRadius.circular(14),
),
child: const Icon(CupertinoIcons.videocam_fill, color: AppColors.guardianAmber, size: 26),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'لا يتوفر فيديو شرح لهذا الدرس حالياً 🎬',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 16),
),
const SizedBox(height: 3),
Text(
lesson.title,
style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 12),
),
],
),
),
],
),
const SizedBox(height: 16),
const Text(
'شروحات هذا الدرس قيد التصوير والمراجعة من قبل نخبة المعلمين المعتمدين وفريق منصة صَقِل.\nيمكنك حالياً دراسة نتاجات وملخص الدرس عبر تبويب الكتب والمذكرات، أو خوض الاختبار التكيفي.',
style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 13, height: 1.5),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.appleBlue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: () {
Navigator.of(ctx).pop();
Navigator.of(context).push(
CupertinoPageRoute(
builder: (_) => CurriculumDocumentViewerScreen(
title: lesson.title,
documentType: 'lesson',
subjectTitle: widget.subject.title,
subjectId: widget.subject.id,
filePath: lesson.markdownFilePath,
),
),
);
},
icon: const Icon(CupertinoIcons.book, size: 18),
label: const Text('قراءة محتوى وملخص الدرس 📖', style: TextStyle(fontWeight: FontWeight.w800)),
),
),
],
),
],
),
),
),
);
}
/// Multi-Teacher and AI Video Selector Bottom Sheet /// Multi-Teacher and AI Video Selector Bottom Sheet
void _showLessonVideoSelector(BuildContext context, CurriculumLessonItemModel lesson) { void _showLessonVideoSelector(BuildContext context, CurriculumLessonItemModel lesson) {
if (!lesson.hasVideo) {
_showNoVideoAvailableSheet(context, lesson);
return;
}
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
backgroundColor: AppColors.darkSurface, backgroundColor: AppColors.darkSurface,
@@ -164,6 +164,16 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
_isVideoInitialized = true; _isVideoInitialized = true;
}); });
_revealVideoControls(); _revealVideoControls();
if (state.currentPositionSeconds > 3) {
_videoController!.seekTo(Duration(seconds: state.currentPositionSeconds));
if (context.mounted) {
SaqelToast.showInfo(
context,
'تم استئناف المشاهدة من الدقيقة ${_formatTime(state.currentPositionSeconds)} ⏱️',
title: 'استئناف المشاهدة',
);
}
}
if (state.isPlaying && state.activeCheckpoint == null) { if (state.isPlaying && state.activeCheckpoint == null) {
_videoController!.play(); _videoController!.play();
} }
@@ -0,0 +1,223 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_tts/flutter_tts.dart';
import '../../core/theme/app_colors.dart';
import '../../core/utils/saqel_toast.dart';
/// ==============================================================================
/// SAQEL ENTERPRISE (EDTECH 2.0) - ENGLISH TEXT-TO-SPEECH (TTS) AUDIO COMPANION
/// ==============================================================================
///
/// ويدجت الناطق الصوتي الذكي لنصوص وقراءات ومفردات اللغة الإنجليزية:
/// 1. يعمل محلياً عبر محركات Apple AVFoundation و Google TTS فائقة الدقة.
/// 2. تحكم في سرعة النطق (0.5x للمبتدئين، 0.8x معيارية، 1.0x طبيعية).
/// 3. دعم التبديل بين اللهجة الأمريكية (en-US) والبريطانية (en-GB).
/// 4. شريط تحكم عائم فاخر بأسلوب كوبرتينو الداكن.
class EnglishTtsPlayerWidget extends StatefulWidget {
final String textToRead;
final String? title;
const EnglishTtsPlayerWidget({
super.key,
required this.textToRead,
this.title,
});
@override
State<EnglishTtsPlayerWidget> createState() => _EnglishTtsPlayerWidgetState();
}
class _EnglishTtsPlayerWidgetState extends State<EnglishTtsPlayerWidget> {
late FlutterTts _flutterTts;
bool _isPlaying = false;
double _speechRate = 0.45; // Moderate pace ideal for ESL learners
String _selectedLanguage = 'en-US';
@override
void initState() {
super.initState();
_initTts();
}
Future<void> _initTts() async {
_flutterTts = FlutterTts();
await _flutterTts.setLanguage(_selectedLanguage);
await _flutterTts.setSpeechRate(_speechRate);
await _flutterTts.setVolume(1.0);
await _flutterTts.setPitch(1.0);
_flutterTts.setStartHandler(() {
if (mounted) setState(() => _isPlaying = true);
});
_flutterTts.setCompletionHandler(() {
if (mounted) setState(() => _isPlaying = false);
});
_flutterTts.setCancelHandler(() {
if (mounted) setState(() => _isPlaying = false);
});
_flutterTts.setErrorHandler((msg) {
if (mounted) {
setState(() => _isPlaying = false);
SaqelToast.showError(context, 'تعذر تشغيل الصوت: $msg', title: 'الناطق الصوتي');
}
});
}
@override
void dispose() {
_flutterTts.stop();
super.dispose();
}
Future<void> _togglePlayPause() async {
if (_isPlaying) {
await _flutterTts.stop();
if (mounted) setState(() => _isPlaying = false);
} else {
if (widget.textToRead.trim().isEmpty) {
SaqelToast.showInfo(context, 'لا يوجد نص للقراءة حالياً', title: 'تنبيه');
return;
}
await _flutterTts.setLanguage(_selectedLanguage);
await _flutterTts.setSpeechRate(_speechRate);
await _flutterTts.speak(widget.textToRead);
}
}
Future<void> _cycleSpeed() async {
setState(() {
if (_speechRate <= 0.35) {
_speechRate = 0.48; // Normal
} else if (_speechRate <= 0.50) {
_speechRate = 0.65; // Fast
} else {
_speechRate = 0.30; // Slow
}
});
await _flutterTts.setSpeechRate(_speechRate);
final label = _speechRate < 0.4 ? 'بطيء (0.5x)' : (_speechRate < 0.6 ? 'معياري (0.8x)' : 'طبيعي (1.0x)');
if (mounted) SaqelToast.showInfo(context, 'سرعة القراءة: $label', title: 'سرعة النطق');
}
Future<void> _toggleAccent() async {
setState(() {
_selectedLanguage = _selectedLanguage == 'en-US' ? 'en-GB' : 'en-US';
});
await _flutterTts.setLanguage(_selectedLanguage);
final accentName = _selectedLanguage == 'en-US' ? 'أمريكية (US 🇺🇸)' : 'بريطانية (UK 🇬🇧)';
if (mounted) SaqelToast.showInfo(context, 'تم اختيار اللهجة: $accentName', title: 'لهجة القراءة');
}
@override
Widget build(BuildContext context) {
final speedLabel = _speechRate < 0.4 ? '0.5x' : (_speechRate < 0.6 ? '0.8x' : '1.0x');
final accentLabel = _selectedLanguage == 'en-US' ? 'US 🇺🇸' : 'UK 🇬🇧';
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xFF0F172A),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.saqelCyan.withAlpha(60)),
boxShadow: [
BoxShadow(
color: AppColors.saqelCyan.withAlpha(20),
blurRadius: 16,
spreadRadius: 1,
),
],
),
child: Row(
children: [
// Play / Stop Button
GestureDetector(
onTap: _togglePlayPause,
child: Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: _isPlaying ? AppColors.crimsonRed : AppColors.saqelCyan,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: (_isPlaying ? AppColors.crimsonRed : AppColors.saqelCyan).withAlpha(80),
blurRadius: 10,
),
],
),
child: Icon(
_isPlaying ? CupertinoIcons.stop_fill : CupertinoIcons.volume_up,
color: Colors.black,
size: 20,
),
),
),
const SizedBox(width: 12),
// Title & Status
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.title ?? 'الناطق الصوتي الذكي (English Audio Reader)',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 12.5),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
_isPlaying ? 'جاري الاستماع بنقاء صوتي عالي... 🎧' : 'اضغط للاستماع للنطق السليم للدرس',
style: TextStyle(
color: _isPlaying ? AppColors.saqelCyan : AppColors.textSecondaryDark,
fontSize: 11,
fontWeight: _isPlaying ? FontWeight.w600 : FontWeight.normal,
),
),
],
),
),
// Speed Control Chip
GestureDetector(
onTap: _cycleSpeed,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withAlpha(15),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.white24),
),
child: Text(
speedLabel,
style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w700),
),
),
),
const SizedBox(width: 6),
// Accent Control Chip
GestureDetector(
onTap: _toggleAccent,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColors.saqelCyan.withAlpha(20),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.saqelCyan.withAlpha(60)),
),
child: Text(
accentLabel,
style: const TextStyle(color: AppColors.saqelCyan, fontSize: 11, fontWeight: FontWeight.w700),
),
),
),
],
),
);
}
}
@@ -7,12 +7,14 @@ import Foundation
import device_info_plus import device_info_plus
import flutter_secure_storage_macos import flutter_secure_storage_macos
import flutter_tts
import shared_preferences_foundation import shared_preferences_foundation
import video_player_avfoundation import video_player_avfoundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin")) VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin"))
} }
+14 -6
View File
@@ -203,6 +203,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_tts:
dependency: "direct main"
description:
name: flutter_tts
sha256: ce5eb209b40e95f2f4a1397116c87ab2fcdff32257d04ed7a764e75894c03775
url: "https://pub.dev"
source: hosted
version: "4.2.5"
flutter_web_plugins: flutter_web_plugins:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -332,10 +340,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.19" version: "0.12.18"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
@@ -348,10 +356,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.17.0"
nested: nested:
dependency: transitive dependency: transitive
description: description:
@@ -577,10 +585,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.11" version: "0.7.9"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
+1
View File
@@ -35,6 +35,7 @@ dependencies:
google_fonts: ^6.2.1 google_fonts: ^6.2.1
intl: ^0.19.0 intl: ^0.19.0
video_player: ^2.11.1 video_player: ^2.11.1
flutter_tts: ^4.2.5
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -7,8 +7,11 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h> #include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <flutter_tts/flutter_tts_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
FlutterSecureStorageWindowsPluginRegisterWithRegistrar( FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
FlutterTtsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterTtsPlugin"));
} }
@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
flutter_secure_storage_windows flutter_secure_storage_windows
flutter_tts
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
@@ -347,4 +347,137 @@ class CurriculumController
]); ]);
} }
} }
/**
* List all available simulations for a subject or overall
* GET /api/curriculum/simulations
*/
public function listSimulations(Request $request, Response $response): void
{
$subject = $request->getQueryParams()['subject'] ?? 'physics_10';
$simDir = realpath(__DIR__ . '/../../storage/curriculum/simulations') . "/{$subject}";
$simulations = [];
if (is_dir($simDir)) {
$files = glob("{$simDir}/*.html");
foreach ($files as $f) {
$slug = basename($f, '.html');
$title = ($slug === 'vectors_lab') ? 'مختبر جمع وتحليل المتجهات التفاعلي' :
(($slug === 'motion_1d_lab') ? 'مختبر الحركة في بُعد واحد والتسارع' : $slug);
$unit = ($slug === 'vectors_lab') ? 'الوحدة 1: المتجهات والكميات' :
(($slug === 'motion_1d_lab') ? 'الوحدة 2: الحركة والقوى' : 'تجارب تفاعلية');
$simulations[] = [
'slug' => $slug,
'title' => $title,
'unit_title' => $unit,
'url' => "/api/curriculum/simulations/{$subject}/{$slug}",
'has_canvas' => true,
'watermark' => 'منصة صَقِل التعليمية الذكية © Saqel Lab',
];
}
}
$response->json([
'status' => 'success',
'data' => $simulations
]);
}
/**
* Get / Serve Interactive HTML5 Canvas Simulation
* GET /api/curriculum/simulations/{subject}/{simName}
*/
public function getSimulation(Request $request, Response $response): void
{
$subject = preg_replace('/[^a-zA-Z0-9_-]/', '', $request->getParam('subject') ?? '');
$simName = preg_replace('/[^a-zA-Z0-9_-]/', '', $request->getParam('simName') ?? '');
$simPath = realpath(__DIR__ . '/../../storage/curriculum/simulations') . "/{$subject}/{$simName}.html";
if (!file_exists($simPath)) {
// Fallback check in root simulations
$simPath = realpath(__DIR__ . '/../../storage/curriculum/simulations') . "/{$simName}.html";
}
if (!file_exists($simPath)) {
$response->status(404)->json(['status' => 'error', 'message' => 'المحاكي التفاعلي غير موجود']);
return;
}
header('Content-Type: text/html; charset=UTF-8');
header('X-Frame-Options: SAMEORIGIN');
readfile($simPath);
exit;
}
/**
* Get real document or textbook markdown content dynamically per subject
* GET /api/curriculum/document
*/
public function getDocumentContent(Request $request, Response $response): void
{
$params = $request->getQueryParams();
$file = $params['file'] ?? '';
$subject = strtolower($params['subject'] ?? '');
$type = $params['type'] ?? 'textbook';
$storage = realpath(__DIR__ . '/../../storage/curriculum');
$resolvedFile = null;
if (!empty($file) && !str_ends_with($file, '.pdf')) {
$cleanFile = ltrim($file, '/');
if (file_exists("{$storage}/{$cleanFile}")) {
$resolvedFile = "{$storage}/{$cleanFile}";
}
}
// If not resolved by exact path, resolve by subject
if (!$resolvedFile && !empty($subject)) {
if (str_contains($subject, 'english') || str_contains($subject, 'إنجليز')) {
$mds = glob("{$storage}/grade_10/english_10/semester_1/*/*.md");
if (!empty($mds)) $resolvedFile = $mds[0];
} elseif (str_contains($subject, 'physic') || str_contains($subject, 'فيزياء')) {
$mds = glob("{$storage}/grade_10/physics_10/semester_1/*/*.md");
if (!empty($mds)) $resolvedFile = $mds[1] ?? $mds[0];
} elseif (str_contains($subject, 'math') || str_contains($subject, 'رياضيات')) {
$mds = glob("{$storage}/grade_10/math_10/semester_1/*/*.md");
if (!empty($mds)) $resolvedFile = $mds[1] ?? $mds[0];
}
}
if ($resolvedFile && file_exists($resolvedFile)) {
$content = file_get_contents($resolvedFile);
$response->json([
'status' => 'success',
'file' => str_replace("{$storage}/", '', $resolvedFile),
'subject' => $subject,
'type' => $type,
'content' => $content
]);
return;
}
// Tailored subject fallback
$content = self::getDefaultSubjectDocument($subject, $type);
$response->json([
'status' => 'success',
'subject' => $subject,
'type' => $type,
'content' => $content
]);
}
private static function getDefaultSubjectDocument(string $subject, string $type): string
{
$s = strtolower($subject);
if (str_contains($s, 'english') || str_contains($s, 'إنجليز')) {
return "## Unit 01: Looking Good — Vocabulary & Reading\n\n### 1. Key Vocabulary & Word Formation\n- **Casual clothing:** Everyday informal garments (jeans, sneakers, hoodie).\n- **Traditional attire:** Cultural heritage wear (Jordanian Thobe and Keffiyeh).\n- **Subconscious influence:** The way external appearance affects cognitive confidence.\n\n### 2. Reading Text: The Power of First Impressions\nResearch shows that within the first seven seconds of meeting someone, people make subconscious judgments about character, competence, and reliability. In a famous experiment, doctors wearing clean white coats demonstrated higher diagnostic focus.\n\n### 3. Grammar Workshop: Articles (a, an, the, zero article)\n- Use **a / an** for non-specific singular countable nouns.\n- Use **the** when the listener knows which specific thing is meant.\n- Use **zero article** with plural or uncountable nouns spoken in general terms.";
}
if (str_contains($s, 'physic') || str_contains($s, 'فيزياء')) {
return "## الوحدة الأولى: المتجهات والكميات الفيزيائية\n\n### 1. التمييز بين الكميات القياسية والمتجهة\n- **الكميات القياسية:** تُحدد بالمقدار ووحدة القياس فقط (الكتلة، الزمن، الطاقة، درجة الحرارة).\n- **الكميات المتجهة:** تُحدد بالمقدار والاتجاه ونقطة التأثير (القوة، السرعة المتجهة، التسارع، الإزاحة).\n\n### 2. جمع وتحليل المتجهات بيانياً وتحليلياً\n- **المركبة الأفقية:** A_x = A cos θ\n- **المركبة الرأسية:** A_y = A sin θ\n- **المحصلة:** R = √(R_x² + R_y²)\n- **زاوية الاتجاه:** tan θ = |R_y / R_x|";
}
return "## ملخص الوحدة والنتاجات التعليمية المقررة\n\n### 1. الأهداف العامة للمادة\n- استيعاب المفاهيم والمصطلحات الأساسية وفق المنهاج الوزاري المعتمد.\n- التدرب على حل النماذج والتطبيقات العملية والأسئلة الوزارية.\n- التحقق من اكتساب المهارات من خلال بنك الأسئلة التكيفي والمختبر.";
}
} }
@@ -0,0 +1,222 @@
<?php
/**
* ==============================================================================
* SAQEL ENTERPRISE (EDTECH 2.0) - AI INTERACTIVE LAB GENERATOR SERVICE
* ==============================================================================
*
* ملف: AiInteractiveLabGeneratorService.php
* الهدف المعماري:
* محرك الذكاء الاصطناعي لتوليد مختبرات ومحاكيات العلوم والرياضيات التفاعلية (HTML5 Canvas)
* 1. قراءة وتحليل ملف الماركداون للدرس أو الوحدة واستخلاص المفاهيم الفيزيائية/الكيميائية/الرياضية.
* 2. استخدام Google Gemini لبرمجة محاكي Canvas أحادي الملف (Standalone Single-File HTML5).
* 3. تضمين العلامة المائية الدائمة لحفظ حقوق المنصة (Watermark) وحماية الكود من النسخ.
* 4. حفظ المحاكي في مجلد simulations وإتاحته عبر الـ API للتطبيق والمتصفح.
*/
namespace App\Services;
use App\Core\Database;
class AiInteractiveLabGeneratorService
{
private static string $storagePath = __DIR__ . '/../../storage/curriculum';
/**
* Generate an interactive HTML5 Canvas lab from a lesson markdown file.
*
* @param string $relativeLessonPath e.g. "grade_10/physics_10/semester_1/unit_01/lesson_01.md"
* @param string $subjectSlug e.g. "physics_10"
* @param string|null $simSlug e.g. "vectors_lab" (auto-inferred if null)
* @return array
*/
public static function generateLabFromLesson(string $relativeLessonPath, string $subjectSlug = 'physics_10', ?string $simSlug = null): array
{
$fullPath = realpath(self::$storagePath) . '/' . ltrim($relativeLessonPath, '/');
if (!file_exists($fullPath)) {
throw new \InvalidArgumentException("ملف الدرس غير موجود: {$relativeLessonPath}");
}
$lessonContent = file_get_contents($fullPath);
if (empty($lessonContent)) {
throw new \RuntimeException("ملف الدرس فارغ: {$relativeLessonPath}");
}
if (!$simSlug) {
$baseName = basename($relativeLessonPath, '.md');
$simSlug = strtolower(preg_replace('/[^a-zA-Z0-9_]/', '_', $baseName)) . '_lab';
}
$apiKey = self::resolveGeminiKey();
if (!$apiKey) {
throw new \RuntimeException("مفتاح Gemini API غير مهيأ (GEMINI_API_KEY).");
}
$prompt = self::buildSimulationPrompt($lessonContent, $subjectSlug, $simSlug);
$htmlCode = self::callGemini($apiKey, $prompt);
// Sanitize code: remove markdown code fences if model returned them
$htmlCode = preg_replace('/^```(?:html)?\s*/i', '', trim($htmlCode));
$htmlCode = preg_replace('/\s*```$/', '', $htmlCode);
// Ensure target simulation directory exists
$simDir = realpath(self::$storagePath) . "/simulations/{$subjectSlug}";
if (!is_dir($simDir)) {
mkdir($simDir, 0777, true);
}
$targetFile = "{$simDir}/{$simSlug}.html";
file_put_contents($targetFile, $htmlCode);
$relativeUrl = "/api/curriculum/simulations/{$subjectSlug}/{$simSlug}";
return [
'status' => 'success',
'sim_slug' => $simSlug,
'subject' => $subjectSlug,
'file_path' => $targetFile,
'url' => $relativeUrl,
'byte_size' => strlen($htmlCode)
];
}
/**
* Build the prompt for Gemini.
*/
private static function buildSimulationPrompt(string $lessonMarkdown, string $subjectSlug, string $simSlug): string
{
return <<<PROMPT
أنت كبير مهندسي المحاكاة البصرية والتطوير التعليمي التفاعلي لمنصة "صَقِل" (Saqel EdTech 2.0).
المطلوب منك برمجة صفحة ويب تفاعلية فائقة الجودة والفخامة (Standalone Single-File HTML5 Canvas Simulation) تُجسد المفاهيم العلمية والتطبيقية الواردة في نص الدرس المرفق.
المتطلبات التصميمية والتقنية الصارمة:
1. التصميم بأسلوب كوبرتينو الفاخر الداكن (Apple Cupertino Dark Luxury):
- خلفية أنيقة جداً #06090f مع حاويات #0d111a وحدود ناعمة شبه شفافة rgba(255,255,255,0.12).
- ألوان مميزة للمتغيرات: السماوي #00f5d4، الذهبي #ffd166، الزمردي #10b981، الأزرق الأبلي #0071e3.
- خطوط واضحة: SF Pro Arabic أو النظام الافتراضي، دعم كامل للغة العربية واتجاه RTL.
2. تفاعل الـ HTML5 Canvas:
- رسم العناصر بدقة ونعومة عالية، مع أسهم موجهة، وأطوال بمقياس رسم مدروس، وشبكة بيانية إرشادية.
- تفاعل سلس عبر سلايدرات تحكم (Sliders) مع أزرار تشغيل/إيقاف/إعادة ضبط إن كانت تجربة حركية.
- قراءات وحسابات فيزيائية/رياضية لحظية تظهر أسفل الـ Canvas توضح القوانين والنتاجات.
- ويدجت "💡 فحص سقراطي مباشر" يطرح تحدياً تفكيرياً على الطالب لتجربته بالسلايدرات.
3. العلامة المائية والحماية من النسخ (Anti-Copy Protection):
- رسم علامة مائية مضمنة غير قابلة للإزالة داخل الـ Canvas نفسه:
ctx.fillText('منصة صَقِل التعليمية الذكية © Saqel Lab - محمي', 14, 24);
- منع النقر بالزر الأيمن وحظر اختصارات النسخ لمنع استخراج الكود.
4. الاستقلالية الكاملة (Self-contained):
- ملف HTML واحد متكامل يحتوي على كل CSS و JS دون أي اعتمادات خارجية غير مدعومة.
- لا تضع أي شروحات أو مقدمات خارج كود الـ HTML، أرجع كود HTML فقط.
محتوى الدرس:
```markdown
{$lessonMarkdown}
```
PROMPT;
}
/**
* Execute Gemini API Call.
*/
private static function callGemini(string $apiKey, string $prompt): string
{
$model = 'gemini-2.0-flash';
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}";
$payload = [
'contents' => [
[
'parts' => [
['text' => $prompt]
]
]
],
'generationConfig' => [
'temperature' => 0.2,
'maxOutputTokens' => 8192
]
];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_TIMEOUT => 90
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200 || !$response) {
// Fallback to gemini-1.5-flash
return self::callGeminiFallback($apiKey, $prompt);
}
$resData = json_decode($response, true);
$text = $resData['candidates'][0]['content']['parts'][0]['text'] ?? '';
if (empty($text)) {
throw new \RuntimeException("استجابة غير صالحة من Gemini.");
}
return $text;
}
private static function callGeminiFallback(string $apiKey, string $prompt): string
{
$model = 'gemini-1.5-flash';
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}";
$payload = [
'contents' => [
[
'parts' => [
['text' => $prompt]
]
]
],
'generationConfig' => [
'temperature' => 0.2,
'maxOutputTokens' => 8192
]
];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_TIMEOUT => 90
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200 || !$response) {
throw new \RuntimeException("فشل استدعاء Gemini API (HTTP {$httpCode})");
}
$resData = json_decode($response, true);
return $resData['candidates'][0]['content']['parts'][0]['text'] ?? '';
}
private static function resolveGeminiKey(): ?string
{
if (!empty($_ENV['GEMINI_API_KEY'])) return $_ENV['GEMINI_API_KEY'];
if (!empty(getenv('GEMINI_API_KEY'))) return getenv('GEMINI_API_KEY');
// Check backend/.env
$envPath = __DIR__ . '/../../.env';
if (file_exists($envPath)) {
$lines = file($envPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (str_starts_with(trim($line), 'GEMINI_API_KEY=')) {
return trim(substr(trim($line), 15), '"\' ');
}
}
}
return null;
}
}
+3
View File
@@ -72,6 +72,9 @@ $router->get('/api/curriculum/search', function ($request, $response) {
'data' => \App\Services\CurriculumService::searchCurriculum($q) 'data' => \App\Services\CurriculumService::searchCurriculum($q)
]); ]);
}); });
$router->get('/api/curriculum/simulations', [\App\Controllers\CurriculumController::class, 'listSimulations']);
$router->get('/api/curriculum/simulations/{subject}/{simName}', [\App\Controllers\CurriculumController::class, 'getSimulation']);
$router->get('/api/curriculum/document', [\App\Controllers\CurriculumController::class, 'getDocumentContent']);
// Health & Diagnostic Routes // Health & Diagnostic Routes
$router->get('/api/health', function ($request, $response) { $router->get('/api/health', function ($request, $response) {
@@ -0,0 +1,35 @@
<?php
/**
* CLI Tool: Generate Interactive HTML5 Canvas Lab Simulation
* Usage:
* php backend/scripts/generate_interactive_lab.php --lesson=grade_10/physics_10/semester_1/unit_01/lesson_01.md --subject=physics_10 --slug=vectors_lab
*/
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../app/Core/Database.php';
require_once __DIR__ . '/../app/Services/AiInteractiveLabGeneratorService.php';
$options = getopt('', ['lesson:', 'subject:', 'slug:']);
$lesson = $options['lesson'] ?? '';
$subject = $options['subject'] ?? 'physics_10';
$slug = $options['slug'] ?? null;
if (empty($lesson)) {
echo "❌ Error: --lesson parameter is required.\n";
echo "Example: php generate_interactive_lab.php --lesson=grade_10/physics_10/semester_1/unit_01/lesson_01.md --subject=physics_10\n";
exit(1);
}
echo "🚀 Starting AI Interactive Lab Generation for: {$lesson}...\n";
try {
$res = \App\Services\AiInteractiveLabGeneratorService::generateLabFromLesson($lesson, $subject, $slug);
echo "✅ Success! Simulation generated successfully:\n";
echo " - Simulation Slug: {$res['sim_slug']}\n";
echo " - File Location: {$res['file_path']}\n";
echo " - API Endpoint: {$res['url']}\n";
echo " - Size: {$res['byte_size']} bytes\n";
} catch (\Throwable $e) {
echo "❌ Generation failed: " . $e->getMessage() . "\n";
exit(1);
}
@@ -0,0 +1,362 @@
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>مختبر صَقِل التفاعلي: الحركة في بُعد واحد والتسارع</title>
<style>
:root {
--bg: #06090f;
--surface: #0d111a;
--card: #131a26;
--border: rgba(255, 255, 255, 0.12);
--cyan: #00f5d4;
--amber: #ffd166;
--emerald: #10b981;
--crimson: #ff4d6d;
--blue: #0071e3;
--text: #f8fafc;
--text-sec: #94a3b8;
}
* { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "SF Pro Arabic", sans-serif; }
body { background: var(--bg); color: var(--text); padding: 16px; display: flex; flex-direction: column; align-items: center; min-height: 100vh; user-select: none; -webkit-user-select: none; }
.header { width: 100%; max-width: 840px; margin-bottom: 16px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border); padding-bottom: 12px; }
.header h1 { font-size: 18px; font-weight: 800; color: #fff; display: flex; align-items: center; gap: 8px; }
.badge { background: rgba(0, 245, 212, 0.15); color: var(--cyan); padding: 4px 10px; border-radius: 8px; font-size: 11px; font-weight: 700; border: 1px solid rgba(0, 245, 212, 0.3); }
.container { width: 100%; max-width: 840px; display: grid; grid-template-columns: 1fr; gap: 16px; }
@media (min-width: 768px) { .container { grid-template-columns: 1.25fr 1fr; } }
.canvas-card { background: var(--surface); border: 1px solid var(--border); border-radius: 16px; padding: 12px; display: flex; flex-direction: column; align-items: center; position: relative; gap: 12px; }
canvas { background: #080c14; border-radius: 12px; border: 1px solid rgba(255,255,255,0.06); width: 100%; height: auto; touch-action: none; }
.controls-card { background: var(--surface); border: 1px solid var(--border); border-radius: 16px; padding: 18px; display: flex; flex-direction: column; gap: 14px; }
.slider-group { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 12px; }
.slider-header { display: flex; justify-content: space-between; font-size: 13px; font-weight: 700; margin-bottom: 8px; }
input[type=range] { width: 100%; height: 6px; border-radius: 3px; background: rgba(255,255,255,0.1); outline: none; -webkit-appearance: none; margin-bottom: 8px; }
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; background: #fff; cursor: pointer; box-shadow: 0 0 8px rgba(0,0,0,0.5); }
.btn-row { display: flex; gap: 10px; }
.btn { flex: 1; padding: 10px; border-radius: 10px; border: none; font-weight: 700; font-size: 13px; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 6px; transition: 0.2s; }
.btn-play { background: var(--emerald); color: #000; }
.btn-pause { background: var(--amber); color: #000; }
.btn-reset { background: rgba(255,255,255,0.1); color: #fff; border: 1px solid var(--border); }
.math-readout { background: #04070c; border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; padding: 12px; font-family: monospace; font-size: 12px; line-height: 1.7; color: #cbd5e1; }
.math-val { color: #fff; font-weight: bold; }
.socratic-challenge { background: rgba(255, 209, 102, 0.1); border: 1px solid rgba(255, 209, 102, 0.3); border-radius: 12px; padding: 12px; font-size: 12px; line-height: 1.5; color: #fde68a; }
.socratic-challenge strong { color: var(--amber); display: block; margin-bottom: 4px; }
</style>
</head>
<body oncontextmenu="return false;">
<div class="header">
<h1><span>🏎️</span> مختبر الحركة في بُعد واحد والتسارع</h1>
<span class="badge">فيزياء 10 • الوحدة 2</span>
</div>
<div class="container">
<div class="canvas-card">
<!-- Motion Track Canvas -->
<canvas id="motionCanvas" width="450" height="200"></canvas>
<!-- Real-time v-t & x-t Graphs Canvas -->
<canvas id="graphCanvas" width="450" height="180"></canvas>
</div>
<div class="controls-card">
<div class="btn-row">
<button id="btnPlay" class="btn btn-play">تشغيل الحركة ▶</button>
<button id="btnReset" class="btn btn-reset">إعادة ضبط ↺</button>
</div>
<!-- Initial Velocity Control -->
<div class="slider-group">
<div class="slider-header" style="color:var(--cyan);">
<span>السرعة الابتدائية (v₀)</span>
<span id="labelV0">v₀ = 10 m/s</span>
</div>
<input type="range" id="v0Slider" min="-20" max="30" value="10">
</div>
<!-- Acceleration Control -->
<div class="slider-group">
<div class="slider-header" style="color:var(--amber);">
<span>التسارع الثابت (a)</span>
<span id="labelA">a = -2.0 m/s²</span>
</div>
<input type="range" id="aSlider" min="-8" max="8" step="0.5" value="-2">
</div>
<!-- Real-time Kinematic Readout -->
<div class="math-readout" id="readout">
الزمن t = 0.0 s<br>
الموقع x(t) = v₀t + ½at² = 0.0 m<br>
السرعة اللحظية v(t) = v₀ + at = 10.0 m/s<br>
التسارع a = -2.0 m/s²
</div>
<!-- Socratic Challenge -->
<div class="socratic-challenge">
<strong>💡 فحص سقراطي تطبيقي:</strong>
لاحظ نقطة السكون اللحظي (v = 0) عندما تكون السرعة الابتدائية موجبة والتسارع سالباً! متى تعكس السيارة اتجاه حركتها؟
</div>
</div>
</div>
<script>
const mCanvas = document.getElementById('motionCanvas');
const mCtx = mCanvas.getContext('2d');
const gCanvas = document.getElementById('graphCanvas');
const gCtx = gCanvas.getContext('2d');
const v0Slider = document.getElementById('v0Slider');
const aSlider = document.getElementById('aSlider');
const labelV0 = document.getElementById('labelV0');
const labelA = document.getElementById('labelA');
const readout = document.getElementById('readout');
const btnPlay = document.getElementById('btnPlay');
const btnReset = document.getElementById('btnReset');
let isRunning = false;
let t = 0;
let animId = null;
let lastTime = null;
let history = [];
function getV0() { return parseFloat(v0Slider.value); }
function getA() { return parseFloat(aSlider.value); }
function resetSim() {
isRunning = false;
t = 0;
history = [];
btnPlay.textContent = 'تشغيل الحركة ▶';
btnPlay.className = 'btn btn-play';
if (animId) cancelAnimationFrame(animId);
drawMotion();
drawGraph();
updateReadout(0, 0, getV0());
}
function updateReadout(time, x, v) {
readout.innerHTML = `
الزمن t = <span class="math-val" style="color:#00f5d4;">${time.toFixed(2)} s</span><br>
الموقع x = <span class="math-val" style="color:#10b981;">${x.toFixed(2)} m</span> (الإزاحة)<br>
السرعة اللحظية v = <span class="math-val" style="color:#00f5d4;">${v.toFixed(2)} m/s</span><br>
التسارع الثابت a = <span class="math-val" style="color:#ffd166;">${getA().toFixed(2)} m/s²</span>
`;
}
function drawMotion() {
const w = mCanvas.width;
const h = mCanvas.height;
mCtx.clearRect(0, 0, w, h);
// Draw Track Grid & Axis
mCtx.strokeStyle = 'rgba(255,255,255,0.1)';
mCtx.lineWidth = 1;
const trackY = 120;
mCtx.beginPath();
mCtx.moveTo(20, trackY);
mCtx.lineTo(w - 20, trackY);
mCtx.stroke();
// Draw Track Marks (-50m to +100m)
const originX = 140;
const scale = 2.4; // pixels per meter
for (let m = -40; m <= 120; m += 20) {
const px = originX + m * scale;
if (px >= 20 && px <= w - 20) {
mCtx.strokeStyle = (m === 0) ? '#00f5d4' : 'rgba(255,255,255,0.2)';
mCtx.lineWidth = (m === 0) ? 2 : 1;
mCtx.beginPath();
mCtx.moveTo(px, trackY - 8);
mCtx.lineTo(px, trackY + 8);
mCtx.stroke();
mCtx.fillStyle = (m === 0) ? '#00f5d4' : '#94a3b8';
mCtx.font = '10px sans-serif';
mCtx.textAlign = 'center';
mCtx.fillText(m + 'm', px, trackY + 22);
}
}
// Calculate state
const v0 = getV0();
const a = getA();
const x = v0 * t + 0.5 * a * t * t;
const v = v0 + a * t;
const carX = Math.min(Math.max(originX + x * scale, 30), w - 30);
// Draw Car (Apple-grade rounded tech pod)
mCtx.fillStyle = '#0071e3';
mCtx.beginPath();
mCtx.roundRect(carX - 22, trackY - 26, 44, 22, 6);
mCtx.fill();
mCtx.strokeStyle = '#00f5d4';
mCtx.lineWidth = 1.5;
mCtx.stroke();
// Wheels
mCtx.fillStyle = '#1e293b';
mCtx.beginPath(); mCtx.arc(carX - 12, trackY - 2, 5, 0, Math.PI * 2); mCtx.fill();
mCtx.beginPath(); mCtx.arc(carX + 12, trackY - 2, 5, 0, Math.PI * 2); mCtx.fill();
// Velocity Vector (Cyan Arrow)
if (Math.abs(v) > 0.2) {
const vLen = Math.min(Math.max(v * 2, -60), 60);
drawArrow(mCtx, carX, trackY - 35, carX + vLen, trackY - 35, '#00f5d4', 2.5);
mCtx.fillStyle = '#00f5d4';
mCtx.font = 'bold 11px sans-serif';
mCtx.textAlign = 'center';
mCtx.fillText(`v = ${v.toFixed(1)} m/s`, carX + vLen / 2, trackY - 42);
}
// Acceleration Vector (Amber Arrow)
if (Math.abs(a) > 0.1) {
const aLen = Math.min(Math.max(a * 5, -50), 50);
drawArrow(mCtx, carX, trackY + 36, carX + aLen, trackY + 36, '#ffd166', 2.5);
mCtx.fillStyle = '#ffd166';
mCtx.font = 'bold 10px sans-serif';
mCtx.textAlign = 'center';
mCtx.fillText(`a = ${a.toFixed(1)}`, carX + aLen / 2, trackY + 50);
}
// Permanent Watermark
mCtx.save();
mCtx.fillStyle = 'rgba(0, 245, 212, 0.18)';
mCtx.font = 'bold 11px sans-serif';
mCtx.textAlign = 'left';
mCtx.fillText('منصة صَقِل التعليمية الذكية © Saqel Lab - محمي', 14, 22);
mCtx.restore();
updateReadout(t, x, v);
}
function drawGraph() {
const w = gCanvas.width;
const h = gCanvas.height;
gCtx.clearRect(0, 0, w, h);
// Graph title
gCtx.fillStyle = '#94a3b8';
gCtx.font = '11px sans-serif';
gCtx.textAlign = 'right';
gCtx.fillText('منحنى السرعة - الزمن v(t) باللون السماوي', w - 14, 18);
// Axes
const originX = 40;
const originY = h / 2;
gCtx.strokeStyle = 'rgba(255,255,255,0.2)';
gCtx.lineWidth = 1;
gCtx.beginPath();
gCtx.moveTo(originX, 10);
gCtx.lineTo(originX, h - 10);
gCtx.moveTo(originX, originY);
gCtx.lineTo(w - 10, originY);
gCtx.stroke();
// Axis labels
gCtx.fillStyle = '#94a3b8';
gCtx.font = '10px sans-serif';
gCtx.textAlign = 'center';
gCtx.fillText('t (s)', w - 16, originY + 14);
gCtx.fillText('v (m/s)', originX + 2, 14);
if (history.length < 2) return;
// Plot v(t)
gCtx.strokeStyle = '#00f5d4';
gCtx.lineWidth = 2;
gCtx.beginPath();
for (let i = 0; i < history.length; i++) {
const pt = history[i];
const px = originX + pt.t * 35;
const py = originY - pt.v * 2.2;
if (i === 0) gCtx.moveTo(px, py);
else gCtx.lineTo(px, py);
}
gCtx.stroke();
}
function drawArrow(ctx, fromx, fromy, tox, toy, color, lineWidth = 2) {
const headlen = 8;
const dx = tox - fromx;
const dy = toy - fromy;
const angle = Math.atan2(dy, dx);
ctx.strokeStyle = color;
ctx.fillStyle = color;
ctx.lineWidth = lineWidth;
ctx.beginPath();
ctx.moveTo(fromx, fromy);
ctx.lineTo(tox, toy);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(tox, toy);
ctx.lineTo(tox - headlen * Math.cos(angle - Math.PI / 6), toy - headlen * Math.sin(angle - Math.PI / 6));
ctx.lineTo(tox - headlen * Math.cos(angle + Math.PI / 6), toy - headlen * Math.sin(angle + Math.PI / 6));
ctx.closePath();
ctx.fill();
}
function loop(timestamp) {
if (!isRunning) return;
if (!lastTime) lastTime = timestamp;
const dt = Math.min((timestamp - lastTime) / 1000, 0.05);
lastTime = timestamp;
t += dt;
const v = getV0() + getA() * t;
const x = getV0() * t + 0.5 * getA() * t * t;
history.push({ t, v, x });
drawMotion();
drawGraph();
if (t >= 10 || Math.abs(x) > 150) {
isRunning = false;
btnPlay.textContent = 'إعادة التشغيل ▶';
btnPlay.className = 'btn btn-play';
return;
}
animId = requestAnimationFrame(loop);
}
btnPlay.addEventListener('click', () => {
isRunning = !isRunning;
if (isRunning) {
btnPlay.textContent = 'إيقاف مؤقت ⏸';
btnPlay.className = 'btn btn-pause';
lastTime = null;
animId = requestAnimationFrame(loop);
} else {
btnPlay.textContent = 'استئناف ▶';
btnPlay.className = 'btn btn-play';
if (animId) cancelAnimationFrame(animId);
}
});
btnReset.addEventListener('click', resetSim);
v0Slider.addEventListener('input', () => {
labelV0.textContent = `v₀ = ${v0Slider.value} m/s`;
if (!isRunning) drawMotion();
});
aSlider.addEventListener('input', () => {
labelA.textContent = `a = ${aSlider.value} m/s²`;
if (!isRunning) drawMotion();
});
// Disable Right-Click and Copy
document.addEventListener('contextmenu', e => e.preventDefault());
document.addEventListener('keydown', e => {
if (e.ctrlKey && (e.key === 'c' || e.key === 'u' || e.key === 's')) e.preventDefault();
});
resetSim();
</script>
</body>
</html>
@@ -210,17 +210,35 @@
ctx.setLineDash([]); ctx.setLineDash([]);
} }
// Draw Permanent Anti-Copy Watermark
ctx.save();
ctx.fillStyle = 'rgba(0, 245, 212, 0.18)';
ctx.font = "bold 11px -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif";
ctx.fillText('منصة صَقِل التعليمية الذكية © Saqel Lab - محمي وغير قابل للنقل', 12, h - 14);
ctx.restore();
// Vector Dot & Cross Products
const deltaAngle = Math.abs(aRad - bRad);
const dotProd = (mA * mB * Math.cos(deltaAngle)).toFixed(1);
const crossProd = (mA * mB * Math.sin(deltaAngle)).toFixed(1);
// Update Math readout // Update Math readout
mathOutput.innerHTML = ` mathOutput.innerHTML = `
Ax = ${ax.toFixed(1)} N | Ay = ${ay.toFixed(1)} N<br> Ax = ${ax.toFixed(1)} N | Ay = ${ay.toFixed(1)} N<br>
Bx = ${bx.toFixed(1)} N | By = ${by.toFixed(1)} N<br> Bx = ${bx.toFixed(1)} N | By = ${by.toFixed(1)} N<br>
<span style="color:#10b981;font-weight:bold;">Rx = Ax + Bx = ${rx.toFixed(1)} N</span><br> <span style="color:#10b981;font-weight:bold;">Rx = Ax + Bx = ${rx.toFixed(1)} N | Ry = Ay + By = ${ry.toFixed(1)} N</span><br>
<span style="color:#10b981;font-weight:bold;">Ry = Ay + By = ${ry.toFixed(1)} N</span><br>
<strong>المحصلة |R| = √(${rx.toFixed(1)}² + ${ry.toFixed(1)}²) = <span class="math-val" style="color:#10b981;">${rMag.toFixed(1)} N</span></strong><br> <strong>المحصلة |R| = √(${rx.toFixed(1)}² + ${ry.toFixed(1)}²) = <span class="math-val" style="color:#10b981;">${rMag.toFixed(1)} N</span></strong><br>
<strong>اتجاه المحصلة θ_R = <span class="math-val" style="color:#10b981;">${rDeg.toFixed(1)}°</span></strong> <strong>اتجاه المحصلة θ_R = <span class="math-val" style="color:#10b981;">${rDeg.toFixed(1)}°</span></strong><br>
<span style="color:#ffd166;">الضرب القياسي (A · B) = ${dotProd} J | الضرب المتجهي |A × B| = ${crossProd} N·m</span>
`; `;
} }
// Disable Right-Click and Copy
document.addEventListener('contextmenu', e => e.preventDefault());
document.addEventListener('keydown', e => {
if (e.ctrlKey && (e.key === 'c' || e.key === 'u' || e.key === 's')) e.preventDefault();
});
[magA, angA, magB, angB, showRes].forEach(el => el.addEventListener('input', render)); [magA, angA, magB, angB, showRes].forEach(el => el.addEventListener('input', render));
render(); render();
</script> </script>