Harden published curriculum and student flows

This commit is contained in:
Hamza-Ayed
2026-09-30 08:22:17 +03:00
parent e8163fe265
commit ce7b0fcf14
53 changed files with 4723 additions and 3317 deletions
File diff suppressed because it is too large Load Diff
@@ -116,7 +116,7 @@ class _SubjectsGridScreenState extends State<SubjectsGridScreen> {
Icon(CupertinoIcons.checkmark_seal_fill, color: AppColors.saqelCyan, size: 14),
SizedBox(width: 6),
Text(
'المنهاج الوزاري الأردني 🇯🇴',
'فهرس الدروس المنشورة',
style: TextStyle(color: AppColors.saqelCyan, fontSize: 11, fontWeight: FontWeight.w700),
),
],
@@ -141,7 +141,9 @@ class _SubjectsGridScreenState extends State<SubjectsGridScreen> {
const SizedBox(height: 14),
LuxuryButton(
text: 'إعادة المحاولة',
onPressed: () => context.read<CurriculumCubit>().fetchSubjects(),
onPressed: () => context.read<CurriculumCubit>().fetchSubjects(
gradeLevel: GradeLevelModel.normalizeGradeKey(widget.initialGrade ?? 'grade_10'),
),
),
],
),
@@ -162,7 +164,7 @@ class _SubjectsGridScreenState extends State<SubjectsGridScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'المواد المقررة — ${GradeLevelModel.getGradeName(state.selectedGrade)} 📚',
'المواد المنشورة — ${GradeLevelModel.getGradeName(state.selectedGrade)} 📚',
style: AppTypography.displayLarge.copyWith(color: Colors.white, fontSize: 20),
),
const SizedBox(height: 4),
@@ -188,7 +190,8 @@ class _SubjectsGridScreenState extends State<SubjectsGridScreen> {
),
const SizedBox(height: 16),
// Official Academic Grade Level Banner (Strict Grade-Gate — الصف العاشر فقط)
// The catalogue lists reviewed publications; do not claim
// ministry certification without a separate source of proof.
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
@@ -212,12 +215,12 @@ class _SubjectsGridScreenState extends State<SubjectsGridScreen> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${GradeLevelModel.getGradeName(state.selectedGrade)} — المسار الأكاديمي المعتمد',
'${GradeLevelModel.getGradeName(state.selectedGrade)} — الدروس المنشورة',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 13.5),
),
const SizedBox(height: 2),
const Text(
'خطة دراسية موحدة ومطابقة للمنهاج الوزاري الأردني المطور',
'تظهر هنا الدروس التي اكتملت مراجعتها ونُشرت على المنصة',
style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 11),
),
],
@@ -255,15 +258,17 @@ class _SubjectsGridScreenState extends State<SubjectsGridScreen> {
child: const Icon(CupertinoIcons.square_grid_2x2, size: 32, color: AppColors.saqelCyan),
),
const SizedBox(height: 16),
const Text(
'لا توجد مواد مضافة بعد في السيرفر',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 16),
Text(
state.subjects.isEmpty ? 'لا توجد دروس منشورة لهذا الصف بعد' : 'لا توجد مواد تطابق البحث',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 16),
),
const SizedBox(height: 6),
const Text(
'يتم إدراج المواد تلقائياً بمجرد رفع وتحليل كتب المناهج في الخادم',
Text(
state.subjects.isEmpty
? 'يمكنك العودة لاحقاً للاطلاع على الحزم بعد نشرها.'
: 'جرّب اسم مادة مختلفاً أو امسح البحث.',
textAlign: TextAlign.center,
style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 13),
style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 13),
),
],
),
@@ -7,20 +7,26 @@ import '../../widgets/luxury_widgets.dart';
/// Apple Cupertino Sheet for choosing a teacher when multiple published video versions exist for a lesson.
/// Conforms to Saqel product rule: "أكثر من حصة متاحة: قائمة المعلمين مرتبة بالتقييم الموثوق، مع عدد التقييمات والتصفح على دفعات."
class TeacherSelectionSheet extends StatelessWidget {
class TeacherSelectionSheet extends StatefulWidget {
final CurriculumLessonItemModel lesson;
final List<PublishedLessonVideoModel> videos;
final int? nextCursor;
final int availableCount;
const TeacherSelectionSheet({
super.key,
required this.lesson,
required this.videos,
required this.nextCursor,
required this.availableCount,
});
static Future<PublishedLessonVideoModel?> show(
BuildContext context, {
required CurriculumLessonItemModel lesson,
required List<PublishedLessonVideoModel> videos,
required int? nextCursor,
required int availableCount,
}) {
return showModalBottomSheet<PublishedLessonVideoModel>(
context: context,
@@ -29,20 +35,43 @@ class TeacherSelectionSheet extends StatelessWidget {
builder: (sheetContext) => TeacherSelectionSheet(
lesson: lesson,
videos: videos,
nextCursor: nextCursor,
availableCount: availableCount,
),
);
}
@override
Widget build(BuildContext context) {
// Sort server-authoritatively by rating desc, then rating count desc
final sortedVideos = List<PublishedLessonVideoModel>.from(videos)
..sort((a, b) {
final cmp = b.rating.compareTo(a.rating);
if (cmp != 0) return cmp;
return b.ratingCount.compareTo(a.ratingCount);
});
State<TeacherSelectionSheet> createState() => _TeacherSelectionSheetState();
}
class _TeacherSelectionSheetState extends State<TeacherSelectionSheet> {
late final List<PublishedLessonVideoModel> _videos = List.of(widget.videos);
late int? _nextCursor = widget.nextCursor;
bool _loading = false;
bool _failed = false;
Future<void> _loadMore() async {
final cursor = _nextCursor;
final lessonId = widget.lesson.curriculumLessonId;
if (_loading || cursor == null || lessonId == null) return;
setState(() { _loading = true; _failed = false; });
try {
final page = await CurriculumRepository().getPublishedLessonVideosPage(lessonId, cursor: cursor, limit: 20);
if (!mounted) return;
final known = _videos.map((video) => video.videoVersionId).toSet();
setState(() {
_videos.addAll(page.items.where((video) => known.add(video.videoVersionId)));
_nextCursor = page.nextCursor;
_loading = false;
});
} catch (_) {
if (mounted) setState(() { _loading = false; _failed = true; });
}
}
@override
Widget build(BuildContext context) {
return Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.82,
@@ -103,7 +132,7 @@ class TeacherSelectionSheet extends StatelessWidget {
),
const SizedBox(height: 2),
Text(
lesson.title,
widget.lesson.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
@@ -161,23 +190,21 @@ class TeacherSelectionSheet extends StatelessWidget {
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
shrinkWrap: true,
itemCount: sortedVideos.length,
itemCount: _videos.length + (_nextCursor != null ? 1 : 0),
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (ctx, idx) {
final video = sortedVideos[idx];
if (idx == _videos.length) {
return Center(child: TextButton(
onPressed: _loading ? null : _loadMore,
child: Text(_loading ? 'جارٍ تحميل المزيد…' : _failed ? 'تعذر التحميل — أعد المحاولة' : 'عرض المزيد (${_videos.length} من ${widget.availableCount})'),
));
}
final video = _videos[idx];
final isNew = video.ratingCount == 0;
final initials = video.teacherName.isNotEmpty
? video.teacherName.trim().characters.first
: 'م';
final approaches = [
'تركيز على خطوات الحل الوزاري والتحليل المفاهيمي',
'تبسيط القواعد مع أمثلة حياتية وتطبيقات واقعية',
'تدريبات مكثفة ونماذج امتحانات وزارية سابقة',
'شرح تفاعلي مرئي مع استنتاج القوانين خطوة بخطوة',
];
final approach = approaches[idx % approaches.length];
return LuxuryCard(
borderColor: idx == 0
? AppColors.saqelCyan.withAlpha(90)
@@ -344,8 +371,8 @@ class TeacherSelectionSheet extends StatelessWidget {
),
const SizedBox(height: 10),
// Pedagogical approach badge
Container(
// Only metadata supplied by the published video.
if (video.durationSeconds > 0 || video.publishedAt != null) Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 5),
decoration: BoxDecoration(
@@ -356,14 +383,17 @@ class TeacherSelectionSheet extends StatelessWidget {
child: Row(
children: [
const Icon(
CupertinoIcons.lightbulb_fill,
CupertinoIcons.time,
color: AppColors.guardianAmber,
size: 12,
),
const SizedBox(width: 6),
Expanded(
child: Text(
approach,
[
if (video.durationSeconds > 0) '${(video.durationSeconds / 60).ceil()} دقيقة',
if (video.publishedAt != null) 'نُشرت ${video.publishedAt!.year}-${video.publishedAt!.month.toString().padLeft(2, '0')}-${video.publishedAt!.day.toString().padLeft(2, '0')}',
].join(' · '),
style: const TextStyle(
color: AppColors.textSecondaryDark,
fontSize: 11,