feat: STEM Virtual Lab, English TTS companion, dynamic textbooks and zero-demo video fallback
This commit is contained in:
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
+257
-231
@@ -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});
|
||||||
|
}
|
||||||
|
|||||||
+723
@@ -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"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user