diff --git a/apps/student_app/lib/data/repositories/curriculum_question_bank.dart b/apps/student_app/lib/data/repositories/curriculum_question_bank.dart index fc75384..4e8fe66 100644 --- a/apps/student_app/lib/data/repositories/curriculum_question_bank.dart +++ b/apps/student_app/lib/data/repositories/curriculum_question_bank.dart @@ -52,7 +52,7 @@ class CurriculumQuestionBank { if (s.contains('chem') || s.contains('كيمياء')) return 'chemistry_10'; if (s.contains('bio') || s.contains('أحياء') || s.contains('حياتية')) return 'biology_10'; if (s.contains('earth') || s.contains('أرض') || s.contains('بيئة') || s.contains('geology')) return 'earth_sciences_10'; - if (s.contains('hist') || s.contains('تاريخ')) return 'history_10'; + if (s.contains('hist') || s.contains('تاريخ') || s.contains('أردن') || s.contains('اردن')) return 'history_10'; if (s.contains('geo') || s.contains('جغرافيا')) return 'geography_10'; if (s.contains('eng') || s.contains('إنجليز') || s.contains('انجليز')) return 'english_10'; if (s.contains('civic') || s.contains('وطنية') || s.contains('مدنية')) return 'civics_10'; @@ -383,6 +383,52 @@ class CurriculumQuestionBank { } else { items = _buildGenericLessonHomework(subjectTitle: 'العربية لغتي', lessonTitle: lessonTitle); } + } else if (key == 'history_10') { + if (lTitle.contains('فارس') || lTitle.contains('أخمين') || lId.contains('01')) { + items = [ + const LessonHomeworkItem( + id: 401, + questionText: 'تأسست الدولة الأخمينية (الإمبراطورية الفارسية الأولى) على يد الملك كورش، وكانت عاصمتها الشهيرة:', + options: [ + 'برسبوليس (Persepolis)', + 'بابل في بلاد الرافدين', + 'طيبة في مصر القديمة', + 'أثينا في بلاد الإغريق', + ], + correctIndex: 0, + explanation: 'اتخذ الملك كورش والملوك الأخمينيون من مدينة برسبوليس عاصمة كبرى للإمبراطورية، واشتهرت بعمارتها وقصورها المهيبة.', + ruleTakeaway: 'برسبوليس هي العاصمة التاريخية للأخمينيين.', + ), + const LessonHomeworkItem( + id: 402, + questionText: 'ما الدلالة التاريخية لاكتشاف حفر خزن الحبوب وقطع الفخار اليونانية في موقع تل الفخار (إربد) ومغارة الوردة (عجلون) خلال العصر الفارسي؟', + options: [ + 'حدوث مجاعة شديدة في المنطقة', + 'انتعاش الأحوال الاقتصادية والتبادل التجاري مع اليونان وفينيقيا', + 'انعدام الاستقرار السكاني في شمال الأردن', + 'اقتصار التجارة على الملح فقط', + ], + correctIndex: 1, + explanation: 'تشير حفر خزن المحاصيل والقطع المستوردة الثمينة إلى وجود مجتمع متمدن واستقرار اقتصادي وتبادل تجاري نشط عبر موانئ البحر المتوسط.', + ruleTakeaway: 'تل الفخار ومغارة الوردة شواهد أثرية على ازدهار الأردن في العصر الفارسي.', + ), + const LessonHomeworkItem( + id: 403, + questionText: 'انتهت آخر مراحل الإمبراطورية الفارسية (الدولة الساسانية) بعد معركة نهاوند الفاصلة سنة 642م ومقتل آخر ملوكها وهو:', + options: [ + 'يزدجرد الثالث', + 'قمبيز الثاني', + 'كورش الكبير', + 'سلوقس الأول', + ], + correctIndex: 0, + explanation: 'معركة نهاوند (فتح الفتوح) أسقطت القوة العسكرية للساسانيين، وانتهت الدولة الفارسية بمقتل يزدجرد الثالث عام 651م.', + ruleTakeaway: 'نهاوند أنهت الوجود السياسي للإمبراطورية الفارسية الساسانية.', + ), + ]; + } else { + items = _buildGenericLessonHomework(subjectTitle: 'التاريخ', lessonTitle: lessonTitle); + } } else { items = _buildGenericLessonHomework(subjectTitle: subjectId, lessonTitle: lessonTitle); } @@ -1162,17 +1208,77 @@ class CurriculumQuestionBank { return [ const QuestionModel( id: 7101, - questionText: 'المعلم الأثري البارز في الأردن الذي شيده الأنباط عاصمةً لحضارتهم التجارية في جنوب بلاد الشام هو:', + questionText: 'تأسست الإمبراطورية الفارسية الأولى (الدولة الأخمينية) عام 560 ق.م على يد الملك:', questionType: 'multiple_choice', points: 10, - topicTag: 'تاريخ الأردن — الحضارة النبطية والتجارة القديمة', - explanationText: 'البترا هي العاصمة النبطية المنحوتة في الصخر الوردي، سيطر الأنباط من خلالها على طرق القوافل التجارية بين اليمن والشام ومصر.', - aiHint: 'المدينة الوردية المنحوتة في الصخر إحدى عجائب الدنيا.', + topicTag: 'تاريخ الصف العاشر — الدولة الأخمينية وتأسيس الإمبراطورية الفارسية', + explanationText: 'تأسست الإمبراطورية الفارسية على يد الملك كورش الذي حكم بين (560 ق.م - 529 ق.م)، وجعل عاصمتها مدينة برسبوليس.', + aiHint: 'الملك الذي أسس الدولة الأخمينية ووسع نفوذها حتى وادي السند.', options: [ - QuestionOptionModel(id: 81, optionText: 'جرش وأعمدتها الرومانية', isCorrect: false), - QuestionOptionModel(id: 82, optionText: 'البترا المنحوتة في الصخر الوردي', isCorrect: true), - QuestionOptionModel(id: 83, optionText: 'قلعة عجلون الأيوبية', isCorrect: false), - QuestionOptionModel(id: 84, optionText: 'أم قيس المطلة على طبريا', isCorrect: false), + QuestionOptionModel(id: 81, optionText: 'داريوس الأكبر', isCorrect: false), + QuestionOptionModel(id: 82, optionText: 'كورش (Cyrus)', isCorrect: true), + QuestionOptionModel(id: 83, optionText: 'قمبيز الثاني', isCorrect: false), + QuestionOptionModel(id: 84, optionText: 'أرساكيس الأول', isCorrect: false), + ], + ), + const QuestionModel( + id: 7102, + questionText: 'تمكن ملك الدولة الأخمينية الذي خلف كورش من غزو مصر وضمها إلى الإمبراطورية الفارسية، وهو الملك:', + questionType: 'multiple_choice', + points: 10, + topicTag: 'تاريخ الصف العاشر — ملوك الدولة الأخمينية والامتداد الجغرافي', + explanationText: 'الملك قمبيز الثاني هو أشهر ملوك الدولة الأخمينية الذي تمكن من غزو مصر وضمها إلى مملكته الفارسية.', + aiHint: 'ابن الملك كورش وخليفته في الحكم الذي توجه بحملاته غرباً نحو وادي النيل.', + options: [ + QuestionOptionModel(id: 85, optionText: 'قمبيز الثاني', isCorrect: true), + QuestionOptionModel(id: 86, optionText: 'سلوقس الأول', isCorrect: false), + QuestionOptionModel(id: 87, optionText: 'الإسكندر المقدوني', isCorrect: false), + QuestionOptionModel(id: 88, optionText: 'ميتراداتس الأول', isCorrect: false), + ], + ), + const QuestionModel( + id: 7103, + questionText: 'خضع الأردن للنفوذ الفارسي عام 539 ق.م بعد هزيمة البابليين. من أهم المواقع الأثرية في الأردن التي عُثر فيها على شواهد وحفر لخزن الحبوب والمحاصيل من تلك الفترة:', + questionType: 'multiple_choice', + points: 10, + topicTag: 'تاريخ الصف العاشر — الأردن في العصر الفارسي والشواهد الأثرية', + explanationText: 'عثر على دلائل الاستقرار وحفر خزن الحبوب وقطع مستوردة من اليونان وفينيقيا في موقعي تل الفخار (شمال إربد) ومغارة الوردة (عجلون).', + aiHint: 'موقعان بارزان أحدهما تل أثري شمال إربد والآخر مغارة في جبال عجلون.', + options: [ + QuestionOptionModel(id: 89, optionText: 'تل الفخار (إربد) ومغارة الوردة (عجلون)', isCorrect: true), + QuestionOptionModel(id: 90, optionText: 'البترا ووادي رم', isCorrect: false), + QuestionOptionModel(id: 91, optionText: 'أم قيس وجرش الرومانية', isCorrect: false), + QuestionOptionModel(id: 92, optionText: 'قلاع الشوبك والكرك الأيوبية', isCorrect: false), + ], + ), + const QuestionModel( + id: 7104, + questionText: 'انتهت الدولة الأخمينية على يد القائد الإغريقي الإسكندر المقدوني، وأصبح الحكم في بلاد فارس بعد وفاته بيد:', + questionType: 'multiple_choice', + points: 10, + topicTag: 'تاريخ الصف العاشر — الغزو اليوناني والدولة السلوقية', + explanationText: 'بعد وفاة الإسكندر المقدوني قُسمت إمبراطوريته وأصبحت بلاد فارس وغرب آسيا تحت حكم الدولة السلوقية التي أسسها سلوقس الأول.', + aiHint: 'الدولة الهلنستية المنسوبة لمؤسسها سلوقس الأول.', + options: [ + QuestionOptionModel(id: 93, optionText: 'السلوقيين بقيادة سلوقس الأول', isCorrect: true), + QuestionOptionModel(id: 94, optionText: 'الفراعنة في مصر', isCorrect: false), + QuestionOptionModel(id: 95, optionText: 'الأنباط في البترا', isCorrect: false), + QuestionOptionModel(id: 96, optionText: 'البيزنطيين في القسطنطينية', isCorrect: false), + ], + ), + const QuestionModel( + id: 7105, + questionText: 'المرحلة التاريخية الرابعة والأخيرة للإمبراطورية الفارسية التي انتهت بعد معركة نهاوند عام 642م وسقوط يزدجرد الثالث هي:', + questionType: 'multiple_choice', + points: 10, + topicTag: 'تاريخ الصف العاشر — الدولة الساسانية ومعركة نهاوند', + explanationText: 'الدولة الساسانية (224م - 651م) هي المرحلة الأخيرة، وانتهت بانتصار المسلمين الحاسم في معركة نهاوند عام 642م ومقتل يزدجرد الثالث عام 651م.', + aiHint: 'الدولة الفارسية الأخيرة التي اصطدمت بجيوش الفتح الإسلامي في القادسية ونهاوند.', + options: [ + QuestionOptionModel(id: 97, optionText: 'الدولة الساسانية', isCorrect: true), + QuestionOptionModel(id: 98, optionText: 'الدولة البارثية (الأشكانية)', isCorrect: false), + QuestionOptionModel(id: 99, optionText: 'الدولة الأخمينية الأولى', isCorrect: false), + QuestionOptionModel(id: 100, optionText: 'الدولة السلوقية', isCorrect: false), ], ), ]; 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 696b077..c5429a8 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 @@ -223,7 +223,7 @@ class _CurriculumDocumentViewerScreenState extends State[]; + final regex = RegExp(r'\*\*(.*?)\*\*'); + int lastMatchEnd = 0; + + for (final match in regex.allMatches(cleaned)) { + if (match.start > lastMatchEnd) { + spans.add(TextSpan( + text: cleaned.substring(lastMatchEnd, match.start), + style: TextStyle( + color: baseColor ?? Colors.white.withAlpha(225), + fontSize: fontSize, + height: 1.6, + ), + )); + } + + final boldText = match.group(1) ?? ''; + spans.add(TextSpan( + text: boldText, + style: TextStyle( + color: baseColor ?? AppColors.saqelCyan, + fontWeight: FontWeight.w800, + fontSize: fontSize, + height: 1.6, + ), + )); + + lastMatchEnd = match.end; + } + + if (lastMatchEnd < cleaned.length) { + spans.add(TextSpan( + text: cleaned.substring(lastMatchEnd), + style: TextStyle( + color: baseColor ?? Colors.white.withAlpha(225), + fontSize: fontSize, + height: 1.6, + ), + )); + } + + return RichText( + text: TextSpan(children: spans), + ); + } + + /// Renders individual lines with rich Cupertino styling: subheadings, bullets, callouts + Widget _buildSectionLine(String line, double fontSize) { + final trimmed = line.trim(); + if (trimmed.isEmpty) return const SizedBox.shrink(); + + // 1. Subheading (#### or ###) + if (trimmed.startsWith('#### ') || trimmed.startsWith('### ')) { + final title = trimmed.replaceAll(RegExp(r'^#{3,4}\s*'), ''); + return Container( + margin: const EdgeInsets.only(top: 14, bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: AppColors.appleBlue.withAlpha(25), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.saqelCyan.withAlpha(50)), + ), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + color: AppColors.saqelCyan, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _cleanFormulas(title), + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, + fontSize: fontSize + 1.0, + ), + ), + ), + ], + ), + ); + } + + // 2. Blockquote / Callout (> Note or check of understanding) + if (trimmed.startsWith('>')) { + final quoteText = trimmed.replaceAll(RegExp(r'^>\s*'), ''); + return Container( + margin: const EdgeInsets.symmetric(vertical: 6), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFF0F1E36), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.guardianAmber.withAlpha(80)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(CupertinoIcons.lightbulb_fill, color: AppColors.guardianAmber, size: 18), + const SizedBox(width: 10), + Expanded( + child: _buildFormattedText(quoteText, fontSize: fontSize, baseColor: Colors.white.withAlpha(235)), + ), + ], + ), + ); + } + + // 3. Sub-bullet (Indented with spaces + bullet) + final isIndented = line.startsWith(' ') || line.startsWith('\t'); + if (isIndented && (trimmed.startsWith('-') || trimmed.startsWith('•') || trimmed.startsWith('*'))) { + final itemText = trimmed.replaceFirst(RegExp(r'^[-•\*]\s*'), ''); + return Padding( + padding: const EdgeInsets.only(right: 18, top: 4, bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: const EdgeInsets.only(top: 7), + width: 5, + height: 5, + decoration: BoxDecoration( + color: AppColors.saqelCyan.withAlpha(160), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildFormattedText(itemText, fontSize: fontSize - 0.5), + ), + ], + ), + ); + } + + // 4. Primary Bullet (- or • or *) + if (trimmed.startsWith('-') || trimmed.startsWith('•') || trimmed.startsWith('*')) { + final itemText = trimmed.replaceFirst(RegExp(r'^[-•\*]\s*'), ''); + return Padding( + padding: const EdgeInsets.only(right: 6, top: 6, bottom: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: const EdgeInsets.only(top: 7), + width: 7, + height: 7, + decoration: const BoxDecoration( + color: AppColors.appleBlue, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 10), + Expanded( + child: _buildFormattedText(itemText, fontSize: fontSize), + ), + ], + ), + ); + } + + // 5. Standard paragraph line + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: _buildFormattedText(trimmed, fontSize: fontSize), + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -618,31 +830,36 @@ class _CurriculumDocumentViewerScreenState extends State _buildSectionLine(line, _fontSize)), ], ), ), 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 d1d8447..eb10bdd 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 @@ -135,10 +135,10 @@ class _SubjectHubScreenState extends State icon: Icon(CupertinoIcons.money_dollar_circle_fill, size: 18), text: 'حاسبة المواريث والتجويد ⚖️', ); - } else if (_isHistorySubject || norm == 'تاريخ الأردن') { + } else if (_isHistorySubject || norm == 'تاريخ الأردن' || norm == 'التاريخ') { return const Tab( icon: Icon(CupertinoIcons.time_solid, size: 18), - text: 'الخط الزمني لتاريخ الأردن 🏛️', + text: 'الخط الزمني الشامل والتاريخ 🏛️', ); } else if (_isChemistrySubject || norm == 'الكيمياء') { return const Tab( @@ -165,43 +165,59 @@ class _SubjectHubScreenState extends State icon: Icon(CupertinoIcons.desktopcomputer, size: 18), text: 'مختبر الخوارزميات والبرمجة 💻', ); - } else { + } else if (norm == 'الجغرافيا') { + return const Tab( + icon: Icon(CupertinoIcons.map, size: 18), + text: 'مختبر الجغرافيا والخرائط 🗺️', + ); + } else if (norm == 'التربية الوطنية والمدنية') { + return const Tab( + icon: Icon(CupertinoIcons.person_3_fill, size: 18), + text: 'مختبر المواطنة والمجتمع 🏛️', + ); + } else if (_isPhysicsSubject || norm == 'الفيزياء') { return const Tab( icon: Icon(CupertinoIcons.bolt_fill, size: 18), text: 'مختبر الفيزياء والميكانيكا ⚡', ); + } else { + return const Tab( + icon: Icon(CupertinoIcons.lab_flask_solid, size: 18), + text: 'المختبر الافتراضي التفاعلي 🧪', + ); } } Widget get _dynamicLabWidget { Widget? specializedTool; String? specializedTitle; + final norm = Grade10LabsRegistry.normalizeSubject(widget.subject.title); - if (_isMathSubject) { + if (_isMathSubject || norm == 'الرياضيات') { specializedTool = const MathInteractiveLabView(); specializedTitle = 'معمل جيوجبرا 📐'; - } else if (_isEnglishSubject) { + } else if (_isEnglishSubject || norm == 'اللغة الإنجليزية') { specializedTool = const EnglishInteractiveLabView(); specializedTitle = 'مدرب الصوتيات 🎙️'; - } else if (_isArabicSubject) { + } else if (_isArabicSubject || norm == 'العربية لغتي') { specializedTool = const ArabicInteractiveLabView(); specializedTitle = 'شجرة الإعراب 📜'; - } else if (_isIslamicSubject) { + } else if (_isIslamicSubject || norm == 'التربية الإسلامية') { specializedTool = const IslamicInteractiveLabView(); specializedTitle = 'حاسبة المواريث ⚖️'; - } else if (_isHistorySubject) { + } else if (_isHistorySubject || norm == 'تاريخ الأردن' || norm == 'التاريخ') { specializedTool = const HistoryInteractiveTimelineView(); specializedTitle = 'الخط الزمني الشامل 🏛️'; - } else if (_isChemistrySubject) { + } else if (_isChemistrySubject || norm == 'الكيمياء') { specializedTool = const ChemistryInteractiveLabView(); specializedTitle = 'طيف ذرة بور ⚗️'; - } else if (_isBiologySubject) { + } else if (_isBiologySubject || norm == 'العلوم الحياتية') { specializedTool = const BiologyInteractiveLabView(); specializedTitle = 'المجهر الرقمي 🔬'; - } else if (_isEarthSciencesSubject) { + } else if (_isEarthSciencesSubject || norm == 'علوم الأرض والبيئة') { specializedTool = const EarthSciencesInteractiveLabView(); specializedTitle = 'دورة الصخور والطبقات 🌍'; - } else if (_isPhysicsSubject) { + } else if (_isPhysicsSubject || norm == 'الفيزياء') { specializedTool = const PhysicsInteractiveLabView(); specializedTitle = 'محاكاة المتجهات ⚡'; } diff --git a/apps/student_app/lib/presentation/screens/player/socratic_video_player_screen.dart b/apps/student_app/lib/presentation/screens/player/socratic_video_player_screen.dart index 6c1f2d2..4e848e4 100644 --- a/apps/student_app/lib/presentation/screens/player/socratic_video_player_screen.dart +++ b/apps/student_app/lib/presentation/screens/player/socratic_video_player_screen.dart @@ -20,6 +20,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_theme.dart'; import '../../../core/utils/saqel_toast.dart'; +import '../../../core/config/app_config.dart'; import '../../../core/services/storage_service.dart'; import '../../../data/models/subject_model.dart'; import '../../../logic/cubits/auth_cubit.dart'; @@ -81,15 +82,23 @@ class _SocraticVideoPlayerScreenState extends State w } final token = await StorageService().getToken(); - final headers = {}; - if (token != null && token.isNotEmpty) { - headers['Authorization'] = 'Bearer $token'; - } - String finalVideoUrl = videoUrl; - if (token != null && token.isNotEmpty && finalVideoUrl.contains('/api/videos/') && !finalVideoUrl.contains('token=')) { - final sep = finalVideoUrl.contains('?') ? '&' : '?'; - finalVideoUrl = '$finalVideoUrl${sep}token=${Uri.encodeComponent(token)}'; + + // IMPORTANT: Check whether the URL points to our own Saqel API or to an external CDN (BunnyCDN, Cloudflare R2). + // NEVER send the Saqel user JWT Bearer token to external CDNs! CDNs reject unauthorized Bearer headers + // with HTTP 403 Forbidden ("Resource is protected by access token"). + final isOurBackend = finalVideoUrl.contains('/api/videos/') || + finalVideoUrl.startsWith(AppConfig.baseUrl) || + finalVideoUrl.contains('localhost') || + finalVideoUrl.contains('127.0.0.1'); + + final headers = {}; + if (isOurBackend && token != null && token.isNotEmpty) { + headers['Authorization'] = 'Bearer $token'; + if (!finalVideoUrl.contains('token=')) { + final sep = finalVideoUrl.contains('?') ? '&' : '?'; + finalVideoUrl = '$finalVideoUrl${sep}token=${Uri.encodeComponent(token)}'; + } } _videoController?.dispose(); @@ -119,6 +128,38 @@ class _SocraticVideoPlayerScreenState extends State w } } }).catchError((err) { + final errStr = err.toString(); + // Resilient Fallback: If 403 Forbidden or Access Token error occurred on an external URL with token query params, + // retry once without the token parameters (e.g. public pull zones or direct CDNs). + if ((errStr.contains('403') || errStr.contains('token') || errStr.contains('permission')) && + finalVideoUrl.contains('?')) { + final strippedUrl = finalVideoUrl.split('?').first; + if (strippedUrl != finalVideoUrl && strippedUrl.isNotEmpty) { + _videoController?.dispose(); + _videoController = VideoPlayerController.networkUrl( + Uri.parse(strippedUrl), + httpHeaders: const {}, + )..initialize().then((_) { + if (mounted) { + setState(() { + _isVideoInitialized = true; + _videoInitError = null; + }); + _revealVideoControls(); + if (shouldPlay) _videoController!.play(); + } + }).catchError((retryErr) { + if (mounted) { + setState(() { + _isVideoInitialized = false; + _videoInitError = 'تعذر تشغيل بث الفيديو من المصدر السحابي، يمكنك استخدام السبورة التفاعلية للشرح.'; + }); + } + }); + return; + } + } + if (mounted) { setState(() { _isVideoInitialized = false; @@ -407,26 +448,48 @@ class _SocraticVideoPlayerScreenState extends State w style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 12), ), const SizedBox(height: 14), - ElevatedButton.icon( - icon: const Icon(CupertinoIcons.arrow_clockwise, size: 16), - label: const Text('بدء تشغيل بث الفيديو المباشر 🎥'), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.saqelCyan, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - onPressed: () { - setState(() { - _videoInitError = null; - _isVideoInitialized = false; - }); - _initPlayer( - state.playbackData.videoUrl, - state.currentPositionSeconds, - true, - ); - }, + Wrap( + alignment: WrapAlignment.center, + spacing: 10, + runSpacing: 10, + children: [ + ElevatedButton.icon( + icon: const Icon(CupertinoIcons.arrow_clockwise, size: 16), + label: const Text('إعادة محاولة البث 🎥'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.saqelCyan, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + onPressed: () { + setState(() { + _videoInitError = null; + _isVideoInitialized = false; + }); + _initPlayer( + state.playbackData.videoUrl, + state.currentPositionSeconds, + true, + ); + }, + ), + OutlinedButton.icon( + icon: const Icon(CupertinoIcons.mic_fill, size: 16, color: AppColors.appleBlue), + label: const Text('السبورة والشرح الصوتي 🎙️', style: TextStyle(color: Colors.white)), + style: OutlinedButton.styleFrom( + side: BorderSide(color: AppColors.appleBlue.withAlpha(150)), + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), + onPressed: () { + setState(() { + _showChalkboardMode = true; + _videoInitError = null; + }); + }, + ), + ], ), ], ), diff --git a/apps/student_app/lib/presentation/screens/virtual_labs/lab_identity.dart b/apps/student_app/lib/presentation/screens/virtual_labs/lab_identity.dart index 8c32d93..143c3cf 100644 --- a/apps/student_app/lib/presentation/screens/virtual_labs/lab_identity.dart +++ b/apps/student_app/lib/presentation/screens/virtual_labs/lab_identity.dart @@ -586,7 +586,7 @@ const LabIdentity kFinanceBudgetToolIdentity = LabIdentity( const LabIdentity kHistoryJordanChronologyToolIdentity = LabIdentity( toolKey: 'history_jordan_chronology', - subjectAr: 'تاريخ الأردن', + subjectAr: 'التاريخ', lessonAr: 'التسلسل الزمني التفاعلي', ); diff --git a/apps/student_app/lib/presentation/screens/virtual_labs/labs_registry.dart b/apps/student_app/lib/presentation/screens/virtual_labs/labs_registry.dart index 45f8003..bb59a46 100644 --- a/apps/student_app/lib/presentation/screens/virtual_labs/labs_registry.dart +++ b/apps/student_app/lib/presentation/screens/virtual_labs/labs_registry.dart @@ -339,8 +339,12 @@ class Grade10LabsRegistry { if (s.contains('وطنية') || s.contains('مدنية') || s.contains('civics')) { return 'التربية الوطنية والمدنية'; } - if (s.contains('أردن') || s.contains('اردن')) return 'تاريخ الأردن'; - if (s.contains('تاريخ') || s.contains('history')) return 'التاريخ'; + if (s.contains('تاريخ') || + s.contains('أردن') || + s.contains('اردن') || + s.contains('history')) { + return 'التاريخ'; + } if (s.contains('رقمية') || s.contains('حاسوب') || s.contains('digital')) { return 'المهارات الرقمية'; } @@ -353,7 +357,11 @@ class Grade10LabsRegistry { final direct = bySubject(norm); if (direct.isNotEmpty) return direct; return all - .where((e) => e.subjectAr.contains(norm) || norm.contains(e.subjectAr)) + .where((e) => + e.subjectAr == norm || + (norm == 'التاريخ' && (e.subjectAr == 'التاريخ' || e.subjectAr == 'تاريخ الأردن')) || + e.subjectAr.contains(norm) || + norm.contains(e.subjectAr)) .toList(); } @@ -361,9 +369,14 @@ class Grade10LabsRegistry { /// Defaults to true in development so that the reviewer/teacher can immediately test the labs. static bool previewModeEnabled = true; - /// Exact identity lookup. + /// Exact identity lookup strictly isolated per subject. /// When [previewModeEnabled] is active or [allowPreview] is true, returns the lab for testing. /// When false (strict production gate), returns ONLY when released & carrying a published bundle. + /// + /// CRITICAL INTEGRITY RULE: + /// Never leak labs across subjects. A Physics lab must NEVER appear under History, Geography, + /// Digital Skills, Civics, or any other subject. If a subject has no matching candidate lab, + /// this method returns null immediately. static Grade10LabEntry? findForLesson({ required String subjectTitle, required String lessonTitle, @@ -372,42 +385,55 @@ class Grade10LabsRegistry { bool? allowPreview, }) { final usePreview = allowPreview ?? previewModeEnabled; + final candidates = bySubjectNormalized(subjectTitle); + + // Subject Isolation Gate: If no candidate labs exist for this subject, return null immediately. + if (candidates.isEmpty) { + return null; + } + if (usePreview) { + // 1. Exact match on curriculumLessonId within candidate subject labs if (curriculumLessonId != null && curriculumLessonId.isNotEmpty) { - final exact = entryForCurriculumLessonId(curriculumLessonId); - if (exact != null) return exact; - } - if (lessonId != null && lessonId.isNotEmpty) { - for (final e in all) { - if (e.identity.curriculumLessonId.contains(lessonId) || - e.identity.toolKey == lessonId || - e.identity.sourceMarkdown.contains(lessonId)) { - return e; - } - } - } - final candidates = bySubjectNormalized(subjectTitle); - if (candidates.isNotEmpty) { - final t = lessonTitle.toLowerCase(); for (final c in candidates) { - if (t.contains(c.lessonAr.toLowerCase()) || - c.lessonAr.toLowerCase().contains(t)) { + if (c.identity.curriculumLessonId == curriculumLessonId) { return c; } } - if (lessonId != null && lessonId.isNotEmpty) { - for (final c in candidates) { - if (c.identity.lessonKey == lessonId || - c.identity.curriculumLessonId.endsWith(lessonId)) { - return c; - } + } + + // 2. Lesson Title fuzzy matching strictly within candidate subject labs + final t = lessonTitle.toLowerCase().trim(); + for (final c in candidates) { + final ct = c.lessonAr.toLowerCase().trim(); + if (t == ct || (ct.length > 4 && (t.contains(ct) || ct.contains(t)))) { + return c; + } + } + + // 3. Lesson Key / Tool Key match strictly within candidate subject labs + if (lessonId != null && lessonId.isNotEmpty) { + for (final c in candidates) { + if (c.identity.lessonKey == lessonId || + c.identity.toolKey == lessonId || + c.identity.curriculumLessonId.endsWith(lessonId) || + c.identity.sourceMarkdown.contains(lessonId)) { + return c; } } - return null; } + + return null; } - return publishedEntryForCurriculumLessonId(curriculumLessonId ?? ''); + if (curriculumLessonId != null && curriculumLessonId.isNotEmpty) { + for (final c in candidates) { + if (c.identity.curriculumLessonId == curriculumLessonId && c.identity.isPublished) { + return c; + } + } + } + return null; } static Grade10LabEntry? entryForCurriculumLessonId( diff --git a/apps/student_app/test/virtual_labs_smoke_test.dart b/apps/student_app/test/virtual_labs_smoke_test.dart index defd6d5..b3184b4 100644 --- a/apps/student_app/test/virtual_labs_smoke_test.dart +++ b/apps/student_app/test/virtual_labs_smoke_test.dart @@ -7,9 +7,9 @@ import 'package:student_app/presentation/screens/virtual_labs/labs_registry.dart import 'package:student_app/presentation/screens/virtual_labs/subject_virtual_labs_view.dart'; void main() { - test('registry covers all Grade-10 labs (48 entries, 14 subjects)', () { + test('registry covers all Grade-10 labs (48 entries, 13 subjects)', () { expect(Grade10LabsRegistry.all.length, 48); - expect(Grade10LabsRegistry.subjects.length, 14); + expect(Grade10LabsRegistry.subjects.length, 13); final ids = Grade10LabsRegistry.all.map((e) => e.id).toSet(); expect(ids.length, 48); // stable unique ids }); @@ -98,6 +98,37 @@ void main() { 'arabic_10_semester_1_unit_01_lesson_01'); }); + test('findForLesson strictly enforces subject isolation (no physics leak to History/Digital)', () { + Grade10LabsRegistry.previewModeEnabled = true; + + // History lesson 2 must NEVER return physics vector lab + final historyLab = Grade10LabsRegistry.findForLesson( + subjectTitle: 'تاريخ الأردن (Jordan History 10)', + lessonTitle: 'الدرس الثاني: الإمبراطورية الساسانية', + lessonId: 'lesson_02', + ); + expect(historyLab, isNull); + + // Digital skills lesson 1 must NEVER return physics vectors intro lab + final digitalLab = Grade10LabsRegistry.findForLesson( + subjectTitle: 'المهارات الرقمية (Digital Skills 10)', + lessonTitle: 'معالجة البيانات', + lessonId: 'lesson_01', + ); + expect(digitalLab, isNull); + + // History lesson 1 MUST return Persian Empire lab, NOT physics! + final historyUnit1Lab = Grade10LabsRegistry.findForLesson( + subjectTitle: 'تاريخ الأردن (Jordan History 10)', + lessonTitle: 'الدرس الأول: الإمبراطورية الفارسية: النشأة والتطور', + lessonId: 'lesson_01', + curriculumLessonId: 'history_10_semester_1_unit_01_lesson_01', + ); + expect(historyUnit1Lab, isNotNull); + expect(historyUnit1Lab?.identity.curriculumLessonId, 'history_10_semester_1_unit_01_lesson_01'); + expect(historyUnit1Lab?.subjectAr, 'التاريخ'); + }); + test('a lab is published only when released AND bundle version provided', () { // Emulate a server bundle arriving only for released bound lesson. const released = LabIdentity( diff --git a/apps/teacher_app/pubspec.lock b/apps/teacher_app/pubspec.lock index b2ddf06..1e9caa8 100644 --- a/apps/teacher_app/pubspec.lock +++ b/apps/teacher_app/pubspec.lock @@ -340,10 +340,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -356,10 +356,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" nested: dependency: transitive description: @@ -585,10 +585,10 @@ packages: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.9" typed_data: dependency: transitive description: diff --git a/backend/app/Services/VideoService.php b/backend/app/Services/VideoService.php index 1b9f972..b289701 100644 --- a/backend/app/Services/VideoService.php +++ b/backend/app/Services/VideoService.php @@ -491,8 +491,8 @@ class VideoService return [ 'library_id' => getenv('BUNNY_STREAM_LIBRARY_ID') ?: '', 'api_key' => getenv('BUNNY_STREAM_API_KEY') ?: '', - 'token_key' => getenv('BUNNY_STREAM_TOKEN_KEY') ?: getenv('JWT_SECRET') ?: '', - 'pull_zone_host' => getenv('BUNNY_STREAM_HOST') ?: '', + 'token_key' => getenv('BUNNY_STREAM_TOKEN_KEY') ?: '', + 'pull_zone_host' => getenv('BUNNY_STREAM_HOST') ?: 'video.bunnycdn.com', 'embed_host' => 'iframe.mediadelivery.net' ]; } @@ -598,18 +598,29 @@ class VideoService $config = self::getBunnyConfig(); $libraryId = $config['library_id']; $tokenKey = $config['token_key']; - $expires = time() + $expiresInSeconds; + $host = !empty($config['pull_zone_host']) ? $config['pull_zone_host'] : 'video.bunnycdn.com'; - $hashable = $tokenKey . $videoId . $expires; - $token = hash('sha256', $hashable); + if (!empty($tokenKey)) { + $expires = time() + $expiresInSeconds; + $hashable = $tokenKey . $videoId . $expires; + $token = hash('sha256', $hashable); - $embedUrl = "https://{$config['embed_host']}/embed/{$libraryId}/{$videoId}?token={$token}&expires={$expires}"; - $hlsUrl = "https://{$config['pull_zone_host']}/{$videoId}/playlist.m3u8?token={$token}&expires={$expires}"; - $thumbUrl = "https://{$config['pull_zone_host']}/{$videoId}/thumbnail.jpg?token={$token}&expires={$expires}"; + $embedUrl = "https://{$config['embed_host']}/embed/{$libraryId}/{$videoId}?token={$token}&expires={$expires}"; + $hlsUrl = "https://{$host}/{$videoId}/playlist.m3u8?token={$token}&expires={$expires}"; + $thumbUrl = "https://{$host}/{$videoId}/thumbnail.jpg?token={$token}&expires={$expires}"; + } else { + // Clean direct streaming without bogus token hashes that cause 403 + $expires = null; + $token = ''; + $embedUrl = "https://{$config['embed_host']}/embed/{$libraryId}/{$videoId}"; + $hlsUrl = "https://{$host}/{$videoId}/playlist.m3u8"; + $thumbUrl = "https://{$host}/{$videoId}/thumbnail.jpg"; + } return [ 'video_id' => $videoId, 'library_id' => $libraryId, + 'video_url' => $hlsUrl, 'embed_url' => $embedUrl, 'hls_url' => $hlsUrl, 'thumb_url' => $thumbUrl,