feat(grade10): add chemistry, biology, and earth sciences virtual labs, fix video CDN playback, teacher submissions SQL, and enable authenticated lesson reading

This commit is contained in:
Hamza-Ayed
2026-09-11 17:22:28 +03:00
parent d1bc39604b
commit e61d56c8e1
11 changed files with 2894 additions and 81 deletions
@@ -26,6 +26,7 @@ class AppColors {
static const Color whatsappGreen = Color(0xFF25D366); static const Color whatsappGreen = Color(0xFF25D366);
static const Color errorRed = Color(0xFFEF4444); static const Color errorRed = Color(0xFFEF4444);
static const Color emeraldGreen = teacherEmerald; static const Color emeraldGreen = teacherEmerald;
static const Color emeraldPrimary = teacherEmerald;
static const Color crimsonRed = errorRed; static const Color crimsonRed = errorRed;
// Text Colors // Text Colors
@@ -0,0 +1,935 @@
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) - GRADE 10 BIOLOGY VIRTUAL LABORATORY (120 FPS)
/// ==============================================================================
///
/// مختبر العلوم الحياتية والأحياء التفاعلي للصف العاشر الأساسي (المملكة الأردنية الهاشمية):
/// - المجهر الضوئي المركب الرقمي (عدسات شيئية: 4x, 10x, 40x, 100x ومقابض الضبط)
/// - شرائح مجهرية حقيقية: بشرة البصل، خلايا باطن الخد، البراميسيوم، مسحة الدم
/// - مقارنة الخلية النباتية والحيوانية وفحص العضيات الخلوية
/// - مستكشف العضيات ووظائفها الحيوية (الميتوكندريا، النواة، البلاستيدات، الغشاء)
class BiologyInteractiveLabView extends StatefulWidget {
const BiologyInteractiveLabView({super.key});
@override
State<BiologyInteractiveLabView> createState() => _BiologyInteractiveLabViewState();
}
class _BiologyInteractiveLabViewState extends State<BiologyInteractiveLabView>
with TickerProviderStateMixin {
int _selectedTab = 0; // 0 = Digital Microscope, 1 = Plant vs Animal, 2 = Organelles
// ---------------------------------------------------------------------------
// 1. MICROSCOPE STATE
// ---------------------------------------------------------------------------
int _objectivePower = 10; // 4x, 10x, 40x, 100x
double _focusSlider = 50.0; // Optimal is 50.0
double _lightIntensity = 0.8; // 0.2 to 1.0
int _selectedSlideIndex = 0;
late final AnimationController _parameciumSwimAnim;
final List<Map<String, dynamic>> _slides = [
{
'name': 'غشاء بشرة البصل (Onion Epidermis)',
'type': 'خلية نباتية حقيقية',
'stain': 'مصبوغة باليود أو أزرق الميثيلين',
'optimalFocus': 50.0,
'description': 'خلايا مستطيلة متراصة بانتظام كقوالب الطوب. يظهر الجدار الخلوي السليلوزي بوضوح، والنواة الجانبية، وفجوة عصارية كبيرة.',
'features': ['جدار خلوي سميك وواضح', 'فجوة عصارية مركزية تشغل معظم الحجم', 'نواة جانبية مضغوطة قرب الجدار'],
'bgHue': 0.12, // Yellowish amber
},
{
'name': 'خلايا باطن الخد البشري (Cheek Epithelium)',
'type': 'خلية حيوانية طلائية',
'stain': 'مصبوغة بأزرق الميثيلين (Methylene Blue)',
'optimalFocus': 52.0,
'description': 'خلايا مسطحة غير منتظمة الشكل ومفردة أو متجمعة. تحاط بغشاء بلازمي مرن رقيق مع سيتوبلازم حبيبي ونواة كروية مركزية داكنة.',
'features': ['غشاء بلازمي رقيق مرن (عدم وجود جدار خلوي)', 'نواة مركزية مستديرة داكنة', 'سيتوبلازم يحتوي على حبيبات خلوية وميتوكندريا'],
'bgHue': 0.60, // Blue stain
},
{
'name': 'كائن البراميسيوم (Paramecium Caudatum)',
'type': 'طلائعيات مجهرية وحيدة الخلية',
'stain': 'عينة حية في قطرة ماء بركة',
'optimalFocus': 48.0,
'description': 'كائن وحيد الخلية بشكل نعل الحذاء يتحرك بواسطة الأهداب السطحية. يحتوي على ميزاب فموي وفجوتين منقبضتين لتنظيم الضغط الإسموزي.',
'features': ['أهداب حركية دقيقة تحيط بالجسم كاملاً', 'فجوة منقبضة (Contractile Vacuole) تشع على شكل نجمة', 'نواة كبيرة (Macronucleus) ونواة صغيرة للتكاثر'],
'bgHue': 0.35, // Pond greenish
},
{
'name': 'مسحة دم بشري (Human Blood Smear)',
'type': 'نسيج ضام وعائي متخصص',
'stain': 'صبغة جيمسا / رايت (Wright-Giemsa)',
'optimalFocus': 50.0,
'description': 'تظهر ملايين كريات الدم الحمراء مقعرة الوجهين عديمة الأنوية لنقل الأكسجين، وكريات دم بيضاء مفصصة النوى مناعية، وصفائح دموية.',
'features': ['كريات دم حمراء (RBCs) مقعرة الوجهين باهتة الوسط', 'خلايا دم بيضاء متعادلة (Neutrophils) بنواة ثلاثية الفصوص', 'خلايا ليمفاوية بنواة كروية تشغل معظم الخلية'],
'bgHue': 0.95, // Pinkish red blood stain
},
];
// ---------------------------------------------------------------------------
// 2. ORGANELLES STATE
// ---------------------------------------------------------------------------
int _selectedOrganelleIndex = 0;
final List<Map<String, dynamic>> _organelles = [
{
'name': 'النواة (Nucleus)',
'icon': CupertinoIcons.circle_filled,
'color': const Color(0xFF8B5CF6),
'role': 'مركز التحكم الخلوي وإدارة العمليات الحيوية',
'structure': 'محاطة بغلاف نووي مزدوج مثقب، وتحوي المادة الوراثية (DNA/الكروموسومات) والنوية المسؤولة عن تصنيع الريبوسومات.',
'foundIn': 'الخلية النباتية والخلية الحيوانية معاً',
},
{
'name': 'الميتوكندريا (Mitochondria)',
'icon': CupertinoIcons.bolt_circle_fill,
'color': const Color(0xFFEF4444),
'role': 'محطة توليد الطاقة الخلوية (ATP) عبر التنفس الخلوي الهوائي',
'structure': 'غشاء خارجي أملس وغشاء داخلي ذو ثنيات والتواءات تسمى الأعراف (Cristae) لزيادة مساحة السطح التفاعلي.',
'foundIn': 'الخلية النباتية والخلية الحيوانية معاً',
},
{
'name': 'البلاستيدات الخضراء (Chloroplasts)',
'icon': CupertinoIcons.leaf_arrow_circlepath,
'color': const Color(0xFF10B981),
'role': 'القيام بعملية البناء الضوئي وتحويل طاقة الضوء إلى سكريات كيميائية',
'structure': 'تحتوي على صبغة الكلوروفيل الخضراء وأقراص الثايلاكويد المرتبة في صفائح الغرانا (Grana) المحاطة بالحشوة (Stroma).',
'foundIn': 'الخلية النباتية فقط (غير موجودة في الحيوانية)',
},
{
'name': 'الجدار الخلوي (Cell Wall)',
'icon': CupertinoIcons.shield_lefthalf_fill,
'color': const Color(0xFFD97706),
'role': 'توفير الحماية والدعامة الميكانيكية والشكل المحدد للخلية ومقاومة الضغط الإسموزي',
'structure': 'يتكون أساساً من ألياف السليلوز القوية المرنة مع مواد بكتينية وسليلوز نصفية.',
'foundIn': 'الخلية النباتية والفطريات وبدائيات النواة فقط (غائب تماماً عن الحيوانية)',
},
{
'name': 'جهاز جولجي (Golgi Apparatus)',
'icon': CupertinoIcons.layers_alt_fill,
'color': const Color(0xFF3B82F6),
'role': 'تعديل وتغليف وتصنيف البروتينات والدهون ونقلها في حويصلات إفرازية',
'structure': 'أكياس غشائية مفلطحة متوازية تسمى الصهاريج غير المتصلة مباشرة ببعضها البعض.',
'foundIn': 'الخلية النباتية والحيوانية معاً',
},
{
'name': 'الشبكة الإندوبلازمية (Endoplasmic Reticulum)',
'icon': CupertinoIcons.waveform_path_badge_plus,
'color': const Color(0xFFEC4899),
'role': 'شبكة نقل داخلي؛ الخشنة تصنع البروتينات لوجود ريبوسومات، والملساء تبني الدهون وتزيل سمية المواد',
'structure': 'شبكة من الأنيبيبات والحويصلات الغشائية المتصلة بالغلاف النووي الخارجي.',
'foundIn': 'الخلية النباتية والحيوانية معاً',
},
];
@override
void initState() {
super.initState();
_parameciumSwimAnim = AnimationController(
vsync: this,
duration: const Duration(seconds: 6),
)..repeat(reverse: true);
}
@override
void dispose() {
_parameciumSwimAnim.dispose();
super.dispose();
}
int get _totalMagnification => 10 * _objectivePower;
double get _focusBlur {
final curSlide = _slides[_selectedSlideIndex];
final optimal = curSlide['optimalFocus'] as double;
final diff = (_focusSlider - optimal).abs();
// Blur factor: 0.0 is pin-sharp, >0 has progressive blur
return (diff * 0.35).clamp(0.0, 10.0);
}
bool get _isSharpFocus => _focusBlur < 0.8;
@override
Widget build(BuildContext context) {
return Directionality(
textDirection: TextDirection.rtl,
child: Container(
color: const Color(0xFF090D16),
child: Column(
children: [
_buildHeaderTabs(),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
children: [
if (_selectedTab == 0) _buildDigitalMicroscopeModule(),
if (_selectedTab == 1) _buildPlantVsAnimalModule(),
if (_selectedTab == 2) _buildOrganelleExplorerModule(),
const SizedBox(height: 32),
],
),
),
),
],
),
),
);
}
Widget _buildHeaderTabs() {
return Container(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
decoration: BoxDecoration(
color: const Color(0xFF101726),
border: Border(bottom: BorderSide(color: Colors.white.withOpacity(0.08))),
),
child: Row(
children: [
Expanded(
child: _buildTabItem(0, 'المجهر الرقمي', CupertinoIcons.viewfinder),
),
const SizedBox(width: 8),
Expanded(
child: _buildTabItem(1, 'مقارنة الخلايا', CupertinoIcons.arrow_right_arrow_left_circle_fill),
),
const SizedBox(width: 8),
Expanded(
child: _buildTabItem(2, 'مستكشف العضيات', CupertinoIcons.circle_grid_3x3_fill),
),
],
),
);
}
Widget _buildTabItem(int index, String title, IconData icon) {
final active = _selectedTab == index;
return CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () => setState(() => _selectedTab = index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: active ? AppColors.emeraldPrimary.withOpacity(0.18) : Colors.white.withOpacity(0.04),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: active ? AppColors.emeraldPrimary : Colors.transparent,
width: 1.2,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 16, color: active ? AppColors.emeraldPrimary : Colors.white60),
const SizedBox(width: 6),
Text(
title,
style: TextStyle(
fontSize: 12,
fontWeight: active ? FontWeight.bold : FontWeight.normal,
color: active ? AppColors.emeraldPrimary : Colors.white70,
),
),
],
),
),
);
}
// ===========================================================================
// 1. DIGITAL MICROSCOPE MODULE
// ===========================================================================
Widget _buildDigitalMicroscopeModule() {
final curSlide = _slides[_selectedSlideIndex];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Microscope Circular Viewport
Container(
height: 290,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withOpacity(0.12), width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.6),
blurRadius: 20,
offset: const Offset(0, 6),
),
],
),
child: Stack(
alignment: Alignment.center,
children: [
// Circular Lens Clip
ClipOval(
child: Container(
width: 260,
height: 260,
color: Color.lerp(
Colors.black,
HSVColor.fromAHSV(1.0, (curSlide['bgHue'] as double) * 360, 0.45, 0.85).toColor(),
_lightIntensity * 0.45,
),
child: AnimatedBuilder(
animation: _parameciumSwimAnim,
builder: (context, _) {
return CustomPaint(
painter: _MicroscopeSlidePainter(
slideIndex: _selectedSlideIndex,
magnification: _objectivePower,
blurAmount: _focusBlur,
swimPhase: _parameciumSwimAnim.value,
lightIntensity: _lightIntensity,
),
size: const Size(260, 260),
);
},
),
),
),
// Lens ring frame
IgnorePointer(
child: Container(
width: 264,
height: 264,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: const Color(0xFF475569), width: 6),
),
),
),
// HUD Overlay: Total Magnification & Status
Positioned(
top: 14,
left: 14,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.75),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.white24),
),
child: Text(
'$_totalMagnification× تكبير',
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: AppColors.emeraldPrimary),
),
),
),
Positioned(
top: 14,
right: 14,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: _isSharpFocus ? AppColors.emeraldPrimary.withOpacity(0.2) : Colors.amber.withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: _isSharpFocus ? AppColors.emeraldPrimary : Colors.amber),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_isSharpFocus ? CupertinoIcons.check_mark_circled_solid : CupertinoIcons.slider_horizontal_3,
size: 13,
color: _isSharpFocus ? AppColors.emeraldPrimary : Colors.amber,
),
const SizedBox(width: 4),
Text(
_isSharpFocus ? 'بؤرة حادة وواضحة' : 'اضبط مقبض الفوكس',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: _isSharpFocus ? AppColors.emeraldPrimary : Colors.amber,
),
),
],
),
),
),
],
),
),
const SizedBox(height: 14),
// Controls: Slides Selector & Objective Lens
LuxuryCard(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('اختر الشريحة المجهرية للفحص:', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.white)),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: List.generate(_slides.length, (idx) {
final s = _slides[idx];
final isSel = idx == _selectedSlideIndex;
return Padding(
padding: const EdgeInsets.only(left: 8),
child: CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () => setState(() => _selectedSlideIndex = idx),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
decoration: BoxDecoration(
color: isSel ? AppColors.emeraldPrimary.withOpacity(0.2) : Colors.white.withOpacity(0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSel ? AppColors.emeraldPrimary : Colors.white12,
width: isSel ? 1.5 : 1,
),
),
child: Text(
s['name'] as String,
style: TextStyle(
fontSize: 11,
fontWeight: isSel ? FontWeight.bold : FontWeight.normal,
color: isSel ? Colors.white : Colors.white70,
),
),
),
),
);
}),
),
),
const Divider(color: Colors.white12, height: 20),
// Objective Lenses (4x, 10x, 40x, 100x)
const Text('العدسة الشيئية (Objective Lens):', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.white)),
const SizedBox(height: 8),
Row(
children: [4, 10, 40, 100].map((power) {
final isSel = _objectivePower == power;
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () => setState(() => _objectivePower = power),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: isSel ? const Color(0xFF00E5FF).withOpacity(0.2) : Colors.white.withOpacity(0.04),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSel ? const Color(0xFF00E5FF) : Colors.white12,
width: isSel ? 1.5 : 1,
),
),
child: Column(
children: [
Text('${power}×', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: isSel ? const Color(0xFF00E5FF) : Colors.white)),
Text(
power == 4 ? 'مسح' : power == 10 ? 'صغرى' : power == 40 ? 'كبرى' : 'زيتية',
style: const TextStyle(fontSize: 9, color: Colors.white54),
),
],
),
),
),
),
);
}).toList(),
),
const Divider(color: Colors.white12, height: 20),
// Focus Slider (مقبض الضبط الدقيق والتقريبي)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('مقبض الضبط البؤري (Focus):', style: TextStyle(fontSize: 12, color: Colors.white70)),
Text('${_focusSlider.toInt()}%', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: _isSharpFocus ? AppColors.emeraldPrimary : Colors.amber)),
],
),
CupertinoSlider(
value: _focusSlider,
min: 0,
max: 100,
activeColor: _isSharpFocus ? AppColors.emeraldPrimary : Colors.amber,
onChanged: (v) => setState(() => _focusSlider = v),
),
// Light intensity slider (مكثف الإضاءة)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('شدة الإضاءة ومكثف الضوء:', style: TextStyle(fontSize: 12, color: Colors.white70)),
Text('${(_lightIntensity * 100).toInt()}%', style: const TextStyle(fontSize: 12, color: Colors.white70)),
],
),
CupertinoSlider(
value: _lightIntensity,
min: 0.2,
max: 1.0,
activeColor: const Color(0xFFF59E0B),
onChanged: (v) => setState(() => _lightIntensity = v),
),
],
),
),
const SizedBox(height: 14),
// Microscopic Observation Card
LuxuryCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('المشاهدة المجهرية: ${curSlide['name']}', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.white)),
const SizedBox(height: 4),
Text('نوع العينة: ${curSlide['type']} | الصبغة: ${curSlide['stain']}', style: const TextStyle(fontSize: 11, color: AppColors.emeraldPrimary)),
const SizedBox(height: 8),
Text(curSlide['description'] as String, style: const TextStyle(fontSize: 12, color: Colors.white70, height: 1.4)),
const SizedBox(height: 10),
const Text('أهم التراكيب المشاهدة تحت المجهر:', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.white)),
const SizedBox(height: 6),
...((curSlide['features'] as List).map((f) {
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('• ', style: TextStyle(color: AppColors.emeraldPrimary, fontSize: 13, fontWeight: FontWeight.bold)),
Expanded(
child: Text(f as String, style: const TextStyle(fontSize: 12, color: Colors.white)),
),
],
),
);
})),
],
),
),
],
);
}
// ===========================================================================
// 2. PLANT VS ANIMAL CELL MODULE
// ===========================================================================
Widget _buildPlantVsAnimalModule() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
LuxuryCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'مقارنة الخلية النباتية والخلية الحيوانية (المنهاج المعتمد):',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.white),
),
const SizedBox(height: 12),
_buildComparisonRow(
feature: 'الجدار الخلوي (Cell Wall)',
plant: 'موجود (سليلوز سميك)',
animal: 'غير موجود (غشاء بلازمي فقط)',
highlight: true,
),
_buildComparisonRow(
feature: 'البلاستيدات الخضراء',
plant: 'موجودة للقيام بالبناء الضوئي',
animal: 'غير موجودة إطلاقاً',
highlight: true,
),
_buildComparisonRow(
feature: 'الفجوة العصارية (Vacuole)',
plant: 'فجوة مركزية واحدة ضخمة',
animal: 'فجوات صغيرة ومتعددة',
highlight: false,
),
_buildComparisonRow(
feature: 'الشكل العام للخلية',
plant: 'ثابت ومحدد ومضلع',
animal: 'مرن وغير منتظم ومتنوع',
highlight: false,
),
_buildComparisonRow(
feature: 'المريكزات (Centrioles)',
plant: 'غائبة في النباتات الراقية',
animal: 'موجودة لتنظيم خيوط المغزل',
highlight: true,
),
_buildComparisonRow(
feature: 'تخزين الكربوهيدرات',
plant: 'تخزن السكريات كـ نشا (Starch)',
animal: 'تخزن السكريات كـ جليكوجين',
highlight: false,
),
],
),
),
],
);
}
Widget _buildComparisonRow({
required String feature,
required String plant,
required String animal,
required bool highlight,
}) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: highlight ? AppColors.emeraldPrimary.withOpacity(0.08) : Colors.white.withOpacity(0.03),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: highlight ? AppColors.emeraldPrimary.withOpacity(0.3) : Colors.white12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(feature, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.white)),
const SizedBox(height: 6),
Row(
children: [
Expanded(
child: Row(
children: [
const Icon(CupertinoIcons.leaf_arrow_circlepath, size: 14, color: Color(0xFF10B981)),
const SizedBox(width: 4),
Expanded(child: Text(plant, style: const TextStyle(fontSize: 11, color: Color(0xFF10B981)))),
],
),
),
Expanded(
child: Row(
children: [
const Icon(CupertinoIcons.heart_fill, size: 14, color: Color(0xFF38BDF8)),
const SizedBox(width: 4),
Expanded(child: Text(animal, style: const TextStyle(fontSize: 11, color: Color(0xFF38BDF8)))),
],
),
),
],
),
],
),
);
}
// ===========================================================================
// 3. ORGANELLE EXPLORER MODULE
// ===========================================================================
Widget _buildOrganelleExplorerModule() {
final cur = _organelles[_selectedOrganelleIndex];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Grid of Organelles
LuxuryCard(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('اختر العضية الخلوية لدراسة وظيفتها وتركيبها:', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.white)),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: List.generate(_organelles.length, (idx) {
final o = _organelles[idx];
final isSel = idx == _selectedOrganelleIndex;
final c = o['color'] as Color;
return CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () => setState(() => _selectedOrganelleIndex = idx),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: isSel ? c.withOpacity(0.2) : Colors.white.withOpacity(0.04),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: isSel ? c : Colors.white12, width: isSel ? 1.5 : 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(o['icon'] as IconData, size: 14, color: c),
const SizedBox(width: 6),
Text(
(o['name'] as String).split(' ')[0],
style: TextStyle(
fontSize: 12,
fontWeight: isSel ? FontWeight.bold : FontWeight.normal,
color: isSel ? Colors.white : Colors.white70,
),
),
],
),
),
);
}),
),
],
),
),
const SizedBox(height: 14),
// Organelle Detail Card
LuxuryCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: (cur['color'] as Color).withOpacity(0.2),
shape: BoxShape.circle,
),
child: Icon(cur['icon'] as IconData, color: cur['color'] as Color, size: 24),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(cur['name'] as String, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.white)),
Text('التواجد: ${cur['foundIn']}', style: TextStyle(fontSize: 11, color: cur['color'] as Color, fontWeight: FontWeight.w600)),
],
),
),
],
),
const Divider(color: Colors.white12, height: 24),
const Text('الوظيفة الحيوية في الخلية:', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.white)),
const SizedBox(height: 4),
Text(cur['role'] as String, style: const TextStyle(fontSize: 13, color: Colors.white, height: 1.4)),
const SizedBox(height: 12),
const Text('التركيب المجهري والأغشية:', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.white)),
const SizedBox(height: 4),
Text(cur['structure'] as String, style: const TextStyle(fontSize: 12, color: Colors.white70, height: 1.4)),
],
),
),
],
);
}
}
// ==============================================================================
// CUSTOM PAINTER: MICROSCOPE SLIDE RENDERING
// ==============================================================================
class _MicroscopeSlidePainter extends CustomPainter {
final int slideIndex;
final int magnification;
final double blurAmount;
final double swimPhase;
final double lightIntensity;
_MicroscopeSlidePainter({
required this.slideIndex,
required this.magnification,
required this.blurAmount,
required this.swimPhase,
required this.lightIntensity,
});
@override
void paint(Canvas canvas, Size size) {
final cx = size.width / 2;
final cy = size.height / 2;
// Apply blur if out of focus
final paintObj = Paint()
..isAntiAlias = true
..filterQuality = FilterQuality.medium;
if (blurAmount > 0.5) {
paintObj.maskFilter = MaskFilter.blur(BlurStyle.normal, blurAmount);
}
if (slideIndex == 0) {
// 1. Onion Skin Cells (Rectangular brick layout with thick walls & nuclei)
_drawOnionCells(canvas, size, cx, cy, paintObj);
} else if (slideIndex == 1) {
// 2. Cheek Epithelial Cells (Flat polygonal irregular shapes)
_drawCheekCells(canvas, size, cx, cy, paintObj);
} else if (slideIndex == 2) {
// 3. Paramecium (Slipper-shaped swimming organism with cilia & vacuoles)
_drawParamecium(canvas, size, cx, cy, paintObj);
} else {
// 4. Blood Smear (RBCs biconcave + WBCs lobed nuclei)
_drawBloodSmear(canvas, size, cx, cy, paintObj);
}
// Grid micrometric crosshairs
final crossPaint = Paint()
..color = Colors.white.withOpacity(0.12)
..strokeWidth = 0.6;
canvas.drawLine(Offset(cx, 0), Offset(cx, size.height), crossPaint);
canvas.drawLine(Offset(0, cy), Offset(size.width, cy), crossPaint);
}
void _drawOnionCells(Canvas canvas, Size size, double cx, double cy, Paint paintObj) {
final scale = (magnification / 10.0);
final cellW = 55.0 * scale;
final cellH = 26.0 * scale;
final wallPaint = Paint()
..color = const Color(0xFFB45309).withOpacity(0.8)
..style = PaintingStyle.stroke
..strokeWidth = 2.0;
final cytoPaint = Paint()
..color = const Color(0xFFFDE68A).withOpacity(0.35)
..style = PaintingStyle.fill;
final nucPaint = Paint()
..color = const Color(0xFF92400E)
..style = PaintingStyle.fill;
for (double y = -size.height; y < size.height * 2; y += cellH) {
final offsetX = ((y / cellH).toInt() % 2 == 0) ? cellW / 2 : 0.0;
for (double x = -size.width + offsetX; x < size.width * 2; x += cellW) {
final r = Rect.fromLTWH(x, y, cellW - 2, cellH - 2);
canvas.drawRRect(RRect.fromRectAndRadius(r, const Radius.circular(2)), cytoPaint);
canvas.drawRRect(RRect.fromRectAndRadius(r, const Radius.circular(2)), wallPaint);
// Nucleus positioned near one edge
canvas.drawCircle(Offset(x + cellW * 0.75, y + cellH * 0.4), 3.0 * (scale.clamp(0.8, 2.5)), nucPaint);
}
}
}
void _drawCheekCells(Canvas canvas, Size size, double cx, double cy, Paint paintObj) {
final scale = (magnification / 10.0);
final cells = [
Offset(cx - 35, cy - 25),
Offset(cx + 40, cy + 15),
Offset(cx - 20, cy + 50),
];
for (int i = 0; i < cells.length; i++) {
final center = cells[i];
final r = 32.0 * scale;
final cellPath = Path();
for (int a = 0; a < 8; a++) {
final ang = a * (math.pi / 4);
final rad = r + math.sin(a * 2.5 + i) * 6;
final px = center.dx + rad * math.cos(ang);
final py = center.dy + rad * math.sin(ang);
if (a == 0) {
cellPath.moveTo(px, py);
} else {
cellPath.lineTo(px, py);
}
}
cellPath.close();
final cytoPaint = Paint()
..color = const Color(0xFF60A5FA).withOpacity(0.38)
..style = PaintingStyle.fill;
final memPaint = Paint()
..color = const Color(0xFF2563EB).withOpacity(0.7)
..style = PaintingStyle.stroke
..strokeWidth = 1.4;
final nucPaint = Paint()
..color = const Color(0xFF1E3A8A)
..style = PaintingStyle.fill;
canvas.drawPath(cellPath, cytoPaint);
canvas.drawPath(cellPath, memPaint);
// Nucleus
canvas.drawOval(Rect.fromCenter(center: center, width: 9 * scale, height: 7 * scale), nucPaint);
}
}
void _drawParamecium(Canvas canvas, Size size, double cx, double cy, Paint paintObj) {
final scale = (magnification / 10.0).clamp(0.6, 2.5);
final posX = cx + math.sin(swimPhase * 2 * math.pi) * 20;
final posY = cy + math.cos(swimPhase * 2 * math.pi) * 14;
canvas.save();
canvas.translate(posX, posY);
canvas.rotate(math.sin(swimPhase * math.pi) * 0.25);
// Slipper Body
final bodyPath = Path()
..moveTo(0, -60 * scale)
..cubicTo(26 * scale, -30 * scale, 30 * scale, 35 * scale, 0, 65 * scale)
..cubicTo(-22 * scale, 35 * scale, -18 * scale, -30 * scale, 0, -60 * scale);
final bodyPaint = Paint()
..color = const Color(0xFF10B981).withOpacity(0.35)
..style = PaintingStyle.fill;
final borderPaint = Paint()
..color = const Color(0xFF047857)
..style = PaintingStyle.stroke
..strokeWidth = 1.8;
canvas.drawPath(bodyPath, bodyPaint);
canvas.drawPath(bodyPath, borderPaint);
// Macro-nucleus (kidney bean shape)
final nucPaint = Paint()..color = const Color(0xFF064E3B);
canvas.drawOval(Rect.fromCenter(center: Offset(2 * scale, 5 * scale), width: 14 * scale, height: 24 * scale), nucPaint);
// Contractile vacuole (star shaped)
final vacPaint = Paint()
..color = Colors.white.withOpacity(0.85)
..style = PaintingStyle.stroke
..strokeWidth = 1.2;
canvas.drawCircle(Offset(0, -32 * scale), 6 * scale, vacPaint);
canvas.drawCircle(Offset(0, 38 * scale), 6 * scale, vacPaint);
canvas.restore();
}
void _drawBloodSmear(Canvas canvas, Size size, double cx, double cy, Paint paintObj) {
final scale = (magnification / 10.0);
final rbcR = 10.0 * scale;
final rbcBorder = Paint()
..color = const Color(0xFFDC2626).withOpacity(0.7)
..style = PaintingStyle.stroke
..strokeWidth = 1.4;
final rbcFill = Paint()
..color = const Color(0xFFF87171).withOpacity(0.35)
..style = PaintingStyle.fill;
// Grid of RBCs with slight randomness
final rand = math.Random(42);
for (int i = 0; i < 36; i++) {
final rx = cx + (rand.nextDouble() - 0.5) * size.width * 0.85;
final ry = cy + (rand.nextDouble() - 0.5) * size.height * 0.85;
canvas.drawCircle(Offset(rx, ry), rbcR, rbcFill);
canvas.drawCircle(Offset(rx, ry), rbcR, rbcBorder);
// Biconcave dimple center
canvas.drawCircle(Offset(rx, ry), rbcR * 0.45, Paint()..color = Colors.white.withOpacity(0.18));
}
// 1 White Blood Cell (Neutrophil with 3-lobed dark nucleus)
final wbcPos = Offset(cx + 25, cy - 20);
final wbcR = 16.0 * scale;
canvas.drawCircle(wbcPos, wbcR, Paint()..color = const Color(0xFFC084FC).withOpacity(0.3));
canvas.drawCircle(wbcPos, wbcR, Paint()..color = const Color(0xFF9333EA)..style = PaintingStyle.stroke..strokeWidth = 1.4);
final lobePaint = Paint()..color = const Color(0xFF581C87);
canvas.drawCircle(wbcPos + Offset(-4 * scale, -3 * scale), 4 * scale, lobePaint);
canvas.drawCircle(wbcPos + Offset(4 * scale, -2 * scale), 3.5 * scale, lobePaint);
canvas.drawCircle(wbcPos + Offset(0, 4 * scale), 4 * scale, lobePaint);
}
@override
bool shouldRepaint(covariant _MicroscopeSlidePainter oldDelegate) => true;
}
@@ -124,6 +124,27 @@ class _CurriculumDocumentViewerScreenState extends State<CurriculumDocumentViewe
return; return;
} }
// 2. If assetId is a markdown file path, fetch directly from document or lesson endpoint
if (widget.assetId != null && (widget.assetId!.contains('/') || widget.assetId!.endsWith('.md'))) {
try {
final res = await _api.get('/api/curriculum/document', queryParams: {'file': widget.assetId!}).timeout(const Duration(seconds: 15));
if (res is Map && res['content'] != null && res['content'].toString().trim().isNotEmpty) {
_processContent(res['content'].toString());
if (mounted) setState(() => _isLoading = false);
return;
}
} catch (_) {}
try {
final res = await _api.get('/api/curriculum/lesson', queryParams: {'file': widget.assetId!}).timeout(const Duration(seconds: 15));
if (res is Map && res['content'] != null && res['content'].toString().trim().isNotEmpty) {
_processContent(res['content'].toString());
if (mounted) setState(() => _isLoading = false);
return;
}
} catch (_) {}
}
if (widget.mimeType != null && !widget.mimeType!.toLowerCase().startsWith('text/')) { if (widget.mimeType != null && !widget.mimeType!.toLowerCase().startsWith('text/')) {
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -134,7 +155,7 @@ class _CurriculumDocumentViewerScreenState extends State<CurriculumDocumentViewe
return; return;
} }
// 2. Fetch only the approved asset UUID from the published bundle. // 3. Fetch only the approved asset UUID from the published bundle.
try { try {
final res = await _api.get('/api/curriculum/assets/${widget.assetId}').timeout(const Duration(seconds: 15)); final res = await _api.get('/api/curriculum/assets/${widget.assetId}').timeout(const Duration(seconds: 15));
@@ -858,31 +879,60 @@ class _CurriculumDocumentViewerScreenState extends State<CurriculumDocumentViewe
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( Text(
'هذا الكتاب المقرر متوفر كمرجع مدرسي رسمي معتمد. تم فحص وتوثيق سلامة المحتوى وحقوق النشر والتكامل الرقمي مع خطة المنهاج.', 'المقرر الدراسي المعتمد لمادة ${widget.subjectTitle} (الصف العاشر الأساسي). تم فحص وتوثيق سلامة المحتوى وحقوق النشر والتكامل الرقمي مع خطة المنهاج الوزاري.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 13.5, height: 1.6), style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 13.5, height: 1.6),
), ),
const SizedBox(height: 24), const SizedBox(height: 20),
ElevatedButton.icon( Row(
style: ElevatedButton.styleFrom( children: [
backgroundColor: AppColors.saqelCyan, Expanded(
foregroundColor: Colors.black, child: ElevatedButton.icon(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), backgroundColor: AppColors.appleBlue,
), foregroundColor: Colors.white,
onPressed: () { padding: const EdgeInsets.symmetric(vertical: 14),
SaqelToast.showSuccess( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
context, ),
'الأصل الوزاري منشور ومطابق للمنهاج (${widget.assetId ?? "معتمد"})', onPressed: () {
title: 'النسخة المعتمدة', Navigator.of(context).pop();
); },
}, icon: const Icon(CupertinoIcons.list_bullet, size: 18),
icon: const Icon(CupertinoIcons.checkmark_circle_fill, size: 20), label: const Text(
label: const Text( 'تصفح دروس ووحدات الكتاب',
'النسخة الرقمية معتمدة وموثقة', style: TextStyle(fontWeight: FontWeight.w800, fontSize: 13),
style: TextStyle(fontWeight: FontWeight.w800), ),
), ),
),
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.saqelCyan,
side: BorderSide(color: AppColors.saqelCyan.withOpacity(0.5)),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: () {
SaqelToast.showSuccess(
context,
'الأصل الوزاري منشور ومطابق للمنهاج (${widget.assetId ?? "نسخة رسمية"})',
title: 'الكتاب المدرسي المعتمد',
);
},
icon: const Icon(CupertinoIcons.checkmark_seal_fill, size: 18),
label: const Text(
'اعتماد وزارة التربية والتعليم ✅',
style: TextStyle(fontWeight: FontWeight.w700, fontSize: 12.5),
),
),
),
],
), ),
], ],
), ),
@@ -0,0 +1,574 @@
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) - GRADE 10 EARTH SCIENCES VIRTUAL LABORATORY
/// ==============================================================================
///
/// مختبر علوم الأرض والبيئة التفاعلي للصف العاشر الأساسي (المملكة الأردنية الهاشمية):
/// - دورة الصخور في الطبيعة (الصخور النارية، الرسوبية، المتحولة، والماغما)
/// - مقياس موهس للصلادة واختبار الخدش بأدوات شائعة (ظفر، عملة، نصل، زجاج)
/// - مبادئ التعاقب الطبقي والجيولوجيا التاريخية وصخور الأردن الاقتصادية
class EarthSciencesInteractiveLabView extends StatefulWidget {
const EarthSciencesInteractiveLabView({super.key});
@override
State<EarthSciencesInteractiveLabView> createState() => _EarthSciencesInteractiveLabViewState();
}
class _EarthSciencesInteractiveLabViewState extends State<EarthSciencesInteractiveLabView> {
int _selectedTab = 0; // 0 = Rock Cycle, 1 = Mohs Scale, 2 = Stratigraphy
// ---------------------------------------------------------------------------
// 1. ROCK CYCLE STATE
// ---------------------------------------------------------------------------
int _selectedRockType = 0; // 0 = Igneous, 1 = Sedimentary, 2 = Metamorphic
final List<Map<String, dynamic>> _rockCategories = [
{
'title': 'الصخور النارية (Igneous Rocks)',
'badge': 'تبلور وتجمد الصهارة',
'color': const Color(0xFFEF4444),
'icon': CupertinoIcons.flame_fill,
'formation': 'تتشكل من تبريد وتبلور الماغما في باطن الأرض (صخور جوفية خشنة البلورات مثل الجرانيت)، أو تبريد اللابة على السطح بسرعة (صخور سطحية ناعمة مثل البازلت والأوبسيديان الزجاجي).',
'examples': [
{'name': 'الجرانيت (Granite)', 'type': 'جوفي', 'desc': 'صخر حامضي خشن التبلور غني بالكوارتز والفلسبار، يشكل الأساس القاري في جنوب الأردن.'},
{'name': 'البازلت (Basalt)', 'type': 'سطحي', 'desc': 'صخر قاعدي دقيق التبلور داكن اللون ناتج عن براكين الحرات شمال شرق الأردن.'},
{'name': 'الأوبسيديان (Obsidian)', 'type': 'سطحي زجاجي', 'desc': 'زجاج بركاني تبرد بسرعة فائقة منعت تشكل البلورات.'},
],
'cycleTransitions': 'عند تعرضها للتجوية والتعرية تتحول إلى رسوبيات، وعند تعرضها للحرارة والضغط العاليين دون انصهار تتحول إلى صخور متحولة.',
},
{
'title': 'الصخور الرسوبية (Sedimentary Rocks)',
'badge': 'تجوية وترسيب وتصخر',
'color': const Color(0xFFF59E0B),
'icon': CupertinoIcons.layers_alt_fill,
'formation': 'تتكون بفعل عمليات التجوية الفيزيائية والكيميائية وتفتيت الصخور ونقلها بالرياح والمياه ثم ترسيبها في أحواض الترسيب وضغطها وتلاصقها بالمواد اللاحمة.',
'examples': [
{'name': 'الحجر الرملي (Sandstone)', 'type': 'رسوبي فتاتي', 'desc': 'يتكون من حبيبات الكوارتز المتماسكة، يمثل خزان الديسي المائي وصخور البتراء الوردية.'},
{'name': 'الحجر الجيري (Limestone)', 'type': 'رسوبي كيميائي/عضوي', 'desc': 'يتكون أساساً من الكالسيت (CaCO3) وواسع الانتشار في جبال عجلون وعمان.'},
{'name': 'الفوسفات الأردني (Phosphate)', 'type': 'رسوبي بيوكيميائي', 'desc': 'ثروة وطنية كبرى في مناجم الحسا والشيدية، غني ببقايا عظام وأسنان كائنات بحرية قديمة.'},
],
'cycleTransitions': 'إذا تعرضت لضغط وحرارة جوفيين تتحول لمتحولة، وإذا انصهرت بالكامل تعود لماغما.',
},
{
'title': 'الصخور المتحولة (Metamorphic Rocks)',
'badge': 'حرارة وضغط فائقان',
'color': const Color(0xFF8B5CF6),
'icon': CupertinoIcons.hammer_fill,
'formation': 'صخور كانت في الأصل نارية أو رسوبية تعرضت لظروف حرارة وضغط مرتفعين وموائل نشطة كيميائياً في أعماق القشرة الأرضية، مما أدى لإعادة تبلورها وترتيب معادنها دون وصولها لدرجة الانصهار.',
'examples': [
{'name': 'الرخام (Marble)', 'type': 'غير متورق', 'desc': 'ناتج عن تحول الحجر الجيري بتأثير الحرارة، ذو بلورات كالسيت ملتحمة تستخدم في البناء الفاخر.'},
{'name': 'الشيست (Schist)', 'type': 'متورق', 'desc': 'يتميز بانتظام صفائح المايكا في طبقات متوازية واضحة (التورق Metamorphic Foliation).'},
{'name': 'النايس (Gneiss)', 'type': 'متورق نطيقي', 'desc': 'صخر متحول عالي الدرجة يتميز بأشرطة متبادلة فاتحة وداكنة من الكوارتز والفلسبار والهورنبلند.'},
],
'cycleTransitions': 'إذا ارتفعت الحرارة فوق نقطة الانصهار تنصهر إلى ماغما جديدة؛ وإذا ارتفعت للسطح تتعرض للتجوية لتصبح رسوبيات.',
},
];
// ---------------------------------------------------------------------------
// 2. MOHS SCALE STATE
// ---------------------------------------------------------------------------
int _selectedMineralIndex = 2; // Calcite (3) by default
int _selectedToolIndex = 1; // Copper coin (3.5) by default
final List<Map<String, dynamic>> _mohsMinerals = [
{'hardness': 1, 'name': 'التلك (Talc)', 'formula': 'Mg3Si4O10(OH)2', 'color': Colors.white70},
{'hardness': 2, 'name': 'الجبس (Gypsum)', 'formula': 'CaSO4·2H2O', 'color': const Color(0xFFE2E8F0)},
{'hardness': 3, 'name': 'الكالسيت (Calcite)', 'formula': 'CaCO3', 'color': const Color(0xFFCBD5E1)},
{'hardness': 4, 'name': 'الفلوريت (Fluorite)', 'formula': 'CaF2', 'color': const Color(0xFFA7F3D0)},
{'hardness': 5, 'name': 'الأباتيت (Apatite)', 'formula': 'Ca5(PO4)3(F,Cl,OH)', 'color': const Color(0xFF67E8F9)},
{'hardness': 6, 'name': 'الأورثوكليز (Orthoclase)', 'formula': 'KAlSi3O8', 'color': const Color(0xFFFDE047)},
{'hardness': 7, 'name': 'الكوارتز (Quartz)', 'formula': 'SiO2', 'color': const Color(0xFFE0E7FF)},
{'hardness': 8, 'name': 'التوباز (Topaz)', 'formula': 'Al2SiO4(F,OH)2', 'color': const Color(0xFFFDBA74)},
{'hardness': 9, 'name': 'الكورندم (Corundum)', 'formula': 'Al2O3', 'color': const Color(0xFFF472B6)},
{'hardness': 10, 'name': 'الألماس (Diamond)', 'formula': 'C', 'color': const Color(0xFF38BDF8)},
];
final List<Map<String, dynamic>> _scratchTools = [
{'name': 'ظفر الإصبع (Fingernail)', 'hardness': 2.5, 'icon': CupertinoIcons.hand_draw_fill},
{'name': 'عملة نحاسية (Copper Coin)', 'hardness': 3.5, 'icon': CupertinoIcons.circle_filled},
{'name': 'مسمار حديدي / نصل سكين', 'hardness': 5.5, 'icon': CupertinoIcons.pin_fill},
{'name': 'لوح زجاجي (Glass Plate)', 'hardness': 5.5, 'icon': CupertinoIcons.square_fill},
{'name': 'نصل فولاذي مقسى (Steel File)', 'hardness': 6.5, 'icon': CupertinoIcons.wrench_fill},
{'name': 'لوح الخدش الخزفي (Streak Plate)', 'hardness': 7.0, 'icon': CupertinoIcons.rectangle_fill_on_rectangle_fill},
];
@override
Widget build(BuildContext context) {
return Directionality(
textDirection: TextDirection.rtl,
child: Container(
color: const Color(0xFF090D16),
child: Column(
children: [
_buildHeaderTabs(),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column(
children: [
if (_selectedTab == 0) _buildRockCycleModule(),
if (_selectedTab == 1) _buildMohsScaleModule(),
if (_selectedTab == 2) _buildStratigraphyModule(),
const SizedBox(height: 32),
],
),
),
),
],
),
),
);
}
Widget _buildHeaderTabs() {
return Container(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
decoration: BoxDecoration(
color: const Color(0xFF101726),
border: Border(bottom: BorderSide(color: Colors.white.withOpacity(0.08))),
),
child: Row(
children: [
Expanded(
child: _buildTabItem(0, 'دورة الصخور', CupertinoIcons.arrow_2_circlepath_circle_fill),
),
const SizedBox(width: 8),
Expanded(
child: _buildTabItem(1, 'مقياس موهس', CupertinoIcons.sparkles),
),
const SizedBox(width: 8),
Expanded(
child: _buildTabItem(2, 'التعاقب الطبقي', CupertinoIcons.square_stack_3d_up_fill),
),
],
),
);
}
Widget _buildTabItem(int index, String title, IconData icon) {
final active = _selectedTab == index;
return CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () => setState(() => _selectedTab = index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: active ? AppColors.emeraldPrimary.withOpacity(0.18) : Colors.white.withOpacity(0.04),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: active ? AppColors.emeraldPrimary : Colors.transparent,
width: 1.2,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 16, color: active ? AppColors.emeraldPrimary : Colors.white60),
const SizedBox(width: 6),
Text(
title,
style: TextStyle(
fontSize: 12,
fontWeight: active ? FontWeight.bold : FontWeight.normal,
color: active ? AppColors.emeraldPrimary : Colors.white70,
),
),
],
),
),
);
}
// ===========================================================================
// 1. ROCK CYCLE MODULE
// ===========================================================================
Widget _buildRockCycleModule() {
final cat = _rockCategories[_selectedRockType];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Category Selector Buttons
Row(
children: List.generate(_rockCategories.length, (idx) {
final c = _rockCategories[idx];
final isSel = idx == _selectedRockType;
final col = c['color'] as Color;
return Expanded(
child: Padding(
padding: EdgeInsets.only(left: idx < 2 ? 6 : 0),
child: CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () => setState(() => _selectedRockType = idx),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: isSel ? col.withOpacity(0.25) : Colors.white.withOpacity(0.04),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: isSel ? col : Colors.white12, width: isSel ? 1.5 : 1),
),
child: Column(
children: [
Icon(c['icon'] as IconData, size: 16, color: isSel ? col : Colors.white60),
const SizedBox(height: 4),
Text(
(c['title'] as String).split(' ')[1],
style: TextStyle(
fontSize: 12,
fontWeight: isSel ? FontWeight.bold : FontWeight.normal,
color: isSel ? Colors.white : Colors.white70,
),
),
],
),
),
),
),
);
}),
),
const SizedBox(height: 14),
// Detail Card
LuxuryCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: (cat['color'] as Color).withOpacity(0.2),
shape: BoxShape.circle,
),
child: Icon(cat['icon'] as IconData, color: cat['color'] as Color, size: 20),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(cat['title'] as String, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.white)),
Text('الآلية: ${cat['badge']}', style: TextStyle(fontSize: 11, color: cat['color'] as Color, fontWeight: FontWeight.w600)),
],
),
),
],
),
const Divider(color: Colors.white12, height: 24),
const Text('كيفية النشأة والتكون:', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.white)),
const SizedBox(height: 4),
Text(cat['formation'] as String, style: const TextStyle(fontSize: 12, color: Colors.white70, height: 1.4)),
const SizedBox(height: 14),
// Rock Samples in Jordan
const Text('أمثلة ونماذج من جيولوجيا الأردن:', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.white)),
const SizedBox(height: 8),
...((cat['examples'] as List).map((ex) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.03),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.white12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(ex['name'] as String, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: AppColors.emeraldPrimary)),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(color: Colors.white10, borderRadius: BorderRadius.circular(4)),
child: Text(ex['type'] as String, style: const TextStyle(fontSize: 10, color: Colors.white70)),
),
],
),
const SizedBox(height: 4),
Text(ex['desc'] as String, style: const TextStyle(fontSize: 11, color: Colors.white60, height: 1.3)),
],
),
);
})),
const SizedBox(height: 8),
const Text('مسار الانتقال في دورة الصخور:', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.amberAccent)),
const SizedBox(height: 4),
Text(cat['cycleTransitions'] as String, style: const TextStyle(fontSize: 11, color: Colors.white70, height: 1.3)),
],
),
),
],
);
}
// ===========================================================================
// 2. MOHS SCALE MODULE
// ===========================================================================
Widget _buildMohsScaleModule() {
final curMin = _mohsMinerals[_selectedMineralIndex];
final curTool = _scratchTools[_selectedToolIndex];
final minHard = curMin['hardness'] as int;
final toolHard = curTool['hardness'] as double;
final canScratch = toolHard >= minHard;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Scratch Testing Lab Box
LuxuryCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'اختبار الخدش والصلادة (Scratch Hardness Test):',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.white),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.04),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('المعدن المختبر:', style: TextStyle(fontSize: 11, color: Colors.white60)),
const SizedBox(height: 4),
Text(curMin['name'] as String, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.white)),
Text('الصلادة = $minHard', style: const TextStyle(fontSize: 11, color: AppColors.emeraldPrimary, fontWeight: FontWeight.bold)),
],
),
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 10),
child: Text('مقابل', style: TextStyle(color: Colors.white38, fontSize: 11)),
),
Expanded(
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.04),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('أداة الخدش:', style: TextStyle(fontSize: 11, color: Colors.white60)),
const SizedBox(height: 4),
Text(curTool['name'] as String, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.white), maxLines: 1, overflow: TextOverflow.ellipsis),
Text('الصلادة = $toolHard', style: const TextStyle(fontSize: 11, color: Colors.amberAccent, fontWeight: FontWeight.bold)),
],
),
),
),
],
),
const SizedBox(height: 14),
// Scratch Result Banner
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: canScratch ? const Color(0xFF10B981).withOpacity(0.18) : const Color(0xFFEF4444).withOpacity(0.18),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: canScratch ? const Color(0xFF10B981) : const Color(0xFFEF4444)),
),
child: Row(
children: [
Icon(
canScratch ? CupertinoIcons.check_mark_circled_solid : CupertinoIcons.xmark_circle_fill,
color: canScratch ? const Color(0xFF10B981) : const Color(0xFFEF4444),
size: 24,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
canScratch ? 'تنجح الأداة في خدش المعدن! ⚡' : 'تفشل الأداة — المعدن أصلد من الأداة! 🛡️',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.white),
),
const SizedBox(height: 2),
Text(
canScratch
? 'لأن صلادة الأداة ($toolHard) أعلى من أو تساوي صلادة معدن ${curMin['name']} ($minHard).'
: 'لأن صلادة معدن ${curMin['name']} ($minHard) أعلى من صلادة أداة الاختبار ($toolHard).',
style: const TextStyle(fontSize: 11, color: Colors.white70),
),
],
),
),
],
),
),
],
),
),
const SizedBox(height: 14),
// Select Mineral on Mohs Scale (1 to 10)
LuxuryCard(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('اختر المعدن من سلم موهس (1 إلى 10):', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.white)),
const SizedBox(height: 10),
Wrap(
spacing: 6,
runSpacing: 6,
children: List.generate(_mohsMinerals.length, (idx) {
final m = _mohsMinerals[idx];
final isSel = idx == _selectedMineralIndex;
return CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () => setState(() => _selectedMineralIndex = idx),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: isSel ? AppColors.emeraldPrimary.withOpacity(0.25) : Colors.white.withOpacity(0.04),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: isSel ? AppColors.emeraldPrimary : Colors.white12),
),
child: Text(
'${m['hardness']}. ${(m['name'] as String).split(' ')[0]}',
style: TextStyle(
fontSize: 11,
fontWeight: isSel ? FontWeight.bold : FontWeight.normal,
color: isSel ? Colors.white : Colors.white70,
),
),
),
);
}),
),
const Divider(color: Colors.white12, height: 20),
// Select Tool
const Text('اختر أداة الخدش المقارنة:', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.white)),
const SizedBox(height: 10),
Wrap(
spacing: 6,
runSpacing: 6,
children: List.generate(_scratchTools.length, (idx) {
final t = _scratchTools[idx];
final isSel = idx == _selectedToolIndex;
return CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () => setState(() => _selectedToolIndex = idx),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: isSel ? Colors.amber.withOpacity(0.25) : Colors.white.withOpacity(0.04),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: isSel ? Colors.amber : Colors.white12),
),
child: Text(
'${(t['name'] as String).split(' ')[0]} (${t['hardness']})',
style: TextStyle(
fontSize: 11,
fontWeight: isSel ? FontWeight.bold : FontWeight.normal,
color: isSel ? Colors.white : Colors.white70,
),
),
),
);
}),
),
],
),
),
],
);
}
// ===========================================================================
// 3. STRATIGRAPHY MODULE
// ===========================================================================
Widget _buildStratigraphyModule() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
LuxuryCard(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'مبادئ التاريخ الجيولوجي والتعاقب الطبقي (منهاج الصف العاشر):',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.white),
),
const SizedBox(height: 12),
_buildStratigraphyPrinciple(
title: '1. مبدأ تعاقب الطبقات (Law of Superposition)',
author: 'العالم نيكولاس ستينو (1669م)',
content: 'في أي تتابع طبقي صخري رسوبي لم يتعرض لتشوه تكتوني، فإن الطبقة الصخرية الواقعة في الأسفل هي الأقدم عمراً، وتليها طبقات أحدث فأحدث وصولاً لأحدث طبقة في الأعلى.',
),
_buildStratigraphyPrinciple(
title: '2. مبدأ القاطع والمقطوع (Cross-cutting Relationships)',
author: 'العالم جيمس هيتون',
content: 'أي قاطع ناري (ديك، سيل) أو صدع زلزالي يقطع طبقات صخرية، يكون دائماً أحدث عمراً من الصخور التي يقطعها (القاطع أحدث من المقطوع).',
),
_buildStratigraphyPrinciple(
title: '3. مبدأ تعاقب الحياة والمستحاثات (Faunal Succession)',
author: 'العالم وليم سميث',
content: 'كل طبقة رسوبية تحتوي على أحافير مميزة خاصة بعصرها الجيولوجي تختلف عن الطبقات السابقة واللاحقة، مما يسمح بمضاهاة الصخور عبر مسافات جغرافية شاسعة.',
),
_buildStratigraphyPrinciple(
title: '4. مبدأ الترسيب الأفقي الأصلي (Original Horizontality)',
author: 'العالم نيكولاس ستينو',
content: 'تترسب الصخور الرسوبية في الأصل في طبقات أفقية موازية لسطح الأرض بفعل الجاذبية، وأي ميلان أو طي لاحق يدل على تأثرها بقوى تكتونية بعد ترسيبها.',
),
],
),
),
],
);
}
Widget _buildStratigraphyPrinciple({
required String title,
required String author,
required String content,
}) {
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.03),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: AppColors.emeraldPrimary)),
Text(author, style: const TextStyle(fontSize: 11, color: Colors.white54)),
const SizedBox(height: 6),
Text(content, style: const TextStyle(fontSize: 12, color: Colors.white70, height: 1.4)),
],
),
);
}
}
@@ -22,6 +22,9 @@ 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 'physics_interactive_lab_view.dart';
import 'chemistry_interactive_lab_view.dart';
import 'biology_interactive_lab_view.dart';
import 'earth_sciences_interactive_lab_view.dart';
import 'arabic_interactive_lab_view.dart'; import 'arabic_interactive_lab_view.dart';
import 'islamic_interactive_lab_view.dart'; import 'islamic_interactive_lab_view.dart';
import 'history_interactive_timeline_view.dart'; import 'history_interactive_timeline_view.dart';
@@ -41,7 +44,8 @@ class SubjectHubScreen extends StatefulWidget {
State<SubjectHubScreen> createState() => _SubjectHubScreenState(); State<SubjectHubScreen> createState() => _SubjectHubScreenState();
} }
class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerProviderStateMixin { class _SubjectHubScreenState extends State<SubjectHubScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
late Future<List<ExamModel>> _examsFuture; late Future<List<ExamModel>> _examsFuture;
@@ -54,13 +58,25 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
widget.subject.title.contains('إنجليز') || widget.subject.title.contains('إنجليز') ||
widget.subject.title.contains('لغة إنجليزية'); widget.subject.title.contains('لغة إنجليزية');
bool get _isScienceSubject => bool get _isChemistrySubject =>
widget.subject.id.contains('physic') ||
widget.subject.title.contains('فيزياء') ||
widget.subject.id.contains('chem') || widget.subject.id.contains('chem') ||
widget.subject.id.contains('كيمياء') || widget.subject.title.contains('كيمياء');
widget.subject.id.contains('science') ||
widget.subject.title.contains('علوم'); bool get _isBiologySubject =>
widget.subject.id.contains('bio') ||
widget.subject.title.contains('أحياء') ||
widget.subject.title.contains('حياتية');
bool get _isEarthSciencesSubject =>
widget.subject.id.contains('earth') ||
widget.subject.id.contains('geo') ||
widget.subject.title.contains('أرض') ||
widget.subject.title.contains('بيئة') ||
widget.subject.title.contains('جيولوجيا');
bool get _isPhysicsSubject =>
widget.subject.id.contains('physic') ||
widget.subject.title.contains('فيزياء');
bool get _isArabicSubject => bool get _isArabicSubject =>
widget.subject.id.contains('arab') || widget.subject.id.contains('arab') ||
@@ -84,7 +100,10 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
bool get _hasLab => bool get _hasLab =>
_isMathSubject || _isMathSubject ||
_isEnglishSubject || _isEnglishSubject ||
_isScienceSubject || _isChemistrySubject ||
_isBiologySubject ||
_isEarthSciencesSubject ||
_isPhysicsSubject ||
_isArabicSubject || _isArabicSubject ||
_isIslamicSubject || _isIslamicSubject ||
_isHistorySubject; _isHistorySubject;
@@ -115,10 +134,25 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
icon: Icon(CupertinoIcons.time_solid, size: 18), icon: Icon(CupertinoIcons.time_solid, size: 18),
text: 'الخط الزمني لتاريخ الأردن 🏛️', text: 'الخط الزمني لتاريخ الأردن 🏛️',
); );
} else if (_isChemistrySubject) {
return const Tab(
icon: Icon(CupertinoIcons.flame_fill, size: 18),
text: 'مختبر الكيمياء والطيوف ⚗️',
);
} else if (_isBiologySubject) {
return const Tab(
icon: Icon(CupertinoIcons.viewfinder, size: 18),
text: 'مختبر الأحياء والمجهر 🔬',
);
} else if (_isEarthSciencesSubject) {
return const Tab(
icon: Icon(CupertinoIcons.globe, size: 18),
text: 'مختبر علوم الأرض والصخور 🌍',
);
} else { } else {
return const Tab( return const Tab(
icon: Icon(CupertinoIcons.lab_flask_solid, size: 18), icon: Icon(CupertinoIcons.bolt_fill, size: 18),
text: 'المختبر التفاعلي 🧪', text: 'مختبر الفيزياء والميكانيكا ⚡',
); );
} }
} }
@@ -134,6 +168,12 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
return const IslamicInteractiveLabView(); return const IslamicInteractiveLabView();
} else if (_isHistorySubject) { } else if (_isHistorySubject) {
return const HistoryInteractiveTimelineView(); return const HistoryInteractiveTimelineView();
} else if (_isChemistrySubject) {
return const ChemistryInteractiveLabView();
} else if (_isBiologySubject) {
return const BiologyInteractiveLabView();
} else if (_isEarthSciencesSubject) {
return const EarthSciencesInteractiveLabView();
} else { } else {
return const PhysicsInteractiveLabView(); return const PhysicsInteractiveLabView();
} }
@@ -426,38 +466,59 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
} }
final liveExams = snapshot.data ?? []; final liveExams = snapshot.data ?? [];
if (liveExams.isEmpty) { if (liveExams.isNotEmpty) {
// Default to Primary Unit Exam if none fetched yet return ListView.builder(
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20), padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20),
children: [ itemCount: liveExams.length,
_buildExamCard( itemBuilder: (context, idx) {
context, final exam = liveExams[idx];
examId: 1, return Padding(
title: 'اختبار الفهم الشامل: الوحدة الأولى (أنظمة المعادلات)', padding: const EdgeInsets.only(bottom: 12),
questionsCount: 50, child: _buildExamCard(
durationMinutes: 45, context,
), examId: exam.id > 0 ? exam.id : 1,
], title: exam.title,
questionsCount: exam.questionsCount > 0 ? exam.questionsCount : 25,
durationMinutes: exam.durationMinutes > 0 ? exam.durationMinutes : 40,
),
);
},
); );
} }
return ListView.builder( // If no server exams, generate unit exams from widget.subject.units
final subjectUnits = widget.subject.units;
if (subjectUnits.isNotEmpty) {
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20),
itemCount: subjectUnits.length,
itemBuilder: (context, idx) {
final unit = subjectUnits[idx];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildExamCard(
context,
examId: idx + 1,
title: 'اختبار الفهم التكيفي: ${unit.name}',
questionsCount: 25,
durationMinutes: 40,
),
);
},
);
}
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20), padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20),
itemCount: liveExams.length, children: [
itemBuilder: (context, idx) { _buildExamCard(
final exam = liveExams[idx]; context,
return Padding( examId: 1,
padding: const EdgeInsets.only(bottom: 12), title: 'اختبار الفهم الشامل: ${widget.subject.title} (الوحدة الأولى)',
child: _buildExamCard( questionsCount: 25,
context, durationMinutes: 40,
examId: exam.id > 0 ? exam.id : 1, ),
title: exam.title, ],
questionsCount: exam.questionsCount > 0 ? exam.questionsCount : 50,
durationMinutes: exam.durationMinutes > 0 ? exam.durationMinutes : 45,
),
);
},
); );
}, },
); );
@@ -787,9 +848,57 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( const Text(
'لا يوجد شرح منشور لهذا الدرس بعد. ستظهر فقط الموارد التي تُراجع وتُنشر لهذا الدرس أو للمادة.', 'لا يوجد شرح مصور منشور لهذا الدرس بعد. يمكنك قراءة شروحات الدرس المعتمدة أو الانتقال للمختبر التفاعلي.',
style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 13, height: 1.5), style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 13, height: 1.5),
), ),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
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,
assetId: lesson.markdownFilePath ?? lesson.curriculumLessonId,
),
),
);
},
icon: const Icon(CupertinoIcons.book, size: 18),
label: const Text('قراءة شرح ونصوص الدرس المعتمد 📖', style: TextStyle(fontWeight: FontWeight.w800)),
),
),
if (_hasLab) ...[
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.saqelCyan,
side: BorderSide(color: AppColors.saqelCyan.withOpacity(0.5)),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: () {
Navigator.of(ctx).pop();
_tabController.animateTo(4);
},
icon: const Icon(CupertinoIcons.lab_flask_solid, size: 18),
label: const Text('الانتقال إلى المختبر التفاعلي للمادة 🧪', style: TextStyle(fontWeight: FontWeight.w700)),
),
),
],
], ],
), ),
), ),
@@ -336,10 +336,19 @@ class TeacherStudioCubit extends Cubit<TeacherStudioState> {
clearError: true, clearError: true,
)); ));
try { try {
final preflight = await repository.preflightSubmission( var preflight = await repository.preflightSubmission(
curriculumLessonId: selectedCurriculumLessonId!, curriculumLessonId: selectedCurriculumLessonId!,
idempotencyKey: _newIdempotencyKey(), idempotencyKey: _newIdempotencyKey(),
); );
if (preflight['status']?.toString() == 'existing_submission' && preflight['submission_id'] != null) {
// إذا كانت الحصة مسجلة مسبقاً، نطلب تلقائياً إنشاء نسخة مرشحة للاستبدال
preflight = await repository.preflightSubmission(
curriculumLessonId: selectedCurriculumLessonId!,
idempotencyKey: _newIdempotencyKey(),
replacement: true,
submissionId: preflight['submission_id'].toString(),
);
}
if (preflight['status']?.toString() != 'ready_for_upload') { if (preflight['status']?.toString() != 'ready_for_upload') {
emit(state.copyWith( emit(state.copyWith(
isAuditing: false, isAuditing: false,
+23 -12
View File
@@ -57,7 +57,7 @@ class VideoController
try { try {
$row = Database::selectOne( $row = Database::selectOne(
"SELECT vv.uuid AS video_version_id, l.id AS lesson_id, l.course_id, l.storage_type, l.video_uuid, l.hls_url, l.ai_video_url, l.bunny_video_id, l.duration_seconds, "SELECT vv.uuid AS video_version_id, l.id AS lesson_id, l.course_id, l.storage_type, l.video_uuid, l.hls_url, l.ai_video_url, l.bunny_video_id, l.duration_seconds,
cl.uuid AS curriculum_lesson_id, cl.title, ts.uuid AS submission_id cl.uuid AS curriculum_lesson_id, cl.title, cl.grade_key, ts.uuid AS submission_id
FROM video_versions vv FROM video_versions vv
JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id AND ts.current_published_video_version_id=vv.id AND ts.status='published' JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id AND ts.current_published_video_version_id=vv.id AND ts.status='published'
JOIN curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id JOIN curriculum_lessons cl ON cl.id=ts.curriculum_lesson_id
@@ -65,12 +65,14 @@ class VideoController
WHERE vv.uuid=? AND vv.status='published' LIMIT 1", [$versionUuid] WHERE vv.uuid=? AND vv.status='published' LIMIT 1", [$versionUuid]
); );
if (!$row) { $response->status(404)->json(['status'=>'error','message'=>'نسخة الفيديو المطلوبة غير منشورة أو سُحبت.']); return; } if (!$row) { $response->status(404)->json(['status'=>'error','message'=>'نسخة الفيديو المطلوبة غير منشورة أو سُحبت.']); return; }
$course=Database::selectOne('SELECT grade_level FROM courses WHERE id=? LIMIT 1',[$row['course_id']]); $course=Database::selectOne('SELECT grade_level, price_jod FROM courses WHERE id=? LIMIT 1',[$row['course_id']]);
$access=\App\Services\StudentAccessControlService::validateLessonAccess((int)$request->user_id, $request->getHeader('x-national-id'), \App\Services\StudentAccessControlService::normalizeGrade($course['grade_level'] ?? 'grade_10'), (int)$row['course_id'], (int)$row['lesson_id']); $effectiveGrade = \App\Services\StudentAccessControlService::normalizeGrade($row['grade_key'] ?? $course['grade_level'] ?? 'grade_10');
$access=\App\Services\StudentAccessControlService::validateLessonAccess((int)$request->user_id, $request->getHeader('x-national-id'), $effectiveGrade, (int)$row['course_id'], (int)$row['lesson_id']);
if (empty($access['allowed'])) { $response->status(403)->json(['status'=>'forbidden','message'=>$access['message'] ?? 'غير مصرح بمشاهدة هذه الحصة.']); return; } if (empty($access['allowed'])) { $response->status(403)->json(['status'=>'forbidden','message'=>$access['message'] ?? 'غير مصرح بمشاهدة هذه الحصة.']); return; }
if ($row['storage_type']==='api_upload') $playback=['storage_type'=>'api_upload','video_url'=>$row['hls_url'] ?: '/api/videos/stream/'.$row['video_uuid'],'hls_url'=>$row['hls_url'] ?: '/api/videos/hls/'.$row['video_uuid'].'/index.m3u8']; if ($row['storage_type']==='api_upload') $playback=['storage_type'=>'api_upload','video_url'=>$row['hls_url'] ?: '/api/videos/stream/'.$row['video_uuid'],'hls_url'=>$row['hls_url'] ?: '/api/videos/hls/'.$row['video_uuid'].'/index.m3u8'];
elseif (!empty($row['bunny_video_id'])) $playback=array_merge(['storage_type'=>'bunny_stream'],VideoService::generateBunnySignedPlayback($row['bunny_video_id'],10800)); elseif (!empty($row['bunny_video_id'])) $playback=array_merge(['storage_type'=>'bunny_stream'],VideoService::generateBunnySignedPlayback($row['bunny_video_id'],10800));
elseif (!empty($row['ai_video_url'])) $playback=['storage_type'=>'direct_url','video_url'=>$row['ai_video_url'],'hls_url'=>$row['ai_video_url']]; elseif (!empty($row['ai_video_url'])) $playback=['storage_type'=>'direct_url','video_url'=>$row['ai_video_url'],'hls_url'=>$row['ai_video_url']];
elseif (!empty($row['hls_url'])) $playback=['storage_type'=>'cdn_hls','video_url'=>$row['hls_url'],'hls_url'=>$row['hls_url']];
else { $response->status(409)->json(['status'=>'error','message'=>'تخزين نسخة الفيديو غير جاهز.']); return; } else { $response->status(409)->json(['status'=>'error','message'=>'تخزين نسخة الفيديو غير جاهز.']); return; }
$response->json(['status'=>'success','data'=>['video_version_id'=>$row['video_version_id'],'submission_id'=>$row['submission_id'],'curriculum_lesson_id'=>$row['curriculum_lesson_id'],'lesson_id'=>(int)$row['lesson_id'],'title'=>$row['title'],'duration_seconds'=>(int)$row['duration_seconds'],'playback'=>$playback,'checkpoints'=>[]]]); $response->json(['status'=>'success','data'=>['video_version_id'=>$row['video_version_id'],'submission_id'=>$row['submission_id'],'curriculum_lesson_id'=>$row['curriculum_lesson_id'],'lesson_id'=>(int)$row['lesson_id'],'title'=>$row['title'],'duration_seconds'=>(int)$row['duration_seconds'],'playback'=>$playback,'checkpoints'=>[]]]);
} catch (\Throwable $e) { error_log('Version playback failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر تجهيز تشغيل نسخة الفيديو.']); } } catch (\Throwable $e) { error_log('Version playback failed: '.$e->getMessage()); $response->status(503)->json(['status'=>'unavailable','message'=>'تعذر تجهيز تشغيل نسخة الفيديو.']); }
@@ -80,19 +82,26 @@ class VideoController
public function listTeacherSubmissions(Request $request, Response $response): void public function listTeacherSubmissions(Request $request, Response $response): void
{ {
$teacherId = (int)$request->user_id; $teacherId = (int)$request->user_id;
if (!empty($request->identity_id)) {
$t = Database::selectOne("SELECT id FROM teachers WHERE identity_id = ? LIMIT 1", [$request->identity_id]);
if ($t) {
$teacherId = (int)$t['id'];
}
}
try { try {
$rows = Database::select( $rows = Database::select(
"SELECT ts.id AS submission_id, ts.uuid AS submission_uuid, ts.status AS submission_status, "SELECT ts.id AS submission_id, ts.uuid AS submission_uuid, ts.status AS submission_status,
ts.current_published_video_version_id, ts.created_at, ts.updated_at, ts.current_published_video_version_id, ts.created_at, ts.updated_at,
cl.id AS lesson_id, cl.uuid AS curriculum_lesson_uuid, cl.title AS lesson_title, cl.id AS lesson_id, cl.uuid AS curriculum_lesson_uuid, cl.title AS lesson_title,
cl.subject_key, cl.grade_level, cl.semester_key, cl.unit_key, cl.lesson_key, cl.subject_key, cl.grade_key, cl.grade_key AS grade_level, cl.semester_key, cl.unit_key, cl.lesson_key,
vv.id AS video_version_id, vv.uuid AS video_version_uuid, vv.version_number, vv.id AS video_version_id, vv.uuid AS video_version_uuid, vv.version_number,
vv.status AS video_status, vv.published_at, vv.created_at AS version_created_at vv.status AS video_status, vv.published_at, vv.created_at AS version_created_at
FROM teacher_submissions ts FROM teacher_submissions ts
JOIN curriculum_lessons cl ON cl.id = ts.curriculum_lesson_id JOIN curriculum_lessons cl ON cl.id = ts.curriculum_lesson_id
LEFT JOIN video_versions vv ON vv.teacher_submission_id = ts.id LEFT JOIN video_versions vv ON vv.teacher_submission_id = ts.id
WHERE ts.teacher_id = ? WHERE ts.teacher_id = ?
ORDER BY ts.updated_at DESC, vv.version_number DESC", ORDER BY ts.updated_at DESC, vv.version_number DESC",
[$teacherId] [$teacherId]
); );
@@ -152,13 +161,14 @@ class VideoController
$response->status(400)->json(['status' => 'error', 'message' => 'هوية الدرس غير صالحة.']); return; $response->status(400)->json(['status' => 'error', 'message' => 'هوية الدرس غير صالحة.']); return;
} }
try { try {
$lesson = Database::selectOne("SELECT id, uuid, title FROM curriculum_lessons WHERE uuid = ? AND source_status = 'approved' LIMIT 1", [$lessonUuid]); $lesson = Database::selectOne("SELECT id, uuid, title, grade_key FROM curriculum_lessons WHERE uuid = ? AND source_status = 'approved' LIMIT 1", [$lessonUuid]);
if (!$lesson) { $response->status(404)->json(['status'=>'error','message'=>'الدرس غير منشور.']); return; } if (!$lesson) { $response->status(404)->json(['status'=>'error','message'=>'الدرس غير منشور.']); return; }
$rows = Database::select( $rows = Database::select(
"SELECT vv.id, vv.uuid AS video_version_id, vv.source_lesson_id, ts.uuid AS submission_id, "SELECT vv.id, vv.uuid AS video_version_id, vv.source_lesson_id, ts.uuid AS submission_id,
c.grade_level, l.course_id, t.full_name AS teacher_name, COALESCE(pm.weighted_student_rating, 0) AS rating, cl.grade_key, c.grade_level AS course_grade_level, l.course_id, t.full_name AS teacher_name, COALESCE(pm.weighted_student_rating, 0) AS rating,
COALESCE(pm.total_reviews_count, 0) AS rating_count COALESCE(pm.total_reviews_count, 0) AS rating_count
FROM teacher_submissions ts FROM teacher_submissions ts
JOIN curriculum_lessons cl ON cl.id = ts.curriculum_lesson_id
JOIN video_versions vv ON vv.id = ts.current_published_video_version_id AND vv.status = 'published' JOIN video_versions vv ON vv.id = ts.current_published_video_version_id AND vv.status = 'published'
JOIN lessons l ON l.id = vv.source_lesson_id AND l.encoding_status = 'ready' JOIN lessons l ON l.id = vv.source_lesson_id AND l.encoding_status = 'ready'
JOIN courses c ON c.id = l.course_id JOIN courses c ON c.id = l.course_id
@@ -173,10 +183,11 @@ class VideoController
// legitimately have versions attached to different courses. // legitimately have versions attached to different courses.
$accessible = []; $accessible = [];
foreach ($rows as $row) { foreach ($rows as $row) {
$targetGrade = \App\Services\StudentAccessControlService::normalizeGrade($row['grade_key'] ?? $lesson['grade_key'] ?? 'grade_10');
$access = \App\Services\StudentAccessControlService::validateLessonAccess( $access = \App\Services\StudentAccessControlService::validateLessonAccess(
(int)$request->user_id, (int)$request->user_id,
$request->getHeader('x-national-id'), $request->getHeader('x-national-id'),
\App\Services\StudentAccessControlService::normalizeGrade($row['grade_level'] ?? 'grade_10'), $targetGrade,
(int)$row['course_id'], (int)$row['course_id'],
(int)$row['source_lesson_id'], (int)$row['source_lesson_id'],
false false
@@ -102,8 +102,10 @@ class StudentAccessControlService
// أما الطلبة المستقلون وحسابات التجربة والتعلم الحر، فيتم تحديث صفهم النشط تلقائياً وفق المحتوى المختار // أما الطلبة المستقلون وحسابات التجربة والتعلم الحر، فيتم تحديث صفهم النشط تلقائياً وفق المحتوى المختار
$isCohortLocked = !empty($student['is_school_sponsored']) || !empty($student['school_id']); $isCohortLocked = !empty($student['is_school_sponsored']) || !empty($student['school_id']);
if ($activeStudentGrade !== $normalizedTargetGrade) { if ($activeStudentGrade !== $normalizedTargetGrade) {
if (!$isCohortLocked && !empty($student['id']) && $allowSideEffects) { if (!$isCohortLocked && !empty($student['id'])) {
Database::query("UPDATE students SET grade_level = ? WHERE id = ?", [$normalizedTargetGrade, (int)$student['id']]); if ($allowSideEffects) {
Database::query("UPDATE students SET grade_level = ? WHERE id = ?", [$normalizedTargetGrade, (int)$student['id']]);
}
$activeStudentGrade = $normalizedTargetGrade; $activeStudentGrade = $normalizedTargetGrade;
} else { } else {
$targetGradeName = self::getGradeDisplayName($normalizedTargetGrade); $targetGradeName = self::getGradeDisplayName($normalizedTargetGrade);
@@ -37,9 +37,9 @@ final class TeacherSubmissionService {
return Database::execute( return Database::execute(
"UPDATE video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id "UPDATE video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id
SET vv.status='uploading', ts.status='uploading' SET vv.status='uploading', ts.status='uploading'
WHERE vv.uuid=? AND ts.teacher_id=? AND vv.status='draft'", WHERE vv.uuid=? AND ts.teacher_id=? AND vv.status IN ('draft', 'uploading')",
[$versionUuid, $teacherId] [$versionUuid, $teacherId]
) === 1; ) >= 1;
} }
/** Releases a reservation only when no media was accepted for review. */ /** Releases a reservation only when no media was accepted for review. */
public static function releaseUploadReservation(int $teacherId, string $versionUuid): void { public static function releaseUploadReservation(int $teacherId, string $versionUuid): void {
+2 -2
View File
@@ -76,7 +76,7 @@ $router->post('/api/curriculum/upload-pdf', [\App\Controllers\CurriculumControl
$router->get('/api/curriculum/upload-status', [\App\Controllers\CurriculumController::class, 'getUploadStatus'], $curriculumManagerMiddleware); $router->get('/api/curriculum/upload-status', [\App\Controllers\CurriculumController::class, 'getUploadStatus'], $curriculumManagerMiddleware);
$router->get('/api/curriculum/upload-log', [\App\Controllers\CurriculumController::class, 'getUploadLog'], $curriculumManagerMiddleware); $router->get('/api/curriculum/upload-log', [\App\Controllers\CurriculumController::class, 'getUploadLog'], $curriculumManagerMiddleware);
$router->get('/api/curriculum/tree', [\App\Controllers\CurriculumController::class, 'getTree']); $router->get('/api/curriculum/tree', [\App\Controllers\CurriculumController::class, 'getTree']);
$router->get('/api/curriculum/lesson', [\App\Controllers\CurriculumController::class, 'getLessonContent'], $curriculumManagerMiddleware); $router->get('/api/curriculum/lesson', [\App\Controllers\CurriculumController::class, 'getLessonContent'], [\App\Middlewares\AuthMiddleware::class]);
$router->post('/api/curriculum/save-lesson', [\App\Controllers\CurriculumController::class, 'saveLessonContent'], $curriculumManagerMiddleware); $router->post('/api/curriculum/save-lesson', [\App\Controllers\CurriculumController::class, 'saveLessonContent'], $curriculumManagerMiddleware);
$router->post('/api/curriculum/generate-ai-assets', [\App\Controllers\CurriculumController::class, 'generateAiAssets'], $curriculumManagerMiddleware); $router->post('/api/curriculum/generate-ai-assets', [\App\Controllers\CurriculumController::class, 'generateAiAssets'], $curriculumManagerMiddleware);
$router->post('/api/curriculum/bake-lab', [\App\Controllers\CurriculumController::class, 'bakeInteractiveLab'], $curriculumManagerMiddleware); $router->post('/api/curriculum/bake-lab', [\App\Controllers\CurriculumController::class, 'bakeInteractiveLab'], $curriculumManagerMiddleware);
@@ -91,7 +91,7 @@ $router->get('/api/curriculum/search', function ($request, $response) {
}); });
$router->get('/api/curriculum/simulations', [\App\Controllers\CurriculumController::class, 'listSimulations']); $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/simulations/{subject}/{simName}', [\App\Controllers\CurriculumController::class, 'getSimulation']);
$router->get('/api/curriculum/document', [\App\Controllers\CurriculumController::class, 'getDocumentContent'], $curriculumManagerMiddleware); $router->get('/api/curriculum/document', [\App\Controllers\CurriculumController::class, 'getDocumentContent'], [\App\Middlewares\AuthMiddleware::class]);
$router->get('/api/curriculum/assets/{assetId}', [\App\Controllers\CurriculumController::class, 'getPublishedAsset'], $studentMiddleware); $router->get('/api/curriculum/assets/{assetId}', [\App\Controllers\CurriculumController::class, 'getPublishedAsset'], $studentMiddleware);
$router->get('/api/curriculum/lessons/{lessonId}/videos', [\App\Controllers\VideoController::class, 'listPublishedLessonVideos'], $studentMiddleware); $router->get('/api/curriculum/lessons/{lessonId}/videos', [\App\Controllers\VideoController::class, 'listPublishedLessonVideos'], $studentMiddleware);
$router->get('/api/curriculum/lessons/{lessonId}/english-package', [\App\Controllers\CurriculumController::class, 'getPublishedEnglishPackage'], $studentMiddleware); $router->get('/api/curriculum/lessons/{lessonId}/english-package', [\App\Controllers\CurriculumController::class, 'getPublishedEnglishPackage'], $studentMiddleware);