diff --git a/apps/student_app/lib/core/theme/app_colors.dart b/apps/student_app/lib/core/theme/app_colors.dart index a0dae0e..14a5fe5 100644 --- a/apps/student_app/lib/core/theme/app_colors.dart +++ b/apps/student_app/lib/core/theme/app_colors.dart @@ -26,6 +26,7 @@ class AppColors { static const Color whatsappGreen = Color(0xFF25D366); static const Color errorRed = Color(0xFFEF4444); static const Color emeraldGreen = teacherEmerald; + static const Color emeraldPrimary = teacherEmerald; static const Color crimsonRed = errorRed; // Text Colors diff --git a/apps/student_app/lib/presentation/screens/curriculum/biology_interactive_lab_view.dart b/apps/student_app/lib/presentation/screens/curriculum/biology_interactive_lab_view.dart new file mode 100644 index 0000000..cea1ac1 --- /dev/null +++ b/apps/student_app/lib/presentation/screens/curriculum/biology_interactive_lab_view.dart @@ -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 createState() => _BiologyInteractiveLabViewState(); +} + +class _BiologyInteractiveLabViewState extends State + 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> _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> _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; +} diff --git a/apps/student_app/lib/presentation/screens/curriculum/chemistry_interactive_lab_view.dart b/apps/student_app/lib/presentation/screens/curriculum/chemistry_interactive_lab_view.dart new file mode 100644 index 0000000..0e224d5 --- /dev/null +++ b/apps/student_app/lib/presentation/screens/curriculum/chemistry_interactive_lab_view.dart @@ -0,0 +1,1122 @@ +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 CHEMISTRY VIRTUAL LABORATORY (120 FPS) +/// ============================================================================== +/// +/// مختبر الكيمياء التفاعلي الشامل للصف العاشر الأساسي (المملكة الأردنية الهاشمية): +/// الوحدة الأولى: بنية الذرة والطيوف الذرية (Bohr Model & Atomic Spectra) +/// - محاكي نظرية بور لذرة الهيدروجين وانتقالات الإلكترون (ΔE = RH(1/n1² - 1/n2²)) +/// - سلاسل طيف الانبعاث (بالمر، ليمان، باشن) والطول الموجي للفوتونات +/// - مطياف الطيف الخطي المنفصل مقابل الطيف المتصل +/// - تجربة اختبار اللهب لأملاح العناصر (Flame Test Lab) +/// - التوزيع الإلكتروني وقواعد التعبئة (Aufbau & Hund's Rule) +class ChemistryInteractiveLabView extends StatefulWidget { + const ChemistryInteractiveLabView({super.key}); + + @override + State createState() => _ChemistryInteractiveLabViewState(); +} + +class _ChemistryInteractiveLabViewState extends State + with TickerProviderStateMixin { + int _selectedTab = 0; // 0 = Bohr Atom, 1 = Flame Test, 2 = Electron Config + + // --------------------------------------------------------------------------- + // BOHR ATOM STATE + // --------------------------------------------------------------------------- + int _nInitial = 3; + int _nFinal = 2; + late final AnimationController _bohrElectronAnim; + late final AnimationController _photonEmissionAnim; + bool _isPhotonEmitting = false; + double _photonProgress = 0.0; + + // Constants + static const double _rydberg = 2.18e-18; // Joules + static const double _planck = 6.626e-34; // J*s + static const double _c = 3.0e8; // m/s + + // --------------------------------------------------------------------------- + // FLAME TEST STATE + // --------------------------------------------------------------------------- + int _selectedElementIndex = 0; + bool _isLoopInFlame = false; + late final AnimationController _flameFlickerAnim; + + final List> _flameElements = [ + { + 'name': 'الصوديوم (Na)', + 'formula': 'NaCl', + 'flameColor': const Color(0xFFFFB703), + 'glowColor': const Color(0xFFFFD166), + 'wavelength': '589 nm', + 'colorDesc': 'أصفر ذهبي ساطع', + 'explanation': 'انتقال إلكترون التكافؤ 3p إلى 3s يطلق فوتوناً بطول موجي 589 نانومتر.', + }, + { + 'name': 'البوتاسيوم (K)', + 'formula': 'KCl', + 'flameColor': const Color(0xFFC77DFF), + 'glowColor': const Color(0xFFE0AAFF), + 'wavelength': '766 nm', + 'colorDesc': 'بنفسجي ليلكي فاتح', + 'explanation': 'انتقال إلكترون التكافؤ 4p إلى 4s يعطي الطيف البنفسجي المميز لمركبات البوتاسيوم.', + }, + { + 'name': 'النحاس (Cu)', + 'formula': 'CuCl₂', + 'flameColor': const Color(0xFF06D6A0), + 'glowColor': const Color(0xFF2EC4B6), + 'wavelength': '510 nm', + 'colorDesc': 'أخضر زمردي مائل للزرقة', + 'explanation': 'الإثارة الإلكترونية لأيون النحاس Cu²⁺ تطلق طيفاً مميزاً في النطاق الأخضر المزرق.', + }, + { + 'name': 'الكالسيوم (Ca)', + 'formula': 'CaCl₂', + 'flameColor': const Color(0xFFFF5400), + 'glowColor': const Color(0xFFFF7900), + 'wavelength': '622 nm', + 'colorDesc': 'برتقالي مائل للحمرة (آجري)', + 'explanation': 'انبعاث خطوط الطيف لأيون الكالسيوم Ca²⁺ بين المدارات الجزئية في النطاق البرتقالي المحمر.', + }, + { + 'name': 'الليثيوم (Li)', + 'formula': 'LiCl', + 'flameColor': const Color(0xFFE63946), + 'glowColor': const Color(0xFFFF4D6D), + 'wavelength': '670 nm', + 'colorDesc': 'أحمر قرمزي ناصع', + 'explanation': 'انتقال 2p إلى 2s في ذرة الليثيوم يولد إشعاعاً أحمر شديد الوضوح عند 670 نانومتر.', + }, + { + 'name': 'الباريوم (Ba)', + 'formula': 'BaCl₂', + 'flameColor': const Color(0xFF9EF01A), + 'glowColor': const Color(0xFFCCFF33), + 'wavelength': '553 nm', + 'colorDesc': 'أخضر تفاحي باهت', + 'explanation': 'انتقالات الطيف الذري للباريوم تقع في نطاق الضوء المرئي بين الأصفر والأخضر.', + }, + ]; + + // --------------------------------------------------------------------------- + // ELECTRON CONFIG STATE + // --------------------------------------------------------------------------- + int _atomicNumber = 11; // Sodium by default + + @override + void initState() { + super.initState(); + _bohrElectronAnim = AnimationController( + vsync: this, + duration: const Duration(seconds: 4), + )..repeat(); + + _photonEmissionAnim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1600), + ); + + _photonEmissionAnim.addListener(() { + setState(() { + _photonProgress = _photonEmissionAnim.value; + }); + }); + + _photonEmissionAnim.addStatusListener((status) { + if (status == AnimationStatus.completed) { + setState(() { + _isPhotonEmitting = false; + }); + } + }); + + _flameFlickerAnim = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 700), + )..repeat(reverse: true); + } + + @override + void dispose() { + _bohrElectronAnim.dispose(); + _photonEmissionAnim.dispose(); + _flameFlickerAnim.dispose(); + super.dispose(); + } + + // Energy & Wavelength calculations + double get _deltaEnergyJoules { + if (_nInitial == _nFinal) return 0.0; + return (_rydberg * (1.0 / (_nFinal * _nFinal) - 1.0 / (_nInitial * _nInitial))).abs(); + } + + double get _deltaEnergyEv { + return _deltaEnergyJoules / 1.602e-19; + } + + double get _wavelengthNm { + if (_deltaEnergyJoules == 0) return 0.0; + final wlMeters = (_planck * _c) / _deltaEnergyJoules; + return wlMeters * 1e9; + } + + Color get _transitionColor { + final wl = _wavelengthNm; + if (_nFinal == 1) return const Color(0xFF8A2BE2); // UV / Lyman + if (_nFinal == 3) return const Color(0xFFE63946); // IR / Paschen + // Balmer series (nFinal == 2) + if ((wl - 656.3).abs() < 15) return const Color(0xFFFF2A2A); // H-alpha Red + if ((wl - 486.1).abs() < 15) return const Color(0xFF00E5FF); // H-beta Cyan + if ((wl - 434.0).abs() < 15) return const Color(0xFF2979FF); // H-gamma Blue + if ((wl - 410.2).abs() < 15) return const Color(0xFF9C27B0); // H-delta Violet + if (wl < 400) return const Color(0xFF7B1FA2); + if (wl > 700) return const Color(0xFFD32F2F); + return const Color(0xFF00E676); + } + + String get _seriesName { + final minN = math.min(_nInitial, _nFinal); + if (minN == 1) return 'سلسلة ليمان (Lyman Series) — فوق بنفسجية (UV)'; + if (minN == 2) return 'سلسلة بالمر (Balmer Series) — طيف مرئي (Visible)'; + if (minN == 3) return 'سلسلة باشن (Paschen Series) — تحت حمراء (IR)'; + return 'سلسلة براكيت / بفوند — تحت حمراء بعيدة'; + } + + void _triggerElectronTransition() { + setState(() { + _isPhotonEmitting = true; + }); + _photonEmissionAnim.forward(from: 0.0); + } + + @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) _buildBohrAtomModule(), + if (_selectedTab == 1) _buildFlameTestModule(), + if (_selectedTab == 2) _buildElectronConfigModule(), + 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.sparkles), + ), + const SizedBox(width: 8), + Expanded( + child: _buildTabItem(1, 'كشف اللهب', CupertinoIcons.flame_fill), + ), + const SizedBox(width: 8), + Expanded( + child: _buildTabItem(2, 'التوزيع الإلكتروني', CupertinoIcons.circle_grid_hex_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. BOHR ATOM MODULE + // =========================================================================== + Widget _buildBohrAtomModule() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Orbit Canvas + Container( + height: 280, + decoration: BoxDecoration( + color: const Color(0xFF0D1322), + borderRadius: BorderRadius.circular(18), + border: Border.all(color: Colors.white.withOpacity(0.09)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.4), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(18), + child: AnimatedBuilder( + animation: Listenable.merge([_bohrElectronAnim, _photonEmissionAnim]), + builder: (context, _) { + return CustomPaint( + painter: _BohrAtomPainter( + nInitial: _nInitial, + nFinal: _nFinal, + electronPhase: _bohrElectronAnim.value * 2 * math.pi, + photonProgress: _photonProgress, + isEmitting: _isPhotonEmitting, + photonColor: _transitionColor, + ), + size: Size.infinite, + ); + }, + ), + ), + ), + const SizedBox(height: 14), + + // Transition selector controls + LuxuryCard( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'تحديد قفزة الإلكترون بين المدارات:', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.white), + ), + CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + color: AppColors.emeraldPrimary, + borderRadius: BorderRadius.circular(8), + onPressed: _nInitial != _nFinal ? _triggerElectronTransition : null, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(CupertinoIcons.play_arrow_solid, size: 14, color: Colors.black), + SizedBox(width: 4), + Text('محاكاة الانتقال ⚡', style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.black)), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildOrbitalDropdown( + label: 'المدار الابتدائي (ni)', + value: _nInitial, + onChanged: (v) { + setState(() => _nInitial = v); + }, + ), + ), + const SizedBox(width: 12), + const Icon(CupertinoIcons.arrow_left, color: Colors.white54, size: 18), + const SizedBox(width: 12), + Expanded( + child: _buildOrbitalDropdown( + label: 'المدار النهائي (nf)', + value: _nFinal, + onChanged: (v) { + setState(() => _nFinal = v); + }, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 14), + + // Calculations & Results Card + LuxuryCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: _transitionColor.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: Icon(CupertinoIcons.waveform_path, color: _transitionColor, size: 20), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _nInitial > _nFinal + ? 'طيف انبعاث خطي (Emission Photon)' + : _nInitial < _nFinal + ? 'طيف امتصاص طاقة (Absorption)' + : 'حالة استقرار في نفس المدار', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.white), + ), + Text( + _seriesName, + style: TextStyle(fontSize: 11, color: Colors.white.withOpacity(0.7)), + ), + ], + ), + ), + ], + ), + const Divider(color: Colors.white12, height: 24), + _buildFormulaRow( + formula: 'ΔE = RH (1/n₁² - 1/n₂²)', + result: '${_deltaEnergyJoules.toStringAsExponential(3)} J', + subResult: '(${_deltaEnergyEv.toStringAsFixed(2)} eV)', + ), + const SizedBox(height: 10), + _buildFormulaRow( + formula: 'λ = (h · c) / ΔE', + result: _wavelengthNm > 0 ? '${_wavelengthNm.toStringAsFixed(1)} nm' : '—', + colorBadge: _transitionColor, + ), + const SizedBox(height: 12), + + // Hydrogen Spectrometer bar + const Text('شريط المطياف لخطوط طيف الهيدروجين المرئي (Balmer Series):', style: TextStyle(fontSize: 12, color: Colors.white70)), + const SizedBox(height: 8), + _buildBalmerSpectrometer(), + ], + ), + ), + ], + ); + } + + Widget _buildOrbitalDropdown({required String label, required int value, required ValueChanged onChanged}) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.04), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.white12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(fontSize: 11, color: Colors.white60)), + const SizedBox(height: 4), + DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + isDense: true, + isExpanded: true, + dropdownColor: const Color(0xFF182236), + style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold), + items: [1, 2, 3, 4, 5, 6].map((n) { + return DropdownMenuItem( + value: n, + child: Text('المدار n = $n'), + ); + }).toList(), + onChanged: (v) { + if (v != null) onChanged(v); + }, + ), + ), + ], + ), + ); + } + + Widget _buildFormulaRow({required String formula, required String result, String? subResult, Color? colorBadge}) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(formula, style: const TextStyle(fontFamily: 'Courier', fontSize: 13, color: Colors.white70)), + Row( + children: [ + if (colorBadge != null) ...[ + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: colorBadge, + shape: BoxShape.circle, + boxShadow: [BoxShadow(color: colorBadge.withOpacity(0.8), blurRadius: 6)], + ), + ), + const SizedBox(width: 6), + ], + Text( + result, + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.white), + ), + if (subResult != null) ...[ + const SizedBox(width: 4), + Text(subResult, style: const TextStyle(fontSize: 11, color: Colors.white60)), + ], + ], + ), + ], + ); + } + + Widget _buildBalmerSpectrometer() { + return Container( + height: 48, + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.white24), + ), + child: Stack( + alignment: Alignment.center, + children: [ + // Background subtle rainbow + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + gradient: const LinearGradient( + colors: [ + Color(0xFF3B0066), + Color(0xFF001B66), + Color(0xFF004466), + Color(0xFF006633), + Color(0xFF666600), + Color(0xFF662200), + Color(0xFF550000), + ], + ), + ), + ), + // Emission lines: 410nm, 434nm, 486nm, 656nm + _buildBalmerLine(name: 'Hδ', wl: 410.2, posFactor: 0.10, color: const Color(0xFF9C27B0), ni: 6), + _buildBalmerLine(name: 'Hγ', wl: 434.0, posFactor: 0.22, color: const Color(0xFF2979FF), ni: 5), + _buildBalmerLine(name: 'Hβ', wl: 486.1, posFactor: 0.44, color: const Color(0xFF00E5FF), ni: 4), + _buildBalmerLine(name: 'Hα', wl: 656.3, posFactor: 0.88, color: const Color(0xFFFF2A2A), ni: 3), + ], + ), + ); + } + + Widget _buildBalmerLine({ + required String name, + required double wl, + required double posFactor, + required Color color, + required int ni, + }) { + final isSelected = (_nFinal == 2 && _nInitial == ni); + return Positioned( + left: posFactor * 280, + top: 0, + bottom: 0, + child: GestureDetector( + onTap: () { + setState(() { + _nInitial = ni; + _nFinal = 2; + }); + _triggerElectronTransition(); + }, + child: Container( + width: isSelected ? 4 : 2, + color: color, + child: Align( + alignment: Alignment.bottomCenter, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 2), + color: Colors.black.withOpacity(0.7), + child: Text( + '$name\n${wl.toInt()}', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 8, + fontWeight: FontWeight.bold, + color: isSelected ? Colors.white : color, + ), + ), + ), + ), + ), + ), + ); + } + + // =========================================================================== + // 2. FLAME TEST MODULE + // =========================================================================== + Widget _buildFlameTestModule() { + final cur = _flameElements[_selectedElementIndex]; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Flame Burner Canvas + Container( + height: 260, + decoration: BoxDecoration( + color: const Color(0xFF070B14), + borderRadius: BorderRadius.circular(18), + border: Border.all(color: Colors.white.withOpacity(0.09)), + boxShadow: [ + BoxShadow( + color: (cur['flameColor'] as Color).withOpacity(0.15), + blurRadius: 24, + spreadRadius: 2, + ), + ], + ), + child: AnimatedBuilder( + animation: _flameFlickerAnim, + builder: (context, _) { + return CustomPaint( + painter: _FlameBurnerPainter( + flicker: _flameFlickerAnim.value, + hasSample: _isLoopInFlame, + flameColor: cur['flameColor'] as Color, + glowColor: cur['glowColor'] as Color, + ), + size: Size.infinite, + ); + }, + ), + ), + const SizedBox(height: 14), + + // Controls: Insert platinum loop toggle + LuxuryCard( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('عينة الملح المراد فحصها:', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.white)), + CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + color: _isLoopInFlame ? const Color(0xFFEF4444) : AppColors.emeraldPrimary, + borderRadius: BorderRadius.circular(8), + onPressed: () { + setState(() { + _isLoopInFlame = !_isLoopInFlame; + }); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(_isLoopInFlame ? CupertinoIcons.clear_circled_solid : CupertinoIcons.flame_fill, size: 14, color: Colors.black), + const SizedBox(width: 4), + Text( + _isLoopInFlame ? 'سحب سلك البلاتين 🛑' : 'إدخال السلك في اللهب 🔥', + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: Colors.black), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + + // Salt selection pills + Wrap( + spacing: 8, + runSpacing: 8, + children: List.generate(_flameElements.length, (idx) { + final e = _flameElements[idx]; + final isSel = idx == _selectedElementIndex; + return CupertinoButton( + padding: EdgeInsets.zero, + onPressed: () { + setState(() { + _selectedElementIndex = idx; + }); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: isSel ? (e['flameColor'] as Color).withOpacity(0.25) : Colors.white.withOpacity(0.04), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isSel ? (e['flameColor'] as Color) : Colors.white12, + width: isSel ? 1.5 : 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration(color: e['flameColor'] as Color, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + Text( + e['name'] as String, + style: TextStyle( + fontSize: 12, + fontWeight: isSel ? FontWeight.bold : FontWeight.normal, + color: isSel ? Colors.white : Colors.white70, + ), + ), + ], + ), + ), + ); + }), + ), + ], + ), + ), + const SizedBox(height: 14), + + // Scientific Explanation Card + LuxuryCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 18, + height: 18, + decoration: BoxDecoration(color: cur['flameColor'] as Color, shape: BoxShape.circle), + ), + const SizedBox(width: 8), + Text( + 'نتائج فحص ${cur['name']} (${cur['formula']}):', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.white), + ), + ], + ), + const SizedBox(height: 10), + Text( + '• لون اللهب الملاحظ: ${cur['colorDesc']}', + style: const TextStyle(fontSize: 13, color: Colors.white), + ), + const SizedBox(height: 4), + Text( + '• الطول الموجي المهيمن: ${cur['wavelength']}', + style: TextStyle(fontSize: 13, color: cur['flameColor'] as Color, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 6), + Text( + '• التفسير العلمي: ${cur['explanation']}', + style: const TextStyle(fontSize: 12, color: Colors.white70, height: 1.4), + ), + ], + ), + ), + ], + ); + } + + // =========================================================================== + // 3. ELECTRON CONFIGURATION MODULE + // =========================================================================== + Widget _buildElectronConfigModule() { + final configData = _getElectronicConfiguration(_atomicNumber); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Element Selector + LuxuryCard( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('اختر العنصر الكيميائي (Z = 1 إلى 20):', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.white)), + const SizedBox(height: 10), + Row( + children: [ + Text('Z = $_atomicNumber', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: AppColors.emeraldPrimary)), + const SizedBox(width: 12), + Expanded( + child: CupertinoSlider( + value: _atomicNumber.toDouble(), + min: 1, + max: 20, + divisions: 19, + activeColor: AppColors.emeraldPrimary, + onChanged: (v) { + setState(() => _atomicNumber = v.round()); + }, + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 14), + + // Element Specs Card + LuxuryCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${configData['arabicName']} (${configData['symbol']})', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: AppColors.emeraldPrimary.withOpacity(0.2), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.emeraldPrimary.withOpacity(0.5)), + ), + child: Text('المجموعة: ${configData['group']} | الدورة: ${configData['period']}', style: const TextStyle(fontSize: 12, color: AppColors.emeraldPrimary, fontWeight: FontWeight.bold)), + ), + ], + ), + const Divider(color: Colors.white12, height: 20), + const Text('التوزيع الإلكتروني الكامل (قاعدة أوفباو):', style: TextStyle(fontSize: 12, color: Colors.white60)), + const SizedBox(height: 6), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.black.withOpacity(0.4), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: Colors.white12), + ), + child: Text( + configData['configString'] as String, + style: const TextStyle(fontFamily: 'Courier', fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF00E5FF)), + ), + ), + const SizedBox(height: 12), + Text( + 'بدلالة الغاز النبيل: ${configData['nobleConfig']}', + style: const TextStyle(fontSize: 13, color: Colors.white70), + ), + const SizedBox(height: 6), + Text( + 'عدد إلكترونات التكافؤ (Valence Electrons): ${configData['valence']}', + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.amberAccent), + ), + ], + ), + ), + ], + ); + } + + Map _getElectronicConfiguration(int z) { + const elements = { + 1: {'sym': 'H', 'name': 'الهيدروجين', 'group': '1', 'period': '1', 'cfg': '1s¹', 'noble': '1s¹', 'v': 1}, + 2: {'sym': 'He', 'name': 'الهيليوم', 'group': '8A (18)', 'period': '1', 'cfg': '1s²', 'noble': '[He]', 'v': 2}, + 3: {'sym': 'Li', 'name': 'الليثيوم', 'group': '1A', 'period': '2', 'cfg': '1s² 2s¹', 'noble': '[He] 2s¹', 'v': 1}, + 4: {'sym': 'Be', 'name': 'البريليوم', 'group': '2A', 'period': '2', 'cfg': '1s² 2s²', 'noble': '[He] 2s²', 'v': 2}, + 5: {'sym': 'B', 'name': 'البورون', 'group': '3A', 'period': '2', 'cfg': '1s² 2s² 2p¹', 'noble': '[He] 2s² 2p¹', 'v': 3}, + 6: {'sym': 'C', 'name': 'الكربون', 'group': '4A', 'period': '2', 'cfg': '1s² 2s² 2p²', 'noble': '[He] 2s² 2p²', 'v': 4}, + 7: {'sym': 'N', 'name': 'النيتروجين', 'group': '5A', 'period': '2', 'cfg': '1s² 2s² 2p³', 'noble': '[He] 2s² 2p³', 'v': 5}, + 8: {'sym': 'O', 'name': 'الأكسجين', 'group': '6A', 'period': '2', 'cfg': '1s² 2s² 2p⁴', 'noble': '[He] 2s² 2p⁴', 'v': 6}, + 9: {'sym': 'F', 'name': 'الفلور', 'group': '7A', 'period': '2', 'cfg': '1s² 2s² 2p⁵', 'noble': '[He] 2s² 2p⁵', 'v': 7}, + 10: {'sym': 'Ne', 'name': 'النيون', 'group': '8A', 'period': '2', 'cfg': '1s² 2s² 2p⁶', 'noble': '[Ne]', 'v': 8}, + 11: {'sym': 'Na', 'name': 'الصوديوم', 'group': '1A', 'period': '3', 'cfg': '1s² 2s² 2p⁶ 3s¹', 'noble': '[Ne] 3s¹', 'v': 1}, + 12: {'sym': 'Mg', 'name': 'المغنيسيوم', 'group': '2A', 'period': '3', 'cfg': '1s² 2s² 2p⁶ 3s²', 'noble': '[Ne] 3s²', 'v': 2}, + 13: {'sym': 'Al', 'name': 'الألمنيوم', 'group': '3A', 'period': '3', 'cfg': '1s² 2s² 2p⁶ 3s² 3p¹', 'noble': '[Ne] 3s² 3p¹', 'v': 3}, + 14: {'sym': 'Si', 'name': 'السيليكون', 'group': '4A', 'period': '3', 'cfg': '1s² 2s² 2p⁶ 3s² 3p²', 'noble': '[Ne] 3s² 3p²', 'v': 4}, + 15: {'sym': 'P', 'name': 'الفوسفور', 'group': '5A', 'period': '3', 'cfg': '1s² 2s² 2p⁶ 3s² 3p³', 'noble': '[Ne] 3s² 3p³', 'v': 5}, + 16: {'sym': 'S', 'name': 'الكبريت', 'group': '6A', 'period': '3', 'cfg': '1s² 2s² 2p⁶ 3s² 3p⁴', 'noble': '[Ne] 3s² 3p⁴', 'v': 6}, + 17: {'sym': 'Cl', 'name': 'الكلور', 'group': '7A', 'period': '3', 'cfg': '1s² 2s² 2p⁶ 3s² 3p⁵', 'noble': '[Ne] 3s² 3p⁵', 'v': 7}, + 18: {'sym': 'Ar', 'name': 'الأرجون', 'group': '8A', 'period': '3', 'cfg': '1s² 2s² 2p⁶ 3s² 3p⁶', 'noble': '[Ar]', 'v': 8}, + 19: {'sym': 'K', 'name': 'البوتاسيوم', 'group': '1A', 'period': '4', 'cfg': '1s² 2s² 2p⁶ 3s² 3p⁶ 4s¹', 'noble': '[Ar] 4s¹', 'v': 1}, + 20: {'sym': 'Ca', 'name': 'الكالسيوم', 'group': '2A', 'period': '4', 'cfg': '1s² 2s² 2p⁶ 3s² 3p⁶ 4s²', 'noble': '[Ar] 4s²', 'v': 2}, + }; + final e = elements[z] ?? elements[1]!; + return { + 'symbol': e['sym'], + 'arabicName': e['name'], + 'group': e['group'], + 'period': e['period'], + 'configString': e['cfg'], + 'nobleConfig': e['noble'], + 'valence': e['v'], + }; + } +} + +// ============================================================================== +// CUSTOM PAINTER 1: BOHR ATOM & PHOTON EMISSION +// ============================================================================== +class _BohrAtomPainter extends CustomPainter { + final int nInitial; + final int nFinal; + final double electronPhase; + final double photonProgress; + final bool isEmitting; + final Color photonColor; + + _BohrAtomPainter({ + required this.nInitial, + required this.nFinal, + required this.electronPhase, + required this.photonProgress, + required this.isEmitting, + required this.photonColor, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + const maxRadius = 118.0; + const minRadius = 24.0; + + // 1. Draw Nucleus + final nucPaint = Paint() + ..shader = RadialGradient( + colors: [const Color(0xFFFF453A), const Color(0xFFFF9F0A).withOpacity(0.8)], + ).createShader(Rect.fromCircle(center: center, radius: 14)); + canvas.drawCircle(center, 12, nucPaint); + + final nucText = TextPainter( + text: const TextSpan(text: '+1e', style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)), + textDirection: TextDirection.ltr, + )..layout(); + nucText.paint(canvas, center - Offset(nucText.width / 2, nucText.height / 2)); + + // 2. Draw 6 Bohr Orbits + final orbitPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.0; + + for (int n = 1; n <= 6; n++) { + final r = minRadius + (n - 1) * ((maxRadius - minRadius) / 5); + final isInit = (n == nInitial); + final isFin = (n == nFinal); + + if (isInit || isFin) { + orbitPaint.color = (isFin ? const Color(0xFF00E5FF) : Colors.amberAccent).withOpacity(0.8); + orbitPaint.strokeWidth = 1.6; + } else { + orbitPaint.color = Colors.white.withOpacity(0.12); + orbitPaint.strokeWidth = 0.8; + } + + canvas.drawCircle(center, r, orbitPaint); + + // Label n=x + final labelPainter = TextPainter( + text: TextSpan(text: 'n=$n', style: TextStyle(color: Colors.white38, fontSize: 8)), + textDirection: TextDirection.ltr, + )..layout(); + labelPainter.paint(canvas, Offset(center.dx + r - 8, center.dy - 12)); + } + + // 3. Compute Current Electron Orbit radius + final rInit = minRadius + (nInitial - 1) * ((maxRadius - minRadius) / 5); + final rFin = minRadius + (nFinal - 1) * ((maxRadius - minRadius) / 5); + double currentR = rInit; + if (isEmitting) { + currentR = rInit + (rFin - rInit) * photonProgress; + } + + final electronPos = Offset( + center.dx + currentR * math.cos(electronPhase), + center.dy + currentR * math.sin(electronPhase), + ); + + // Draw Glowing Electron + final glowPaint = Paint() + ..color = const Color(0xFF00E5FF).withOpacity(0.4) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 8); + canvas.drawCircle(electronPos, 9, glowPaint); + + final elecPaint = Paint()..color = const Color(0xFF00E5FF); + canvas.drawCircle(electronPos, 5, elecPaint); + + // 4. Emitted Photon Packet Wave + if (isEmitting && photonProgress > 0.05) { + final waveDistance = photonProgress * (size.width * 0.45); + final waveAngle = electronPhase + math.pi / 2; + final waveStart = electronPos; + final waveCenter = Offset( + waveStart.dx + waveDistance * math.cos(waveAngle), + waveStart.dy + waveDistance * math.sin(waveAngle), + ); + + final photonPaint = Paint() + ..color = photonColor + ..strokeWidth = 2.4 + ..style = PaintingStyle.stroke; + + final wavePath = Path(); + for (double t = -18; t <= 18; t += 2) { + final perp = math.sin(t * 0.4) * 7.0 * (1.0 - (t.abs() / 18.0)); + final ptX = waveCenter.dx + t * math.cos(waveAngle) - perp * math.sin(waveAngle); + final ptY = waveCenter.dy + t * math.sin(waveAngle) + perp * math.cos(waveAngle); + if (t == -18) { + wavePath.moveTo(ptX, ptY); + } else { + wavePath.lineTo(ptX, ptY); + } + } + canvas.drawPath(wavePath, photonPaint); + } + } + + @override + bool shouldRepaint(covariant _BohrAtomPainter oldDelegate) => true; +} + +// ============================================================================== +// CUSTOM PAINTER 2: FLAME BURNER WITH REAL CHEMICAL COLORING +// ============================================================================== +class _FlameBurnerPainter extends CustomPainter { + final double flicker; + final bool hasSample; + final Color flameColor; + final Color glowColor; + + _FlameBurnerPainter({ + required this.flicker, + required this.hasSample, + required this.flameColor, + required this.glowColor, + }); + + @override + void paint(Canvas canvas, Size size) { + final cx = size.width / 2; + final baseH = size.height * 0.78; + + // 1. Burner Base & Metal Barrel + final metalPaint = Paint()..color = const Color(0xFF4A5568); + canvas.drawRect(Rect.fromCenter(center: Offset(cx, baseH + 20), width: 44, height: 40), metalPaint); + final nozzlePaint = Paint()..color = const Color(0xFF718096); + canvas.drawRect(Rect.fromCenter(center: Offset(cx, baseH), width: 28, height: 10), nozzlePaint); + + // 2. Base Bunsen Flame (Blue Cone) + final blueFlamePath = Path() + ..moveTo(cx - 12, baseH) + ..quadraticBezierTo(cx - 14 - flicker * 2, baseH - 45, cx, baseH - 85 - flicker * 10) + ..quadraticBezierTo(cx + 14 + flicker * 2, baseH - 45, cx + 12, baseH) + ..close(); + + final bluePaint = Paint() + ..shader = const LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [Color(0xFF1D4ED8), Color(0xFF60A5FA), Colors.transparent], + ).createShader(Rect.fromLTWH(cx - 20, baseH - 95, 40, 95)); + canvas.drawPath(blueFlamePath, bluePaint); + + // 3. Chemical Flame Body (when platinum loop inserted or active) + if (hasSample) { + final activeFlamePath = Path() + ..moveTo(cx - 18, baseH - 10) + ..cubicTo( + cx - 28 - flicker * 4, + baseH - 70, + cx - 15, + baseH - 140 - flicker * 15, + cx, + baseH - 180 - flicker * 20, + ) + ..cubicTo( + cx + 15, + baseH - 140 - flicker * 15, + cx + 28 + flicker * 4, + baseH - 70, + cx + 18, + baseH - 10, + ) + ..close(); + + // Outer glow + final chemGlow = Paint() + ..color = glowColor.withOpacity(0.5) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 16); + canvas.drawPath(activeFlamePath, chemGlow); + + final chemPaint = Paint() + ..shader = LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [flameColor.withOpacity(0.9), glowColor, Colors.white.withOpacity(0.8)], + ).createShader(Rect.fromLTWH(cx - 35, baseH - 200, 70, 200)); + canvas.drawPath(activeFlamePath, chemPaint); + + // Platinum wire entering from left + final wirePaint = Paint() + ..color = const Color(0xFFE2E8F0) + ..strokeWidth = 2.5 + ..style = PaintingStyle.stroke; + final wirePath = Path() + ..moveTo(cx - 90, baseH - 35) + ..lineTo(cx - 4, baseH - 45) + ..addOval(Rect.fromCircle(center: Offset(cx, baseH - 45), radius: 5)); + canvas.drawPath(wirePath, wirePaint); + } + } + + @override + bool shouldRepaint(covariant _FlameBurnerPainter oldDelegate) => true; +} diff --git a/apps/student_app/lib/presentation/screens/curriculum/curriculum_document_viewer_screen.dart b/apps/student_app/lib/presentation/screens/curriculum/curriculum_document_viewer_screen.dart index 6c636a1..84b287d 100644 --- a/apps/student_app/lib/presentation/screens/curriculum/curriculum_document_viewer_screen.dart +++ b/apps/student_app/lib/presentation/screens/curriculum/curriculum_document_viewer_screen.dart @@ -124,6 +124,27 @@ class _CurriculumDocumentViewerScreenState extends State _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 (mounted) { setState(() { @@ -134,7 +155,7 @@ class _CurriculumDocumentViewerScreenState extends State createState() => _EarthSciencesInteractiveLabViewState(); +} + +class _EarthSciencesInteractiveLabViewState extends State { + 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> _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> _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> _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)), + ], + ), + ); + } +} diff --git a/apps/student_app/lib/presentation/screens/curriculum/subject_hub_screen.dart b/apps/student_app/lib/presentation/screens/curriculum/subject_hub_screen.dart index c4f13fb..6128c1c 100644 --- a/apps/student_app/lib/presentation/screens/curriculum/subject_hub_screen.dart +++ b/apps/student_app/lib/presentation/screens/curriculum/subject_hub_screen.dart @@ -22,6 +22,9 @@ import '../player/socratic_video_player_screen.dart'; import '../exams/adaptive_exam_screen.dart'; import 'curriculum_document_viewer_screen.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 'islamic_interactive_lab_view.dart'; import 'history_interactive_timeline_view.dart'; @@ -41,7 +44,8 @@ class SubjectHubScreen extends StatefulWidget { State createState() => _SubjectHubScreenState(); } -class _SubjectHubScreenState extends State with SingleTickerProviderStateMixin { +class _SubjectHubScreenState extends State + with SingleTickerProviderStateMixin { late TabController _tabController; late Future> _examsFuture; @@ -54,13 +58,25 @@ class _SubjectHubScreenState extends State with SingleTickerPr widget.subject.title.contains('إنجليز') || widget.subject.title.contains('لغة إنجليزية'); - bool get _isScienceSubject => - widget.subject.id.contains('physic') || - widget.subject.title.contains('فيزياء') || + bool get _isChemistrySubject => widget.subject.id.contains('chem') || - widget.subject.id.contains('كيمياء') || - widget.subject.id.contains('science') || - widget.subject.title.contains('علوم'); + 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 => widget.subject.id.contains('arab') || @@ -84,7 +100,10 @@ class _SubjectHubScreenState extends State with SingleTickerPr bool get _hasLab => _isMathSubject || _isEnglishSubject || - _isScienceSubject || + _isChemistrySubject || + _isBiologySubject || + _isEarthSciencesSubject || + _isPhysicsSubject || _isArabicSubject || _isIslamicSubject || _isHistorySubject; @@ -115,10 +134,25 @@ class _SubjectHubScreenState extends State with SingleTickerPr icon: Icon(CupertinoIcons.time_solid, size: 18), 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 { return const Tab( - icon: Icon(CupertinoIcons.lab_flask_solid, size: 18), - text: 'المختبر التفاعلي 🧪', + icon: Icon(CupertinoIcons.bolt_fill, size: 18), + text: 'مختبر الفيزياء والميكانيكا ⚡', ); } } @@ -134,6 +168,12 @@ class _SubjectHubScreenState extends State with SingleTickerPr return const IslamicInteractiveLabView(); } else if (_isHistorySubject) { return const HistoryInteractiveTimelineView(); + } else if (_isChemistrySubject) { + return const ChemistryInteractiveLabView(); + } else if (_isBiologySubject) { + return const BiologyInteractiveLabView(); + } else if (_isEarthSciencesSubject) { + return const EarthSciencesInteractiveLabView(); } else { return const PhysicsInteractiveLabView(); } @@ -426,38 +466,59 @@ class _SubjectHubScreenState extends State with SingleTickerPr } final liveExams = snapshot.data ?? []; - if (liveExams.isEmpty) { - // Default to Primary Unit Exam if none fetched yet - return ListView( + if (liveExams.isNotEmpty) { + return ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20), - children: [ - _buildExamCard( - context, - examId: 1, - title: 'اختبار الفهم الشامل: الوحدة الأولى (أنظمة المعادلات)', - questionsCount: 50, - durationMinutes: 45, - ), - ], + itemCount: liveExams.length, + itemBuilder: (context, idx) { + final exam = liveExams[idx]; + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _buildExamCard( + 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), - itemCount: liveExams.length, - itemBuilder: (context, idx) { - final exam = liveExams[idx]; - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: _buildExamCard( - context, - examId: exam.id > 0 ? exam.id : 1, - title: exam.title, - questionsCount: exam.questionsCount > 0 ? exam.questionsCount : 50, - durationMinutes: exam.durationMinutes > 0 ? exam.durationMinutes : 45, - ), - ); - }, + children: [ + _buildExamCard( + context, + examId: 1, + title: 'اختبار الفهم الشامل: ${widget.subject.title} (الوحدة الأولى)', + questionsCount: 25, + durationMinutes: 40, + ), + ], ); }, ); @@ -787,9 +848,57 @@ class _SubjectHubScreenState extends State with SingleTickerPr ), const SizedBox(height: 16), const Text( - 'لا يوجد شرح منشور لهذا الدرس بعد. ستظهر فقط الموارد التي تُراجع وتُنشر لهذا الدرس أو للمادة.', + 'لا يوجد شرح مصور منشور لهذا الدرس بعد. يمكنك قراءة شروحات الدرس المعتمدة أو الانتقال للمختبر التفاعلي.', 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)), + ), + ), + ], ], ), ), diff --git a/apps/teacher_app/lib/logic/cubits/teacher_studio_cubit.dart b/apps/teacher_app/lib/logic/cubits/teacher_studio_cubit.dart index 06bb445..e4592a9 100644 --- a/apps/teacher_app/lib/logic/cubits/teacher_studio_cubit.dart +++ b/apps/teacher_app/lib/logic/cubits/teacher_studio_cubit.dart @@ -336,10 +336,19 @@ class TeacherStudioCubit extends Cubit { clearError: true, )); try { - final preflight = await repository.preflightSubmission( + var preflight = await repository.preflightSubmission( curriculumLessonId: selectedCurriculumLessonId!, 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') { emit(state.copyWith( isAuditing: false, diff --git a/backend/app/Controllers/VideoController.php b/backend/app/Controllers/VideoController.php index 41a861c..f8d3add 100644 --- a/backend/app/Controllers/VideoController.php +++ b/backend/app/Controllers/VideoController.php @@ -57,7 +57,7 @@ class VideoController try { $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, - 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 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 @@ -65,12 +65,14 @@ class VideoController WHERE vv.uuid=? AND vv.status='published' LIMIT 1", [$versionUuid] ); 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']]); - $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']); + $course=Database::selectOne('SELECT grade_level, price_jod FROM courses WHERE id=? LIMIT 1',[$row['course_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 ($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['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; } $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'=>'تعذر تجهيز تشغيل نسخة الفيديو.']); } @@ -80,19 +82,26 @@ class VideoController public function listTeacherSubmissions(Request $request, Response $response): void { $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 { $rows = Database::select( "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, 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.status AS video_status, vv.published_at, vv.created_at AS version_created_at - FROM teacher_submissions ts - JOIN curriculum_lessons cl ON cl.id = ts.curriculum_lesson_id - LEFT JOIN video_versions vv ON vv.teacher_submission_id = ts.id - WHERE ts.teacher_id = ? - ORDER BY ts.updated_at DESC, vv.version_number DESC", + FROM teacher_submissions ts + JOIN curriculum_lessons cl ON cl.id = ts.curriculum_lesson_id + LEFT JOIN video_versions vv ON vv.teacher_submission_id = ts.id + WHERE ts.teacher_id = ? + ORDER BY ts.updated_at DESC, vv.version_number DESC", [$teacherId] ); @@ -152,13 +161,14 @@ class VideoController $response->status(400)->json(['status' => 'error', 'message' => 'هوية الدرس غير صالحة.']); return; } 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; } $rows = Database::select( "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 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 lessons l ON l.id = vv.source_lesson_id AND l.encoding_status = 'ready' JOIN courses c ON c.id = l.course_id @@ -173,10 +183,11 @@ class VideoController // legitimately have versions attached to different courses. $accessible = []; foreach ($rows as $row) { + $targetGrade = \App\Services\StudentAccessControlService::normalizeGrade($row['grade_key'] ?? $lesson['grade_key'] ?? 'grade_10'); $access = \App\Services\StudentAccessControlService::validateLessonAccess( (int)$request->user_id, $request->getHeader('x-national-id'), - \App\Services\StudentAccessControlService::normalizeGrade($row['grade_level'] ?? 'grade_10'), + $targetGrade, (int)$row['course_id'], (int)$row['source_lesson_id'], false diff --git a/backend/app/Services/StudentAccessControlService.php b/backend/app/Services/StudentAccessControlService.php index 0538dd2..c6ac878 100644 --- a/backend/app/Services/StudentAccessControlService.php +++ b/backend/app/Services/StudentAccessControlService.php @@ -102,8 +102,10 @@ class StudentAccessControlService // أما الطلبة المستقلون وحسابات التجربة والتعلم الحر، فيتم تحديث صفهم النشط تلقائياً وفق المحتوى المختار $isCohortLocked = !empty($student['is_school_sponsored']) || !empty($student['school_id']); if ($activeStudentGrade !== $normalizedTargetGrade) { - if (!$isCohortLocked && !empty($student['id']) && $allowSideEffects) { - Database::query("UPDATE students SET grade_level = ? WHERE id = ?", [$normalizedTargetGrade, (int)$student['id']]); + if (!$isCohortLocked && !empty($student['id'])) { + if ($allowSideEffects) { + Database::query("UPDATE students SET grade_level = ? WHERE id = ?", [$normalizedTargetGrade, (int)$student['id']]); + } $activeStudentGrade = $normalizedTargetGrade; } else { $targetGradeName = self::getGradeDisplayName($normalizedTargetGrade); diff --git a/backend/app/Services/TeacherSubmissionService.php b/backend/app/Services/TeacherSubmissionService.php index 163b098..64441c6 100644 --- a/backend/app/Services/TeacherSubmissionService.php +++ b/backend/app/Services/TeacherSubmissionService.php @@ -37,9 +37,9 @@ final class TeacherSubmissionService { return Database::execute( "UPDATE video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id 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] - ) === 1; + ) >= 1; } /** Releases a reservation only when no media was accepted for review. */ public static function releaseUploadReservation(int $teacherId, string $versionUuid): void { diff --git a/backend/public/index.php b/backend/public/index.php index 47a23d6..d22f58c 100644 --- a/backend/public/index.php +++ b/backend/public/index.php @@ -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-log', [\App\Controllers\CurriculumController::class, 'getUploadLog'], $curriculumManagerMiddleware); $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/generate-ai-assets', [\App\Controllers\CurriculumController::class, 'generateAiAssets'], $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/{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/lessons/{lessonId}/videos', [\App\Controllers\VideoController::class, 'listPublishedLessonVideos'], $studentMiddleware); $router->get('/api/curriculum/lessons/{lessonId}/english-package', [\App\Controllers\CurriculumController::class, 'getPublishedEnglishPackage'], $studentMiddleware);