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:
@@ -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
|
||||||
|
|||||||
+935
@@ -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;
|
||||||
|
}
|
||||||
+1122
File diff suppressed because it is too large
Load Diff
+74
-24
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
+574
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user