diff --git a/apps/student_app/lib/data/models/subject_model.dart b/apps/student_app/lib/data/models/subject_model.dart index d6bb8bd..7c19fcb 100644 --- a/apps/student_app/lib/data/models/subject_model.dart +++ b/apps/student_app/lib/data/models/subject_model.dart @@ -235,17 +235,14 @@ class CurriculumLessonItemModel { // Video availability: // 1. Live server enriched: json['has_video'] == true or json['video_url'] is present - // 2. Unit 1 Math 10 lessons (Intro, Lesson 1, Lesson 2) uploaded by teacher are fully available + // 2. Exact uploaded lessons in Grade 10 Math Unit 1 (Intro, Lesson 1, Lesson 2) final bool hasVideoExplicit = (json['has_video'] == true) || (json['video_url'] != null && json['video_url'].toString().isNotEmpty) || (filePath != null && ( - filePath.contains('math_10/semester_1/unit_01') || - filePath.contains('intro_and_project') || - filePath.contains('lesson_01') || - filePath.contains('lesson_02') - )) || - ((json['title']?.toString() ?? '').contains('معادلات')) || - ((json['title']?.toString() ?? '').contains('مقدمة ومشروع')); + filePath.contains('math_10/semester_1/unit_01/intro_and_project') || + filePath.contains('math_10/semester_1/unit_01/lesson_01') || + filePath.contains('math_10/semester_1/unit_01/lesson_02') + )); return CurriculumLessonItemModel( id: stableId, 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 1f8f078..6fe2fc0 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 @@ -262,12 +262,17 @@ class _SubjectHubScreenState extends State with SingleTickerPr style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 12), ), children: unit.lessons.map((lesson) { + final hasVid = lesson.hasVideo; return Container( margin: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), decoration: BoxDecoration( color: const Color(0xFF09111E), borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColors.darkCardBorder.withAlpha(80)), + border: Border.all( + color: hasVid + ? AppColors.darkCardBorder.withAlpha(80) + : Colors.white.withAlpha(15), + ), ), child: ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4), @@ -275,16 +280,28 @@ class _SubjectHubScreenState extends State with SingleTickerPr width: 38, height: 38, decoration: BoxDecoration( - gradient: LinearGradient( - colors: [widget.subject.primaryColor, widget.subject.secondaryColor], - ), + gradient: hasVid + ? LinearGradient( + colors: [widget.subject.primaryColor, widget.subject.secondaryColor], + ) + : null, + color: hasVid ? null : Colors.white.withAlpha(10), borderRadius: BorderRadius.circular(10), + border: hasVid ? null : Border.all(color: Colors.white12), + ), + child: Icon( + hasVid ? CupertinoIcons.play_fill : CupertinoIcons.film, + color: hasVid ? Colors.black : AppColors.textSecondaryDark, + size: 18, ), - child: const Icon(CupertinoIcons.play_fill, color: Colors.black, size: 18), ), title: Text( lesson.title, - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 13), + style: TextStyle( + color: hasVid ? Colors.white : Colors.white70, + fontWeight: FontWeight.w600, + fontSize: 13, + ), ), subtitle: Row( children: [ @@ -296,17 +313,27 @@ class _SubjectHubScreenState extends State with SingleTickerPr Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: AppColors.saqelCyan.withAlpha(25), + color: hasVid + ? AppColors.saqelCyan.withAlpha(25) + : Colors.white.withAlpha(12), borderRadius: BorderRadius.circular(6), ), child: Text( - '${lesson.checkpointsCount} فحوصات', - style: const TextStyle(color: AppColors.saqelCyan, fontSize: 10, fontWeight: FontWeight.w700), + hasVid ? '${lesson.checkpointsCount} فحوصات' : 'قريباً', + style: TextStyle( + color: hasVid ? AppColors.saqelCyan : AppColors.textSecondaryDark, + fontSize: 10, + fontWeight: FontWeight.w700, + ), ), ), ], ), - trailing: const Icon(CupertinoIcons.chevron_back, color: AppColors.textSecondaryDark, size: 16), + trailing: Icon( + hasVid ? CupertinoIcons.chevron_back : CupertinoIcons.clock, + color: hasVid ? AppColors.textSecondaryDark : Colors.white24, + size: 16, + ), onTap: () { _showLessonVideoSelector(context, lesson); }, @@ -814,8 +841,13 @@ class _SubjectHubScreenState extends State with SingleTickerPr ); } - /// Direct Launch of Socratic Video Player + /// Direct Launch of Socratic Video Player or Unavailable Notice void _showLessonVideoSelector(BuildContext context, CurriculumLessonItemModel lesson) { + if (!lesson.hasVideo) { + _showNoVideoAvailableSheet(context, lesson); + return; + } + // Direct authentic launch: zero mockups, zero fake teacher names Navigator.of(context).push( CupertinoPageRoute( @@ -828,4 +860,94 @@ class _SubjectHubScreenState extends State with SingleTickerPr ), ); } + + /// Luxury Modal Sheet when a lesson has no uploaded video yet + void _showNoVideoAvailableSheet(BuildContext context, CurriculumLessonItemModel lesson) { + showCupertinoModalPopup( + context: context, + builder: (ctx) => Container( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), + decoration: BoxDecoration( + color: const Color(0xFF0F172A), + borderRadius: const BorderRadius.vertical(top: Radius.circular(28)), + border: Border.all(color: Colors.white12), + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Drag Indicator + Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + color: Colors.white24, + borderRadius: BorderRadius.circular(2), + ), + ), + // Icon + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: AppColors.saqelCyan.withAlpha(20), + shape: BoxShape.circle, + border: Border.all(color: AppColors.saqelCyan.withAlpha(60)), + ), + child: const Icon( + CupertinoIcons.film, + color: AppColors.saqelCyan, + size: 32, + ), + ), + const SizedBox(height: 16), + // Title + const Text( + 'فيديو الشرح قيد الإنتاج والمراجعة', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + // Lesson title + Text( + lesson.title, + textAlign: TextAlign.center, + style: const TextStyle( + color: AppColors.saqelCyan, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 12), + // Explanation + const Text( + 'لم يقم المعلم برفع ونشر فيديو الشرح لهذا الدرس بعد.\nسيتاح فور اعتماده من الإشراف الأكاديمي. يمكنك حالياً الاستفادة من أوراق العمل والملخصات والأنشطة التفاعلية في التبويبات المجاورة.', + textAlign: TextAlign.center, + style: TextStyle( + color: AppColors.textSecondaryDark, + fontSize: 13, + height: 1.5, + ), + ), + const SizedBox(height: 24), + // Action Button + SizedBox( + width: double.infinity, + child: LuxuryButton( + text: 'حسناً، فهمت ذلك', + onPressed: () => Navigator.of(ctx).pop(), + ), + ), + ], + ), + ), + ), + ); + } } diff --git a/backend/app/Controllers/VideoController.php b/backend/app/Controllers/VideoController.php index 9d6564e..e8a7abc 100644 --- a/backend/app/Controllers/VideoController.php +++ b/backend/app/Controllers/VideoController.php @@ -533,14 +533,23 @@ class VideoController // 1. Match by curriculum_key if provided if ($curriculumKey !== '') { - $baseKey = basename($curriculumKeyNoExt); - $matched = Database::select( - "SELECT * FROM lessons - WHERE curriculum_key = ? OR curriculum_key = ? - OR curriculum_key LIKE ? OR local_path LIKE ? OR markdown_content LIKE ? - ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5", - [$curriculumKey, $curriculumKeyNoExt, '%' . $baseKey . '%', '%' . $curriculumKeyNoExt . '%', '%' . $curriculumKeyNoExt . '%'] - ); + if (str_contains($curriculumKeyNoExt, '/')) { + // Structured hierarchical key: match exact or qualified path suffix + $matched = Database::select( + "SELECT * FROM lessons + WHERE curriculum_key = ? OR curriculum_key = ? + OR curriculum_key LIKE ? OR local_path LIKE ? OR markdown_content LIKE ? + ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5", + [$curriculumKey, $curriculumKeyNoExt, "%{$curriculumKeyNoExt}%", "%{$curriculumKeyNoExt}%", "%{$curriculumKeyNoExt}%"] + ); + } else { + $matched = Database::select( + "SELECT * FROM lessons + WHERE curriculum_key = ? OR curriculum_key LIKE ? + ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5", + [$curriculumKey, "%/{$curriculumKeyNoExt}%"] + ); + } if (!empty($matched)) { $candidates = array_merge($candidates, $matched); } @@ -549,14 +558,16 @@ class VideoController // 2. Match by title if provided if (!empty($title)) { $cleanTitle = trim(preg_replace('/^(الدرس\s*\d+:\s*|معملُ\s*[^:]+:\s*|مقدمة\s*[^:]+:\s*)/u', '', $title)); - $matchedTitle = Database::select( - "SELECT * FROM lessons - WHERE title = ? OR title LIKE ? OR title LIKE ? - ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5", - [$title, "%{$title}%", "%{$cleanTitle}%"] - ); - if (!empty($matchedTitle)) { - $candidates = array_merge($candidates, $matchedTitle); + if (mb_strlen($cleanTitle) >= 6) { + $matchedTitle = Database::select( + "SELECT * FROM lessons + WHERE title = ? OR title LIKE ? + ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5", + [$title, "%{$cleanTitle}%"] + ); + if (!empty($matchedTitle)) { + $candidates = array_merge($candidates, $matchedTitle); + } } } @@ -570,28 +581,17 @@ class VideoController // 4. Manifest slug lookup if (empty($candidates) && !empty($rawId)) { - $rawIdNoExt = preg_replace('/\.md$/i', '', $rawId); - $matchedRaw = Database::select( - "SELECT * FROM lessons - WHERE curriculum_key = ? OR curriculum_key = ? OR title LIKE ? OR local_path LIKE ? OR markdown_content LIKE ? - ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5", - [$rawId, $rawIdNoExt, "%{$rawId}%", "%{$rawIdNoExt}%", "%{$rawIdNoExt}%"] - ); - if (!empty($matchedRaw)) { - $candidates = array_merge($candidates, $matchedRaw); - } - if (empty($candidates)) { - $manifestLesson = CurriculumService::findLessonById($rawId); - if ($manifestLesson && !empty($manifestLesson['title'])) { - $matchedManifest = Database::select( - "SELECT * FROM lessons - WHERE title = ? OR title LIKE ? OR markdown_content LIKE ? - ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5", - [$manifestLesson['title'], '%' . $manifestLesson['title'] . '%', '%' . ($manifestLesson['file'] ?? '') . '%'] - ); - if (!empty($matchedManifest)) { - $candidates = array_merge($candidates, $matchedManifest); - } + $manifestLesson = CurriculumService::findLessonById($rawId); + if ($manifestLesson && !empty($manifestLesson['file'])) { + $fileNoExt = preg_replace('/\.md$/i', '', $manifestLesson['file']); + $matchedManifest = Database::select( + "SELECT * FROM lessons + WHERE curriculum_key = ? OR curriculum_key = ? OR local_path LIKE ? + ORDER BY (hls_url IS NOT NULL AND hls_url != '') DESC, id DESC LIMIT 5", + [$manifestLesson['file'], $fileNoExt, "%{$fileNoExt}%"] + ); + if (!empty($matchedManifest)) { + $candidates = array_merge($candidates, $matchedManifest); } } } @@ -604,24 +604,13 @@ class VideoController break; } } - if (!$lesson) { - $lesson = $candidates[0]; - } } - if (!$lesson) { - // Fallback to latest available lesson in DB with a video - $lesson = Database::selectOne( - "SELECT * FROM lessons - WHERE hls_url IS NOT NULL AND hls_url != '' - ORDER BY id DESC LIMIT 1" - ); - } - - if (!$lesson) { + // Strict verification: if no matching lesson with an uploaded video is found, return 404 + if (!$lesson || empty($lesson['hls_url'])) { $response->status(404)->json([ 'status' => 'error', - 'message' => 'الدرس غير موجود أو لم يتم نشر الفيديو الخاص به بعد' + 'message' => 'لم يتم رفع ونشر فيديو شرح لهذا الدرس بعد.' ]); return; } diff --git a/backend/app/Services/CurriculumService.php b/backend/app/Services/CurriculumService.php index b24360e..cd5a607 100644 --- a/backend/app/Services/CurriculumService.php +++ b/backend/app/Services/CurriculumService.php @@ -224,12 +224,23 @@ class CurriculumService $dbTitle = (string)($dbl['title'] ?? ''); $matched = false; - if ($currKey !== '' && ($currKey === $lessonFile || $currKeyNoExt === $lessonFileNoExt)) { - $matched = true; - } elseif ($currKeyNoExt !== '' && str_ends_with($currKeyNoExt, $lessonId)) { - $matched = true; - } elseif ($dbTitle !== '' && ($dbTitle === $lessonTitle || str_contains($lessonTitle, $dbTitle) || str_contains($dbTitle, $lessonTitle))) { - $matched = true; + if ($currKeyNoExt !== '' && $lessonFileNoExt !== '') { + if ($currKeyNoExt === $lessonFileNoExt) { + $matched = true; + } elseif (str_contains($currKeyNoExt, '/') && ( + str_ends_with($lessonFileNoExt, '/' . ltrim($currKeyNoExt, '/')) || + str_ends_with($currKeyNoExt, '/' . ltrim($lessonFileNoExt, '/')) + )) { + $matched = true; + } + } + + if (!$matched && $dbTitle !== '' && $lessonTitle !== '') { + $normDb = preg_replace('/[\s\p{P}]+/u', '', mb_strtolower($dbTitle)); + $normLes = preg_replace('/[\s\p{P}]+/u', '', mb_strtolower($lessonTitle)); + if ($normDb === $normLes && mb_strlen($normDb) > 8) { + $matched = true; + } } if ($matched) {