diff --git a/.gitignore b/.gitignore index c891784..e06ece2 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,11 @@ apps/**/.dart_tool/ apps/**/build/ apps/**/.flutter-plugins* +# Local source books and large curriculum references. +# Register them through backend/scripts/import_grade10_book_sources.php; do not +# push raw PDFs through the application repository. +books/*.pdf + # IDE & Editor files .idea/ .vscode/ diff --git a/apps/admin_app/lib/main.dart b/apps/admin_app/lib/main.dart index 013b131..cd0543b 100644 --- a/apps/admin_app/lib/main.dart +++ b/apps/admin_app/lib/main.dart @@ -183,7 +183,7 @@ class _UnifiedSupervisorShellState extends State { ), _buildModeTab( index: 1, - title: 'لوحة القائد الأعلى (43 مدرسة)', + title: 'لوحة المديرية', icon: CupertinoIcons.shield_lefthalf_fill, ), ], diff --git a/apps/admin_app/lib/presentation/screens/directorate_command_screen.dart b/apps/admin_app/lib/presentation/screens/directorate_command_screen.dart index 9fab498..5ee3b42 100644 --- a/apps/admin_app/lib/presentation/screens/directorate_command_screen.dart +++ b/apps/admin_app/lib/presentation/screens/directorate_command_screen.dart @@ -83,7 +83,7 @@ class _DirectorateCommandScreenState extends State ), const SizedBox(width: 6), Text( - dir['name'] ?? 'مديرية الثقافة العسكرية', + dir['name']?.toString() ?? 'مديرية غير محددة', style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w800, @@ -93,7 +93,7 @@ class _DirectorateCommandScreenState extends State ), const SizedBox(height: 2), const Text( - 'لوحة القائد الأعلى للرقابة والسيادة الرقمية (43 مدرسة في المملكة)', + 'تعرض البيانات المتاحة ضمن نطاق صلاحيات الحساب.', style: TextStyle(fontSize: 12, color: Color(0xFF94A3B8)), ), ], @@ -163,7 +163,7 @@ class _DirectorateCommandScreenState extends State Expanded( child: _kpiCell( 'إجمالي المدارس', - '${dir['total_schools'] ?? 43} مدرسة', + '${dir['total_schools'] ?? 'غير متاح'} مدرسة', CupertinoIcons.building_2_fill, const Color(0xFF818CF8), ), @@ -171,7 +171,7 @@ class _DirectorateCommandScreenState extends State Expanded( child: _kpiCell( 'الطلبة المسجلون', - '${dir['total_students'] ?? 19350}', + '${dir['total_students'] ?? 'غير متاح'}', CupertinoIcons.person_3_fill, const Color(0xFF38BDF8), ), @@ -184,7 +184,7 @@ class _DirectorateCommandScreenState extends State Expanded( child: _kpiCell( 'معلمو الثقافة العسكرية', - '${dir['total_teachers'] ?? 812} معلم', + '${dir['total_teachers'] ?? 'غير متاح'} معلم', CupertinoIcons.person_badge_plus_fill, const Color(0xFFFBBF24), ), @@ -192,7 +192,7 @@ class _DirectorateCommandScreenState extends State Expanded( child: _kpiCell( 'نسبة كوتة الحصص', - '${dir['compliance_rate'] ?? 94.5}%', + dir['compliance_rate'] == null ? 'غير متاح' : '${dir['compliance_rate']}%', CupertinoIcons.check_mark_circled_solid, const Color(0xFF34D399), ), @@ -209,14 +209,14 @@ class _DirectorateCommandScreenState extends State mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text( - 'توزيع مدارس الثقافة العسكرية الـ 43 حسب تصنيف الوزن:', + 'المدارس ضمن النطاق:', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white), ), - Text( - 'إجمالي العقد: 20,000 د.أ', + const Text( + 'القيمة التعاقدية غير معروضة', style: TextStyle( fontSize: 12, fontWeight: FontWeight.w700, @@ -325,7 +325,7 @@ class _DirectorateCommandScreenState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // Monetization Model Header + // Revenue and teacher ranking cannot be inferred without a verified ledger. Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -347,7 +347,7 @@ class _DirectorateCommandScreenState extends State color: Color(0xFF4ADE80), size: 22), SizedBox(width: 8), Text( - 'سوق تسييل المعلمين المتميزين (شراكة الأرباح)', + 'بيانات المعلمين المتاحة', style: TextStyle( fontSize: 14.5, fontWeight: FontWeight.w700, @@ -357,7 +357,7 @@ class _DirectorateCommandScreenState extends State ), const SizedBox(height: 8), const Text( - 'المعلمون الحاصلون على تقييم 95%+ يتم منحهم اعتماد "معلم صَقِل المعتمد" وتُباع شروحاتهم خارج الثقافة العسكرية مع توزيع العائد: 55% للمعلم، 15% للمديرية، 30% لصَقِل.', + 'لا توجد سياسة دخل أو اعتماد معلّم منشورة في هذه الشاشة. لا يظهر ترتيب إلا إذا أعاده الخادم بدليل موثق.', style: TextStyle( fontSize: 12.5, color: Color(0xFFCBD5E1), height: 1.5), ), @@ -368,12 +368,14 @@ class _DirectorateCommandScreenState extends State // Top Teachers List const Text( - '🌟 أفضل المعلمين المرشحين للتسييل والاعتماد الرسمي:', + 'المعلمون الذين أعادهم الخادم:', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white), ), const SizedBox(height: 12), - ...topTeachers.map((t) => Container( + if (topTeachers.isEmpty) + const Text('لا توجد بيانات ترتيب معلمين متاحة.', style: TextStyle(color: Color(0xFF94A3B8))) + else ...topTeachers.map((t) => Container( margin: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.all(14), decoration: BoxDecoration( @@ -419,7 +421,7 @@ class _DirectorateCommandScreenState extends State borderRadius: BorderRadius.circular(6), ), child: Text( - '${t['score']}% تقييم AI', + '${t['score'] ?? 'غير متاح'}', style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w800, @@ -433,12 +435,14 @@ class _DirectorateCommandScreenState extends State // Early Intervention Radar const Text( - '⚠️ رادار الإنذار والتوجيه المبكر (معالجة القصور قبل التوجيهي):', + 'حالات الدعم التي أعادها الخادم:', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white), ), const SizedBox(height: 12), - ...needingSupport.map((ns) => Container( + if (needingSupport.isEmpty) + const Text('لا توجد بيانات دعم متاحة.', style: TextStyle(color: Color(0xFF94A3B8))) + else ...needingSupport.map((ns) => Container( margin: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.all(14), decoration: BoxDecoration( @@ -532,7 +536,7 @@ class _DirectorateCommandScreenState extends State ), const SizedBox(height: 8), const Text( - 'مراقبة لحظية لنحو 19,000 طالب يخوضون الامتحان الموحد المتزامن عبر 43 مدرسة، برصد فوري للسرعة المستحيلة وتكتل الأخطاء.', + 'تظهر هنا فقط الشذوذات التي سجلها الخادم من جلسات امتحان فعلية ضمن نطاق الحساب.', style: TextStyle( fontSize: 12.5, color: Color(0xFF94A3B8), height: 1.5), ), @@ -556,12 +560,7 @@ class _DirectorateCommandScreenState extends State borderRadius: BorderRadius.circular(14), border: Border.all(color: const Color(0xFF1E293B)), ), - child: const Center( - child: Text( - '✅ لا توجد أي تنبيهات شذوذ حالياً · كافة المدارس تعمل بانضباط تام', - style: TextStyle(color: Color(0xFF10B981), fontSize: 13.5), - ), - ), + child: const Center(child: Text('لا توجد سجلات شذوذ متاحة من الخادم.', style: TextStyle(color: Color(0xFF94A3B8), fontSize: 13.5))), ) else ...anomalies.map((anm) => Container( @@ -613,19 +612,11 @@ class _DirectorateCommandScreenState extends State ), const SizedBox(height: 12), ElevatedButton.icon( - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - '🎥 جاري فتح العينة البانورامية المسجلة (16.5 MB) لتلك الدقيقة للتحقق البصري المباشر'), - backgroundColor: Color(0xFF0284C7), - ), - ); - }, + onPressed: null, icon: const Icon(CupertinoIcons.play_circle_fill, size: 16), label: const Text( - 'عرض العينة البانورامية المسجلة في هذه الدقيقة 👁️', + 'عرض العينة غير متاح من هذه الشاشة', style: TextStyle(fontSize: 12.5), ), style: ElevatedButton.styleFrom( diff --git a/apps/admin_app/lib/presentation/screens/school_principal_screen.dart b/apps/admin_app/lib/presentation/screens/school_principal_screen.dart index 6e286ce..f6ca48a 100644 --- a/apps/admin_app/lib/presentation/screens/school_principal_screen.dart +++ b/apps/admin_app/lib/presentation/screens/school_principal_screen.dart @@ -1,11 +1,13 @@ -import 'dart:async'; import 'dart:convert'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:file_picker/file_picker.dart'; -import '../../data/models/directorate_models.dart'; + import '../../services/directorate_api_service.dart'; +/// School dashboard deliberately renders only server-provided records. +/// Uploads, exam dispatch, parent messaging, and roster imports stay unavailable +/// here until each action has an auditable production workflow. class SchoolPrincipalScreen extends StatefulWidget { const SchoolPrincipalScreen({super.key}); @@ -13,1498 +15,121 @@ class SchoolPrincipalScreen extends StatefulWidget { State createState() => _SchoolPrincipalScreenState(); } -class _SchoolPrincipalScreenState extends State - with SingleTickerProviderStateMixin { - late TabController _tabController; - bool _isLoading = true; - Map? _dashboardData; - - // Recording State - bool _isRecording = false; - int _recordDurationSeconds = 0; - Timer? _recordTimer; - double _recordedSizeMb = 0.0; - int? _selectedTeacherId; - String? _selectedTeacher; - String _selectedSubject = ''; - final TextEditingController _lessonTitleController = - TextEditingController(); - RecordedLessonResult? _lastResult; - - // Exam state - bool _examPushedToLab = false; - bool _isPanoramicActive = false; - double _panoramicSizeMb = 16.5; - - // Enterprise Integrity, Parent Reports & Roster State - bool _isRunningIntegrityAudit = false; - Map? _integrityResult; - bool _isDispatchingParentReports = false; - bool _isImportingRoster = false; +class _SchoolPrincipalScreenState extends State { + Future>? _dashboard; + bool _busy = false; + String? _operationMessage; @override void initState() { super.initState(); - _tabController = TabController(length: 3, vsync: this); - _loadData(); + _reload(); } - Future _loadData() async { - setState(() => _isLoading = true); - final data = await DirectorateApiService.fetchSchoolDashboard(); - if (mounted) { - setState(() { - _dashboardData = data; - final teachers = data['teachers'] as List? ?? const []; - if (teachers.isNotEmpty) { - final first = Map.from(teachers.first as Map); - _selectedTeacherId = (first['id'] as num?)?.toInt(); - _selectedTeacher = first['name']?.toString(); - _selectedSubject = first['subject']?.toString() ?? ''; - } - _isLoading = false; - }); - } + void _reload() => setState(() => _dashboard = DirectorateApiService.fetchSchoolDashboard()); + + Future _pickVideo() async { + final picked = await FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: const ['mp4', 'mov', 'webm'], withData: true); + return picked?.files.isNotEmpty == true ? picked!.files.single : null; } - Future _selectAndUploadRecording() async { - if (_selectedTeacherId == null || _lessonTitleController.text.trim().isEmpty) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('اختر معلماً وأدخل عنوان الحصة أولاً.'))); - return; - } - final selection = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: const ['mp4', 'mov', 'webm'], - withData: true, - ); - if (selection == null || selection.files.isEmpty) return; - final file = selection.files.single; - setState(() { - _isRecording = true; - _recordedSizeMb = file.size / 1048576; - _lastResult = null; + Future _run(String label, Future Function() operation) async { + setState(() { _busy = true; _operationMessage = null; }); + try { + final result = await operation(); + if (mounted) setState(() => _operationMessage = result); + _reload(); + } catch (error) { + if (mounted) setState(() => _operationMessage = '$label تعذر: $error'); + } finally { if (mounted) setState(() => _busy = false); } + } + + Future _recordLesson(int schoolId, List> teachers) async { + if (teachers.isEmpty) { setState(() => _operationMessage = 'لا يوجد معلمون متاحون من الخادم.'); return; } + final title = TextEditingController(); + var selected = teachers.first; + final confirmed = await showDialog(context: context, builder: (dialogContext) => StatefulBuilder(builder: (_, setDialogState) => AlertDialog( + title: const Text('رفع حصة مرصودة'), + content: Column(mainAxisSize: MainAxisSize.min, children: [DropdownButton>(value: selected, isExpanded: true, items: teachers.map((teacher) => DropdownMenuItem(value: teacher, child: Text(teacher['name']?.toString() ?? 'معلم'))).toList(), onChanged: (value) { if (value != null) setDialogState(() => selected = value); }), TextField(controller: title, decoration: const InputDecoration(labelText: 'عنوان الحصة'))]), + actions: [TextButton(onPressed: () => Navigator.pop(dialogContext, false), child: const Text('إلغاء')), FilledButton(onPressed: () => Navigator.pop(dialogContext, true), child: const Text('اختيار ملف'))], + ))); + if (confirmed != true || title.text.trim().isEmpty) return; + final file = await _pickVideo(); if (file == null) return; + await _run('رفع الحصة', () async { + final result = await DirectorateApiService.recordLesson(schoolId: schoolId, teacherId: (selected['id'] as num).toInt(), subject: selected['subject']?.toString() ?? '', gradeLevel: selected['grade']?.toString() ?? 'grade_10', lessonTitle: title.text.trim(), durationMinutes: 0, filePath: file.path, fileBytes: file.bytes, fileName: file.name); + return 'تم تسجيل الحصة بحالة ${result.status}.'; }); - - // Show quick processing dialog - showCupertinoDialog( - context: context, - barrierDismissible: false, - builder: (ctx) => const Center( - child: CupertinoActivityIndicator(radius: 20), - ), - ); - - try { - final result = await DirectorateApiService.recordLesson( - schoolId: (_dashboardData?['school']?['id'] as num?)?.toInt() ?? 0, - teacherId: _selectedTeacherId!, - subject: _selectedSubject, - gradeLevel: 'grade_10', - lessonTitle: _lessonTitleController.text.trim(), - durationMinutes: 0, - filePath: file.path, - fileBytes: file.bytes, - fileName: file.name, - ); - if (mounted) setState(() => _lastResult = result); - } catch (e) { - if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString()))); - } finally { - if (mounted) { - Navigator.of(context).pop(); - setState(() => _isRecording = false); - } - } } - void _showDualFormsPreview() async { - await DirectorateApiService.fetchDualForms(); - if (!mounted) return; - - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (ctx) => Directionality( - textDirection: TextDirection.rtl, - child: Container( - height: MediaQuery.of(context).size.height * 0.8, - decoration: const BoxDecoration( - color: Color(0xFF0F172A), - borderRadius: BorderRadius.vertical(top: Radius.circular(24)), - border: Border(top: BorderSide(color: Color(0xFF8B5CF6), width: 2)), - ), - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: const Color(0xFF334155), - borderRadius: BorderRadius.circular(2), - ), - ), - ), - const SizedBox(height: 14), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'معاينة النماذج الموحدة (نموذج أ ونموذج ب)', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w800, - color: Colors.white, - ), - ), - IconButton( - icon: const Icon(CupertinoIcons.xmark, color: Colors.white70), - onPressed: () => Navigator.of(context).pop(), - ), - ], - ), - const SizedBox(height: 10), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFF1E1B4B), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: const Color(0xFF8B5CF6).withOpacity(0.3)), - ), - child: const Text( - 'المخطط الهجين: 70% أسئلة موضوعية مبرمجة على محطات الحاسوب + 30% خطوات إنشائية ورقية مطبوعة مع باركود تسلسلي مشفر.', - style: TextStyle(fontSize: 12, color: Color(0xFFC4B5FD), height: 1.4), - ), - ), - const SizedBox(height: 14), - Expanded( - child: Row( - children: [ - // Form A Card - Expanded( - child: Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: const Color(0xFF161F30), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: const Color(0xFF10B981).withOpacity(0.5)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Row( - children: [ - Icon(CupertinoIcons.doc_checkmark_fill, color: Color(0xFF10B981), size: 16), - SizedBox(width: 6), - Text('النموذج (أ) — الفا', style: TextStyle(fontWeight: FontWeight.w800, color: Color(0xFF34D399), fontSize: 13)), - ], - ), - const SizedBox(height: 8), - const Text('الباركود التسلسلي:\nSAQEL-EXAM-A-ALPHA', style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8))), - const Divider(color: Color(0xFF1E293B)), - const Text('• 20 مسألة موضوعية (70 علامة)\n• مسألتان إنشائيتان (30 علامة)\n• باركود موجه لكاميرا المشرف', style: TextStyle(fontSize: 11.5, color: Colors.white70, height: 1.6)), - ], - ), - ), - ), - const SizedBox(width: 10), - // Form B Card - Expanded( - child: Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: const Color(0xFF161F30), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: const Color(0xFF38BDF8).withOpacity(0.5)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Row( - children: [ - Icon(CupertinoIcons.doc_plaintext, color: Color(0xFF38BDF8), size: 16), - SizedBox(width: 6), - Text('النموذج (ب) — بيتا', style: TextStyle(fontWeight: FontWeight.w800, color: Color(0xFF38BDF8), fontSize: 13)), - ], - ), - const SizedBox(height: 8), - const Text('الباركود التسلسلي:\nSAQEL-EXAM-B-BETA', style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8))), - const Divider(color: Color(0xFF1E293B)), - const Text('• خلط كامل لترتيب الأسئلة\n• تغيير معطيات الأرقام للمقاعد المتجاورة لمنع النقل', style: TextStyle(fontSize: 11.5, color: Colors.white70, height: 1.6)), - ], - ), - ), - ), - ], - ), - ), - const SizedBox(height: 14), - ElevatedButton.icon( - onPressed: () { - Navigator.of(context).pop(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('🖨️ تم إرسال 48 نسخة من النموذج أ وب ومسودات الباركود لطابعة المدرسة المركزية'), - backgroundColor: Color(0xFF10B981), - ), - ); - }, - icon: const Icon(CupertinoIcons.printer_fill, size: 16), - label: const Text('أمر طباعة النماذج والباركود الآن'), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF8B5CF6), - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - ), - ], - ), - ), - ), - ); + Future _uploadPanorama(int sessionId) async { + final file = await _pickVideo(); if (file == null) return; + await _run('رفع العينة', () async { await DirectorateApiService.uploadPanoramicSample(sessionId: sessionId, filePath: file.path, fileBytes: file.bytes, fileName: file.name); return 'تم حفظ العينة وربطها بالجلسة.'; }); } - void _runIntegrityAudit() async { - setState(() => _isRunningIntegrityAudit = true); - final res = await DirectorateApiService.evaluateExamSessionIntegrity(); - if (mounted) { - setState(() { - _integrityResult = res; - _isRunningIntegrityAudit = false; - }); - } - } - - void _dispatchParentReports() async { - setState(() => _isDispatchingParentReports = true); - final res = await DirectorateApiService.dispatchParentReports(); - if (mounted) { - setState(() => _isDispatchingParentReports = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(res['message'] ?? 'تم إرسال التقارير لأولياء الأمور بنجاح'), - backgroundColor: const Color(0xFF10B981), - ), - ); - } - } - - void _importSchoolRoster() async { - final picked = await FilePicker.platform.pickFiles( - type: FileType.custom, - allowedExtensions: const ['csv', 'json'], - withData: true, - ); - if (picked == null || picked.files.single.bytes == null) return; - - setState(() => _isImportingRoster = true); - try { - final file = picked.files.single; - final text = utf8.decode(file.bytes!); - final List> records; - if (file.extension?.toLowerCase() == 'json') { - final decoded = json.decode(text); - if (decoded is! List) throw const FormatException('ملف JSON يجب أن يحتوي قائمة طلبة.'); - records = decoded.map((row) => Map.from(row as Map)).toList(); - } else { - final lines = const LineSplitter() - .convert(text) - .where((line) => line.trim().isNotEmpty) - .toList(); - if (lines.length < 2) throw const FormatException('ملف CSV فارغ.'); - final headers = lines.first.split(',').map((h) => h.trim()).toList(); - records = lines.skip(1).map((line) { - final values = line.split(','); - return { - for (var i = 0; i < headers.length; i++) - headers[i]: i < values.length ? values[i].trim() : '', - }; - }).toList(); - } - - final res = await DirectorateApiService.importSchoolRoster(records: records); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('تم استيراد ${res['imported_count']} طالباً والتحقق من بياناتهم بنجاح.'), - backgroundColor: const Color(0xFF0284C7), - ), - ); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('تعذر استيراد الكشف: $e'), backgroundColor: const Color(0xFFDC2626)), - ); - } - } finally { - if (mounted) setState(() => _isImportingRoster = false); - } + Future _importRoster(int schoolId) async { + final picked = await FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: const ['json'], withData: true); + final file = picked?.files.isNotEmpty == true ? picked!.files.single : null; + if (file?.bytes == null) return; + final decoded = json.decode(utf8.decode(file!.bytes!)); + if (decoded is! List) throw StateError('ملف الكشف يجب أن يحتوي قائمة JSON من السجلات.'); + final records = decoded.whereType().map((row) => Map.from(row)).toList(); + await _run('استيراد الكشف', () async { final result = await DirectorateApiService.importSchoolRoster(schoolId: schoolId, records: records); return 'استُورد ${result['imported_count'] ?? 0} سجل؛ الفاشل ${result['failed_count'] ?? 0}.'; }); } @override - void dispose() { - _tabController.dispose(); - _recordTimer?.cancel(); - _lessonTitleController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - if (_isLoading) { - return const Scaffold( - backgroundColor: Color(0xFF070B12), - body: Center( - child: CupertinoActivityIndicator(color: Color(0xFF8B5CF6), radius: 18), - ), - ); - } - - final school = _dashboardData?['school'] ?? {}; - final teachers = (_dashboardData?['teachers'] as List? ?? []) - .map((t) => TeacherItem.fromJson(t)) - .toList(); - - return Scaffold( - backgroundColor: const Color(0xFF070B12), - appBar: AppBar( - backgroundColor: const Color(0xFF0E1626), - elevation: 0, - centerTitle: false, - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - school['name'] ?? 'إدارة المدرسة العسكرية', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - const SizedBox(height: 2), - Row( - children: [ - const Icon(CupertinoIcons.location_solid, - size: 12, color: Color(0xFF94A3B8)), - const SizedBox(width: 4), - Text( - school['governorate'] ?? 'العاصمة - عمان', - style: const TextStyle(fontSize: 12, color: Color(0xFF94A3B8)), - ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), - decoration: BoxDecoration( - color: const Color(0xFF1E293B), - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - 'الفئة (ج) كبرى · 980 طالباً', - style: TextStyle(fontSize: 11, color: Color(0xFFA78BFA)), - ), - ) - ], - ) - ], - ), - bottom: TabBar( - controller: _tabController, - indicatorColor: const Color(0xFF8B5CF6), - indicatorWeight: 3, - labelColor: Colors.white, - unselectedLabelColor: const Color(0xFF64748B), - labelStyle: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13.5), - tabs: const [ - Tab( - icon: Icon(CupertinoIcons.video_camera_solid, size: 18), - text: 'تسجيل الحصص الصفية', - ), - Tab( - icon: Icon(CupertinoIcons.person_2_fill, size: 18), - text: 'المعلمون والدورية', - ), - Tab( - icon: Icon(CupertinoIcons.doc_text_fill, size: 18), - text: 'الامتحانات ومختبر الحاسوب', - ), - ], - ), - ), - body: TabBarView( - controller: _tabController, - children: [ - _buildLessonRecordingTab(teachers), - _buildTeachersQuotaTab(teachers), - _buildUnifiedExamsTab(), - ], - ), - ); - } - - // --------------------------------------------------------------------------- - // TAB 1: Classroom Video Recording & Pedagogical Quality Audit - // --------------------------------------------------------------------------- - Widget _buildLessonRecordingTab(List teachers) { - return SingleChildScrollView( - padding: const EdgeInsets.all(18), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Quota Banner - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [const Color(0xFF1E1B4B), const Color(0xFF0F172A)], - begin: Alignment.topRight, - end: Alignment.bottomLeft, - ), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: const Color(0xFF4338CA).withOpacity(0.5)), - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: const Color(0xFF4F46E5).withOpacity(0.2), - shape: BoxShape.circle, - ), - child: const Icon(CupertinoIcons.calendar_badge_plus, - color: Color(0xFFA5B4FC), size: 24), - ), - const SizedBox(width: 14), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'دورية التدقيق الميداني المعتمدة', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - color: Colors.white), - ), - SizedBox(height: 2), - Text( - 'حصة مرئية واحدة لكل معلم كل أسبوعين (الحد الأقصى للحجم: 400 ميجابايت بدقة 720p)', - style: TextStyle(fontSize: 12, color: Color(0xFFCBD5E1)), - ), - ], - ), - ), - ], - ), - ), - const SizedBox(height: 18), - - // Camera Viewport Simulation Card - Container( - height: 240, - decoration: BoxDecoration( - color: const Color(0xFF0F172A), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: _isRecording - ? const Color(0xFFEF4444) - : const Color(0xFF1E293B), - width: _isRecording ? 2 : 1, - ), - boxShadow: _isRecording - ? [ - BoxShadow( - color: const Color(0xFFEF4444).withOpacity(0.2), - blurRadius: 20, - spreadRadius: 2, - ) - ] - : [], - ), - child: Stack( - children: [ - // Mock Classroom Background Grid - Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - CupertinoIcons.videocam_fill, - size: 54, - color: _isRecording - ? const Color(0xFFEF4444) - : const Color(0xFF475569), - ), - const SizedBox(height: 10), - Text( - _isRecording - ? 'جاري تصوير وقائع الحصة الصفية داخل الغرفة...' - : 'كاميرا المشرف جاهزة للتسجيل الصفي', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: _isRecording ? Colors.white : const Color(0xFF94A3B8), - ), - ), - const SizedBox(height: 4), - Text( - 'ترميز ذكي 720p · محدد تلقائياً لسقف 400 ميجابايت', - style: TextStyle( - fontSize: 12, color: const Color(0xFF64748B)), - ), - ], - ), - ), - - // Top Status Bar in Camera - Positioned( - top: 12, - left: 12, - right: 12, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (_isRecording) - Row( - children: [ - Container( - width: 10, - height: 10, - decoration: const BoxDecoration( - color: Color(0xFFEF4444), - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 6), - const Text( - 'تسجيل نشط (REC)', - style: TextStyle( - color: Color(0xFFEF4444), - fontWeight: FontWeight.w800, - fontSize: 12), - ), - ], - ) - else - const Text( - 'جاهز', - style: TextStyle(color: Color(0xFF10B981), fontSize: 12), - ), - - // Metrics - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.6), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - _isRecording - ? '${_formatDuration(_recordDurationSeconds)} | ${_recordedSizeMb.toStringAsFixed(1)} MB / 400 MB' - : '00:00 | 0.0 MB', - style: const TextStyle( - color: Colors.white, - fontSize: 12, - fontWeight: FontWeight.w700), - ), - ) - ], - ), - ), - - // Bottom Action inside Camera - Positioned( - bottom: 14, - left: 14, - right: 14, - child: ElevatedButton( - onPressed: _isRecording ? null : _selectAndUploadRecording, - style: ElevatedButton.styleFrom( - backgroundColor: _isRecording - ? const Color(0xFFDC2626) - : const Color(0xFF8B5CF6), - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12)), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - _isRecording - ? CupertinoIcons.stop_fill - : CupertinoIcons.circle_fill, - size: 18), - const SizedBox(width: 8), - Text( - _isRecording ? 'جارٍ رفع الحصة إلى R2…' : 'اختيار فيديو حصة حقيقي ورفعه 🎥', - style: const TextStyle( - fontSize: 15, fontWeight: FontWeight.w700), - ), - ], - ), - ), - ), - ], - ), - ), - const SizedBox(height: 18), - - // Lesson Details Form - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFF0F172A), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: const Color(0xFF1E293B)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'بيانات الحصة والمعلم المستهدف:', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - color: Colors.white), - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: DropdownButtonFormField( - initialValue: _selectedTeacher, - dropdownColor: const Color(0xFF1E293B), - style: const TextStyle(color: Colors.white, fontSize: 13), - decoration: InputDecoration( - labelText: 'المعلم', - labelStyle: const TextStyle(color: Color(0xFF94A3B8)), - filled: true, - fillColor: const Color(0xFF161F30), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10)), - ), - items: teachers - .map((t) => DropdownMenuItem( - value: t.name, child: Text(t.name))) - .toList(), - onChanged: (val) { - if (val != null) { - final teacher = teachers.firstWhere((t) => t.name == val); - setState(() { - _selectedTeacher = val; - _selectedTeacherId = teacher.id; - _selectedSubject = teacher.subject; - }); - } - }, - ), - ), - const SizedBox(width: 12), - Expanded( - child: DropdownButtonFormField( - initialValue: _selectedSubject.isEmpty ? null : _selectedSubject, - dropdownColor: const Color(0xFF1E293B), - style: const TextStyle(color: Colors.white, fontSize: 13), - decoration: InputDecoration( - labelText: 'المادة', - labelStyle: const TextStyle(color: Color(0xFF94A3B8)), - filled: true, - fillColor: const Color(0xFF161F30), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10)), - ), - items: teachers.map((t) => t.subject).where((s) => s.isNotEmpty).toSet() - .map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(), - onChanged: (val) { - if (val != null) setState(() => _selectedSubject = val); - }, - ), - ), - ], - ), - const SizedBox(height: 12), - TextField( - controller: _lessonTitleController, - style: const TextStyle(color: Colors.white, fontSize: 13.5), - decoration: InputDecoration( - labelText: 'عنوان الدرس وفق المنهاج الوزاري', - labelStyle: const TextStyle(color: Color(0xFF94A3B8)), - filled: true, - fillColor: const Color(0xFF161F30), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10)), - ), - ), - ], - ), - ), - const SizedBox(height: 18), - - // AI Evaluation Card (if available) - if (_lastResult != null) _buildAiResultCard(_lastResult!), - ], - ), - ); - } - - Widget _buildAiResultCard(RecordedLessonResult res) { - return Container( - padding: const EdgeInsets.all(18), - decoration: BoxDecoration( - color: const Color(0xFF111827), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFF10B981).withOpacity(0.6)), - boxShadow: [ - BoxShadow( - color: const Color(0xFF10B981).withOpacity(0.1), - blurRadius: 20, - ) - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Row( - children: [ - Icon(CupertinoIcons.checkmark_seal_fill, - color: Color(0xFF10B981), size: 22), - SizedBox(width: 8), - Text( - 'بطاقة التدقيق التربوي المعتمدة بالذكاء الاصطناعي', - style: TextStyle( - fontSize: 14.5, - fontWeight: FontWeight.w700, - color: Colors.white), - ), - ], - ), - Container( - padding: - const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: const Color(0xFF10B981).withOpacity(0.2), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - '${res.aiAlignmentScore}% مطابقة', - style: const TextStyle( - color: Color(0xFF10B981), - fontWeight: FontWeight.w800, - fontSize: 13), - ), - ), - ], - ), - const SizedBox(height: 12), - Text( - res.reportSummary, - style: const TextStyle( - fontSize: 13, color: Color(0xFFCBD5E1), height: 1.5), - ), - const SizedBox(height: 14), - Row( - children: [ - _metricChip('نسبة كلام المعلم', res.teacherTalkRatio), - const SizedBox(width: 8), - _metricChip('مدة الحصة', '${res.durationMinutes} دقيقة'), - const SizedBox(width: 8), - _metricChip('حجم الفيديو', '${res.fileSizeMb.toStringAsFixed(1)} MB'), - ], - ), - const Divider(color: Color(0xFF1F2937), height: 24), - const Text( - 'الوقفات السقراطية المقترحة أثناء الحصة:', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: Color(0xFFA78BFA)), - ), + Widget build(BuildContext context) => Scaffold( + backgroundColor: const Color(0xFF070B12), + appBar: AppBar( + backgroundColor: const Color(0xFF0E1626), + title: const Text('لوحة المدرسة'), + actions: [IconButton(onPressed: _reload, icon: const Icon(CupertinoIcons.refresh))], + ), + body: FutureBuilder>( + future: _dashboard, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) return const Center(child: CupertinoActivityIndicator()); + if (snapshot.hasError) return Center(child: Padding(padding: const EdgeInsets.all(24), child: Text('تعذر تحميل بيانات المدرسة: ${snapshot.error}', textAlign: TextAlign.center, style: const TextStyle(color: Colors.white70)))); + final data = snapshot.data ?? const {}; + final school = data['school'] is Map ? Map.from(data['school'] as Map) : const {}; + final teachers = data['teachers'] is List ? (data['teachers'] as List).whereType().map((item) => Map.from(item)).toList() : const >[]; + final exams = data['active_exams'] is List ? (data['active_exams'] as List).whereType().map((item) => Map.from(item)).toList() : const >[]; + return RefreshIndicator(onRefresh: () async => _reload(), child: ListView(padding: const EdgeInsets.all(16), children: [ + Text(school['name']?.toString() ?? 'مدرسة غير محددة', style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + Text('${school['governorate'] ?? 'الموقع غير متاح'} • الطلبة: ${school['student_count'] ?? 'غير متاح'} • المعلمون: ${school['teacher_count'] ?? 'غير متاح'}', style: const TextStyle(color: Colors.white60)), + const SizedBox(height: 20), + _operations(school, teachers, exams), + const SizedBox(height: 20), + const Text('المعلمون', style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), const SizedBox(height: 8), - ...res.socraticCheckpoints.map((cp) => Container( - margin: const EdgeInsets.only(bottom: 6), - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: const Color(0xFF1E293B), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Text( - cp['timestamp'] ?? '', - style: const TextStyle( - color: Color(0xFFF59E0B), - fontWeight: FontWeight.w700, - fontSize: 12), - ), - const SizedBox(width: 10), - Expanded( - child: Text( - cp['question'] ?? '', - style: - const TextStyle(color: Colors.white, fontSize: 12.5), - ), - ), - ], - ), - )), - ], - ), - ); - } - - // --------------------------------------------------------------------------- - // TAB 2: Teachers & Bi-Weekly Quota Tracking - // --------------------------------------------------------------------------- - Widget _buildTeachersQuotaTab(List teachers) { - return ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: teachers.length, - itemBuilder: (ctx, idx) { - final t = teachers[idx]; - return Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFF0F172A), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: const Color(0xFF1E293B)), - ), - child: Row( - children: [ - CircleAvatar( - radius: 22, - backgroundColor: t.isCompleted - ? const Color(0xFF10B981).withOpacity(0.2) - : const Color(0xFFF59E0B).withOpacity(0.2), - child: Text( - t.name.split(' ').first, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: t.isCompleted - ? const Color(0xFF10B981) - : const Color(0xFFF59E0B), - ), - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - t.name, - style: const TextStyle( - fontSize: 14.5, - fontWeight: FontWeight.w700, - color: Colors.white), - ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 1), - decoration: BoxDecoration( - color: const Color(0xFF1E293B), - borderRadius: BorderRadius.circular(4), - ), - child: Text( - t.subject, - style: const TextStyle( - fontSize: 11, color: Color(0xFF94A3B8)), - ), - ), - ], - ), - const SizedBox(height: 4), - Text( - 'آخر حصة: ${t.lastLesson}', - style: - const TextStyle(fontSize: 12, color: Color(0xFF64748B)), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Container( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: t.isCompleted - ? const Color(0xFF065F46) - : const Color(0xFF78350F), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - t.isCompleted ? 'مكتمل الدورية' : 'متبقي هذا الأسبوع', - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - color: t.isCompleted - ? const Color(0xFF34D399) - : const Color(0xFFFBBF24), - ), - ), - ), - const SizedBox(height: 4), - Text( - 'التقييم: ${t.aiScore}%', - style: const TextStyle( - fontSize: 11.5, - color: Color(0xFFA78BFA), - fontWeight: FontWeight.w600), - ), - ], - ), - ], - ), - ); + if (teachers.isEmpty) const Text('لا توجد بيانات معلمين متاحة من الخادم.', style: TextStyle(color: Colors.white60)) else ...teachers.map(_teacher), + const SizedBox(height: 20), + const Text('الامتحانات الفعالة', style: TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + if (exams.isEmpty) const Text('لا توجد امتحانات فعالة في البيانات المستلمة.', style: TextStyle(color: Colors.white60)) else ...exams.map(_exam), + ])); }, - ); - } - - // --------------------------------------------------------------------------- - // TAB 3: Unified Exams, Lab Dispatch & Smart Panoramic Surveillance - // --------------------------------------------------------------------------- - Widget _buildUnifiedExamsTab() { - return SingleChildScrollView( - padding: const EdgeInsets.all(18), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Active Exam Card - Container( - padding: const EdgeInsets.all(18), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [const Color(0xFF1E293B), const Color(0xFF0F172A)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFF8B5CF6).withOpacity(0.5)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Row( - children: [ - Icon(CupertinoIcons.doc_checkmark_fill, - color: Color(0xFFA78BFA), size: 20), - SizedBox(width: 8), - Text( - 'امتحان وزاري موحد من مديرية الثقافة العسكرية', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - color: Colors.white), - ), - ], - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: const Color(0xFF8B5CF6).withOpacity(0.2), - borderRadius: BorderRadius.circular(6), - ), - child: const Text( - 'موعد موحد: 09:00 ص', - style: TextStyle( - fontSize: 11.5, - color: Color(0xFFA78BFA), - fontWeight: FontWeight.w700), - ), - ), - ], - ), - const SizedBox(height: 12), - const Text( - 'الرياضيات العلمي - محاكاة امتحان التوجيهي الوزاري 2008', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w800, - color: Colors.white), - ), - const SizedBox(height: 6), - const Text( - 'المدة: 60 دقيقة · جدول المواصفات الوزاري المعتمد · نموذجان (أ و ب)', - style: TextStyle(fontSize: 12.5, color: Color(0xFF94A3B8)), - ), - const SizedBox(height: 16), - - // Lab Push & Print Buttons - Row( - children: [ - Expanded( - child: ElevatedButton.icon( - onPressed: () async { - final exams = _dashboardData?['active_exams'] as List? ?? const []; - final examId = exams.isEmpty - ? 0 - : (Map.from(exams.first as Map)['id'] as num?)?.toInt() ?? 0; - final schoolId = (_dashboardData?['school']?['id'] as num?)?.toInt() ?? 0; - if (examId == 0 || schoolId == 0) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('لا يوجد امتحان حقيقي مجدول لهذه المدرسة.')), - ); - return; - } - final ok = await DirectorateApiService.pushExamToLab( - examId: examId, - schoolId: schoolId, - ); - if (ok && mounted) { - setState(() => _examPushedToLab = true); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - '✅ تم بث الامتحان لمحطات مختبر الحاسوب وتفعيل البيئة المقفلة (Kiosk Mode)'), - backgroundColor: Color(0xFF10B981), - ), - ); - } - }, - icon: Icon( - _examPushedToLab - ? CupertinoIcons.checkmark_alt_circle_fill - : CupertinoIcons.desktopcomputer, - size: 16), - label: Text( - _examPushedToLab - ? 'تم البث لمختبر الحاسوب' - : 'إرسال لمختبر الحاسوب (32 جهازاً)', - style: const TextStyle( - fontSize: 12.5, fontWeight: FontWeight.w700), - ), - style: ElevatedButton.styleFrom( - backgroundColor: _examPushedToLab - ? const Color(0xFF059669) - : const Color(0xFF4F46E5), - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10)), - ), - ), - ), - const SizedBox(width: 8), - ElevatedButton.icon( - onPressed: _showDualFormsPreview, - icon: const Icon(CupertinoIcons.doc_on_doc_fill, size: 16), - label: const Text('معاينة وطباعة النماذج (أ وب)', - style: TextStyle(fontSize: 12.5)), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF8B5CF6), - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric( - vertical: 12, horizontal: 14), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10)), - ), - ), - ], - ), - ], - ), - ), - const SizedBox(height: 18), - - // Smart Panoramic Surveillance Viewport - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFF0F172A), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: _isPanoramicActive - ? const Color(0xFFEF4444) - : const Color(0xFF1E293B), - width: _isPanoramicActive ? 2 : 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: _isPanoramicActive - ? const Color(0xFFEF4444) - : const Color(0xFF64748B), - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8), - const Text( - 'الرقابة البانورامية بتقنية التقطيع الزمني الذكي', - style: TextStyle( - fontSize: 13.5, - fontWeight: FontWeight.w700, - color: Colors.white), - ), - ], - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: const Color(0xFF1E293B), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - 'الحجم: ${_panoramicSizeMb.toStringAsFixed(1)} MB فقط', - style: const TextStyle( - fontSize: 11.5, - color: Color(0xFF38BDF8), - fontWeight: FontWeight.w700), - ), - ), - ], - ), - const SizedBox(height: 10), - const Text( - 'المبدأ: تظهر الكاميرا نشطة طوال الامتحان لفرض الردع النفسي، لكن التطبيق يقتطع ثانيتين فقط كل دقيقة لينتج مقطعاً مجمعاً مدته دقيقتان فقط بحجم 16.5 ميجابايت.', - style: TextStyle( - fontSize: 12, color: Color(0xFF94A3B8), height: 1.5), - ), - const SizedBox(height: 14), - - // Toggle Panoramic Button - ElevatedButton.icon( - onPressed: () async { - final exams = _dashboardData?['active_exams'] as List? ?? const []; - final sessionId = exams.isEmpty - ? 0 - : (Map.from(exams.first as Map)['session_id'] as num?)?.toInt() ?? 0; - if (sessionId == 0) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('لا توجد جلسة امتحان حقيقية لربط العينة بها.')), - ); - return; - } - final selected = await FilePicker.platform.pickFiles(type: FileType.video, withData: true); - if (selected == null || selected.files.isEmpty) return; - final file = selected.files.single; - setState(() { - _isPanoramicActive = true; - _panoramicSizeMb = file.size / 1048576; - }); - try { - await DirectorateApiService.uploadPanoramicSample( - sessionId: sessionId, - filePath: file.path, - fileBytes: file.bytes, - fileName: file.name, - ); - } finally { - if (mounted) setState(() => _isPanoramicActive = false); - } - }, - icon: Icon( - _isPanoramicActive - ? CupertinoIcons.eye_slash_fill - : CupertinoIcons.eye_fill, - size: 16), - label: Text( - _isPanoramicActive - ? 'إيقاف نمط الرقابة البانورامية' - : 'تفعيل نمط الرقابة البانورامية في القاعة 👁️', - style: const TextStyle( - fontSize: 13, fontWeight: FontWeight.w700), - ), - style: ElevatedButton.styleFrom( - backgroundColor: _isPanoramicActive - ? const Color(0xFFB91C1C) - : const Color(0xFF0284C7), - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10)), - ), - ), - ], - ), - ), - const SizedBox(height: 18), - - // Anomaly Indicator Summary & Statistical Detection - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFF0F172A), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFF1E293B)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - 'مؤشرات الذكاء الإحصائي لكشف الشذوذ والتواطؤ:', - style: TextStyle( - fontSize: 13.5, - fontWeight: FontWeight.w700, - color: Colors.white), - ), - ElevatedButton.icon( - onPressed: _isRunningIntegrityAudit ? null : _runIntegrityAudit, - icon: _isRunningIntegrityAudit - ? const SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) - : const Icon(CupertinoIcons.waveform_path_ecg, size: 15), - label: Text( - _isRunningIntegrityAudit ? 'جاري الفحص...' : 'فحص النزاهة اللحظي ⚡', - style: const TextStyle(fontSize: 11.5, fontWeight: FontWeight.w800), - ), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF0284C7), - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - ), - ), - ], - ), - const SizedBox(height: 12), - _buildAnomalyTile( - title: 'مؤشر السرعة المستحيلة (أقل من 15 ثانية لمسألة تفاضل معقدة)', - status: _integrityResult != null ? 'تم رصد حالة مشبوهة ⚠️' : 'سليم', - isGood: _integrityResult == null, - ), - _buildAnomalyTile( - title: 'مؤشر تكتل الأخطاء المتطابقة (خيارات خاطئة نادرة بين مقاعد متجاورة)', - status: _integrityResult != null ? 'تطابق في مقعد 05 و 06 ⚠️' : 'سليم', - isGood: _integrityResult == null, - ), - _buildAnomalyTile( - title: 'مؤشر القفزة التاريخية المفاجئة (ارتفاع 45% دفعة واحدة)', - status: _integrityResult != null ? 'حالة واحدة تتطلب مطابقة الخطوات الورقية ⚠️' : 'سليم', - isGood: _integrityResult == null, - ), - - // Live Anomalies Feed - if (_integrityResult != null && (_integrityResult!['anomalies'] as List? ?? []).isNotEmpty) ...[ - const Divider(color: Color(0xFF1E293B), height: 20), - const Text('🚨 تنبيهات الشذوذ المكتشفة ومقترحات المشرف:', style: TextStyle(fontSize: 12.5, fontWeight: FontWeight.w800, color: Color(0xFFEF4444))), - const SizedBox(height: 8), - ...((_integrityResult!['anomalies'] as List).take(2).map((anom) => Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: const Color(0xFF7F1D1D).withOpacity(0.25), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xFFEF4444).withOpacity(0.4)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('${anom['title']} · ${anom['student_name']} (${anom['seat_number']})', style: const TextStyle(fontSize: 11.5, fontWeight: FontWeight.w800, color: Color(0xFFFCA5A5))), - const SizedBox(height: 4), - Text(anom['details'] ?? '', style: const TextStyle(fontSize: 11, color: Colors.white70)), - const SizedBox(height: 4), - Text('الإجراء: ${anom['recommended_action']}', style: const TextStyle(fontSize: 10.5, color: Color(0xFFFBBF24), fontWeight: FontWeight.w700)), - ], - ), - ))), - ], - ], - ), - ), - const SizedBox(height: 18), - - // Monthly Parent Reports Dispatch Card (Nabeh WhatsApp Gateway) - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFF064E3B), Color(0xFF0F172A)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFF10B981).withOpacity(0.4)), - ), - child: Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: const Color(0xFF10B981).withOpacity(0.2), - shape: BoxShape.circle, - ), - child: const Icon(CupertinoIcons.chat_bubble_2_fill, color: Color(0xFF34D399), size: 22), - ), - const SizedBox(width: 14), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'بث تقارير أولياء الأمور عبر الواتساب (بوابة نبيه) 📲', - style: TextStyle(fontSize: 13.5, fontWeight: FontWeight.w800, color: Colors.white), - ), - SizedBox(height: 3), - Text( - 'إرسال ومضة رقمية شهرية لـ 450 ولي أمر تتضمن الحضور ودفتر الأخطاء ودرجات الامتحان الموحد.', - style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8), height: 1.3), - ), - ], - ), - ), - const SizedBox(width: 8), - ElevatedButton( - onPressed: _isDispatchingParentReports ? null : _dispatchParentReports, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF10B981), - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - child: _isDispatchingParentReports - ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.black)) - : const Text('بث الدفعة الآن', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w800)), - ), - ], - ), - ), - const SizedBox(height: 14), - - // Encrypted School Roster Import Card (AES-256-GCM) - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFF0F172A), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFF1E293B)), - ), - child: Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: const Color(0xFF38BDF8).withOpacity(0.2), - shape: BoxShape.circle, - ), - child: const Icon(CupertinoIcons.lock_shield_fill, color: Color(0xFF38BDF8), size: 22), - ), - const SizedBox(width: 14), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'استيراد كشف المدرسة وتشفير الأرقام الوطنية 🔒', - style: TextStyle(fontSize: 13.5, fontWeight: FontWeight.w800, color: Colors.white), - ), - SizedBox(height: 3), - Text( - 'تشفير فوري سيادي بـ AES-256-GCM مع التحقق من الـ 10 خانات الرقمية ومؤشر HMAC الأعمى.', - style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8), height: 1.3), - ), - ], - ), - ), - const SizedBox(width: 8), - ElevatedButton( - onPressed: _isImportingRoster ? null : _importSchoolRoster, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF0284C7), - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - child: _isImportingRoster - ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) - : const Text('استيراد الكشف', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w800)), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildAnomalyTile( - {required String title, required String status, required bool isGood}) { - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - children: [ - Icon( - isGood - ? CupertinoIcons.check_mark_circled_solid - : CupertinoIcons.exclamationmark_triangle_fill, - color: isGood ? const Color(0xFF10B981) : const Color(0xFFEF4444), - size: 16, - ), - const SizedBox(width: 8), - Expanded( - child: Text(title, - style: const TextStyle(fontSize: 12.5, color: Colors.white)), - ), - Text( - status, - style: TextStyle( - fontSize: 11.5, - fontWeight: FontWeight.w600, - color: isGood ? const Color(0xFF34D399) : const Color(0xFFF87171), - ), - ), - ], - ), - ); - } - - Widget _metricChip(String label, String value) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: const Color(0xFF1E293B), - borderRadius: BorderRadius.circular(6), - ), - child: Row( - children: [ - Text('$label: ', - style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8))), - Text(value, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - color: Colors.white)), - ], - ), - ); - } - - String _formatDuration(int seconds) { - final m = seconds ~/ 60; - final s = seconds % 60; - return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}'; + ), + ); + + Widget _notice(String title, String body) => Container(padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: const Color(0xFF4338CA).withOpacity(.2), borderRadius: BorderRadius.circular(12)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(title, style: const TextStyle(color: Color(0xFFC4B5FD), fontWeight: FontWeight.bold)), const SizedBox(height: 5), Text(body, style: const TextStyle(color: Colors.white70, height: 1.4))])); + Widget _operations(Map school, List> teachers, List> exams) { + final schoolId = (school['id'] as num?)?.toInt(); + if (schoolId == null) return _notice('الإجراءات غير متاحة', 'لم يعِد الخادم هوية مدرسة صالحة.'); + return Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + _notice('إجراءات خادمية فعلية', 'كل زر يرسل طلباً حقيقياً للخادم. لا توجد موافقات أو نتائج محلية.'), + if (_operationMessage != null) Padding(padding: const EdgeInsets.only(top: 8), child: Text(_operationMessage!, style: const TextStyle(color: Color(0xFFC4B5FD)))), + const SizedBox(height: 8), + Wrap(spacing: 8, runSpacing: 8, children: [ + OutlinedButton(onPressed: _busy ? null : () => _recordLesson(schoolId, teachers), child: const Text('رفع حصة مرصودة')), + OutlinedButton(onPressed: _busy ? null : () => _run('إرسال التقارير', () async { final result = await DirectorateApiService.dispatchParentReports(schoolId: schoolId); return 'أُرسل ${result['total_dispatched'] ?? 0} تقرير؛ فشل ${result['failed_count'] ?? 0}.'; }), child: const Text('إرسال تقارير الأولياء')), + OutlinedButton(onPressed: _busy ? null : () => _importRoster(schoolId), child: const Text('استيراد كشف JSON')), + ]), + if (exams.isNotEmpty) Padding(padding: const EdgeInsets.only(top: 8), child: Wrap(spacing: 8, runSpacing: 8, children: exams.where((exam) => exam['id'] is num).map((exam) => OutlinedButton(onPressed: _busy ? null : () => _run('تفعيل الامتحان', () async { await DirectorateApiService.pushExamToLab(examId: (exam['id'] as num).toInt(), schoolId: schoolId); return 'تم تفعيل الامتحان للمختبر المسجل.'; }), child: Text('تفعيل: ${exam['title'] ?? 'امتحان'}'))).toList())), + if (exams.any((exam) => exam['session_id'] is num)) Padding(padding: const EdgeInsets.only(top: 8), child: OutlinedButton(onPressed: _busy ? null : () => _uploadPanorama((exams.firstWhere((exam) => exam['session_id'] is num)['session_id'] as num).toInt()), child: const Text('رفع عينة بانورامية'))), + const SizedBox(height: 20), + ]); } + Widget _teacher(Map item) => Card(color: const Color(0xFF0F172A), child: ListTile(title: Text(item['name']?.toString() ?? 'معلم بدون اسم', style: const TextStyle(color: Colors.white)), subtitle: Text('${item['subject'] ?? 'المبحث غير متاح'} • ${item['quota_status'] ?? 'الحالة غير متاحة'}', style: const TextStyle(color: Colors.white60)))); + Widget _exam(Map item) => Card(color: const Color(0xFF0F172A), child: ListTile(title: Text(item['title']?.toString() ?? 'امتحان بدون عنوان', style: const TextStyle(color: Colors.white)), subtitle: Text('${item['subject'] ?? 'المبحث غير متاح'} • ${item['status'] ?? 'الحالة غير متاحة'}', style: const TextStyle(color: Colors.white60)))); } diff --git a/apps/admin_app/lib/services/directorate_api_service.dart b/apps/admin_app/lib/services/directorate_api_service.dart index 13db793..75041df 100644 --- a/apps/admin_app/lib/services/directorate_api_service.dart +++ b/apps/admin_app/lib/services/directorate_api_service.dart @@ -123,10 +123,9 @@ class DirectorateApiService { return _decode(response); } - static Future> fetchSchoolDashboard( - {int schoolId = 1}) async { + static Future> fetchSchoolDashboard({int? schoolId}) async { final uri = Uri.parse('$baseUrl/api/supervisor/school-dashboard').replace( - queryParameters: {'school_id': '$schoolId'}, + queryParameters: schoolId == null ? null : {'school_id': '$schoolId'}, ); final response = await http .get(uri, headers: await _headers()) @@ -242,8 +241,7 @@ class DirectorateApiService { return Map.from(data['data'] as Map? ?? const {}); } - static Future> dispatchParentReports( - {int schoolId = 1}) async { + static Future> dispatchParentReports({required int schoolId}) async { final response = await http .post( Uri.parse('$baseUrl/api/parent-reports/dispatch'), @@ -255,7 +253,7 @@ class DirectorateApiService { } static Future> importSchoolRoster({ - int schoolId = 1, + required int schoolId, required List> records, }) async { final response = await http diff --git a/apps/student_app/lib/data/models/subject_model.dart b/apps/student_app/lib/data/models/subject_model.dart index 73829b9..2414f3d 100644 --- a/apps/student_app/lib/data/models/subject_model.dart +++ b/apps/student_app/lib/data/models/subject_model.dart @@ -255,19 +255,25 @@ class CurriculumLessonItemModel { /// Model representing a Resource Item (PDF Textbook or Worksheet) class ResourceItemModel { final String title; - final String filePath; + final String assetId; + final String assetType; + final String mimeType; final String type; // textbook, worksheet, summary const ResourceItemModel({ required this.title, - required this.filePath, + required this.assetId, + required this.assetType, + required this.mimeType, this.type = 'textbook', }); factory ResourceItemModel.fromJson(Map json) { return ResourceItemModel( title: json['title']?.toString() ?? 'ملف وزاري', - filePath: json['file']?.toString() ?? '', + assetId: json['asset_id']?.toString() ?? '', + assetType: json['asset_type']?.toString() ?? 'other', + mimeType: json['mime_type']?.toString() ?? '', type: json['type']?.toString() ?? 'textbook', ); } 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 db0f260..a50d15b 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 @@ -28,7 +28,9 @@ class CurriculumDocumentViewerScreen extends StatefulWidget { final String documentType; // 'worksheet', 'summary', 'textbook', 'lesson' final String subjectTitle; final String? subjectId; - final String? filePath; + final String? assetId; + final String? assetType; + final String? mimeType; final String? customContent; final String? simulationSlug; @@ -38,7 +40,9 @@ class CurriculumDocumentViewerScreen extends StatefulWidget { required this.documentType, required this.subjectTitle, this.subjectId, - this.filePath, + this.assetId, + this.assetType, + this.mimeType, this.customContent, this.simulationSlug, }); @@ -68,11 +72,10 @@ class _CurriculumDocumentViewerScreenState extends State widget.subjectId ?? (_isEnglish ? 'english_10' : (_isPhysics ? 'physics_10' : 'math_10')); - String get _effectiveFilePath => widget.filePath ?? widget.title; + String get _effectiveFilePath => widget.assetId ?? ''; @override void initState() { @@ -110,16 +113,29 @@ class _CurriculumDocumentViewerScreenState extends State with SingleTickerPr /// Tab 2: Worksheets & Summaries Widget _buildWorksheetsTab(BuildContext context) { - final s = widget.subject.id.toLowerCase(); - final isMath = s.contains('math') || widget.subject.title.contains('رياضيات'); - final isPhys = s.contains('physic') || widget.subject.title.contains('فيزياء'); - final isEng = s.contains('english') || widget.subject.title.contains('إنجليز'); + final worksheets = widget.subject.worksheets; - final defaultWorksheets = isMath - ? [ - const ResourceItemModel(title: 'ورقة عمل 1: الأسس والأنظمة والمعادلات الخاصة', filePath: 'grade_10/math_10/semester_1/resources/worksheet_1.md', type: 'worksheet'), - const ResourceItemModel(title: 'ملخص شامل: قوانين المعادلات والتحليل إلى العوامل', filePath: 'math_summary.md', type: 'summary'), - const ResourceItemModel(title: 'مراجعة تدريبية: حل أنظمة المعادلات بيانياً وجبرياً', filePath: 'math_exam_prep.md', type: 'worksheet'), - ] - : (isPhys - ? [ - const ResourceItemModel(title: 'ورقة عمل وتطبيقات: تحليل المتجهات وقوانين نيوتن', filePath: 'physics_ws1.md', type: 'worksheet'), - const ResourceItemModel(title: 'ملخص شامل: الكميات القياسية والمتجهة والضرب النقطي', filePath: 'physics_summary.md', type: 'summary'), - const ResourceItemModel(title: 'دليل التجارب المخبرية: طاولة القوى والتسارع', filePath: 'physics_lab_guide.md', type: 'worksheet'), - ] - : (isEng - ? [ - const ResourceItemModel(title: 'Action Pack 10 — Practice Worksheet: Unit 01 (Looking Good)', filePath: 'english_ws1.md', type: 'worksheet'), - const ResourceItemModel(title: 'Grammar & Vocabulary Revision: Articles & First Impressions', filePath: 'english_summary.md', type: 'summary'), - const ResourceItemModel(title: 'Unit 02 Reading & Grammar Worksheet (The Digital Mind)', filePath: 'english_ws2.md', type: 'worksheet'), - ] - : [ - const ResourceItemModel(title: 'ورقة عمل 1: المفاهيم الأساسية والتطبيقات', filePath: 'ws1.md', type: 'worksheet'), - const ResourceItemModel(title: 'ملخص شامل: القوانين والمعادلات الوزارية المقررة', filePath: 'summary.md', type: 'summary'), - ])); - - final worksheets = widget.subject.worksheets.isNotEmpty ? widget.subject.worksheets : defaultWorksheets; + if (worksheets.isEmpty) { + return _buildUnavailableResourcesState( + icon: CupertinoIcons.doc_text, + message: 'لا توجد أوراق عمل منشورة لهذه المادة بعد.', + ); + } return ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20), @@ -416,7 +395,15 @@ class _SubjectHubScreenState extends State with SingleTickerPr IconButton( icon: const Icon(CupertinoIcons.arrow_down_circle_fill, color: AppColors.saqelCyan, size: 28), onPressed: () { - _showResourceSheet(context, ws.title, 'ورقة عمل ومذكرة مراجعة', 'PDF جاهز للطباعة بدقة عالية', filePath: ws.filePath); + _showResourceSheet( + context, + ws.title, + 'ورقة عمل منشورة', + 'مورد تمت مراجعته ونشره لهذه المادة.', + assetId: ws.assetId, + assetType: ws.assetType, + mimeType: ws.mimeType, + ); }, ), ], @@ -553,32 +540,14 @@ class _SubjectHubScreenState extends State with SingleTickerPr /// Tab 4: Official Ministry Textbooks Widget _buildTextbooksTab(BuildContext context) { - final s = widget.subject.id.toLowerCase(); - final isMath = s.contains('math') || widget.subject.title.contains('رياضيات'); - final isPhys = s.contains('physic') || widget.subject.title.contains('فيزياء'); - final isEng = s.contains('english') || widget.subject.title.contains('إنجليز'); + final textbooks = widget.subject.textbooks; - final defaultTextbooks = isMath - ? [ - const ResourceItemModel(title: 'كتاب الطالب المقرر — الرياضيات (أنظمة المعادلات والدائرة)', filePath: 'grade_10/math_10/semester_1/math_student_book.md', type: 'textbook'), - const ResourceItemModel(title: 'كتاب التمارين والأنشطة الإضافية — الرياضيات 10', filePath: 'grade_10/math_10/semester_1/math_workbook.md', type: 'textbook'), - ] - : (isPhys - ? [ - const ResourceItemModel(title: 'كتاب الفيزياء المقرر — الطالب (المتجهات والحركة)', filePath: 'grade_10/physics_10/semester_1/physics_student_book.md', type: 'textbook'), - const ResourceItemModel(title: 'كتاب التجارب والأنشطة العلمية والعملية (طاولة القوى)', filePath: 'grade_10/physics_10/semester_1/physics_activities_book.md', type: 'textbook'), - ] - : (isEng - ? [ - const ResourceItemModel(title: 'Action Pack 10 — Student\'s Book (Looking Good & The Digital Mind)', filePath: 'grade_10/english_10/semester_1/unit_01.md', type: 'textbook'), - const ResourceItemModel(title: 'Action Pack 10 — Activity Book & Literature Spot', filePath: 'grade_10/english_10/semester_1/activity_book.md', type: 'textbook'), - ] - : [ - const ResourceItemModel(title: 'كتاب الطالب المقرّر — منهاج وزارة التربية والتعليم', filePath: 'book.md', type: 'textbook'), - const ResourceItemModel(title: 'كتاب التجارب والأنشطة العلمية والعملية', filePath: 'workbook.md', type: 'textbook'), - ])); - - final textbooks = widget.subject.textbooks.isNotEmpty ? widget.subject.textbooks : defaultTextbooks; + if (textbooks.isEmpty) { + return _buildUnavailableResourcesState( + icon: CupertinoIcons.book, + message: 'لا يوجد كتاب أو ملف مصدر منشور لهذه المادة بعد.', + ); + } return ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20), @@ -619,7 +588,15 @@ class _SubjectHubScreenState extends State with SingleTickerPr IconButton( icon: const Icon(CupertinoIcons.eye_fill, color: AppColors.saqelCyan, size: 24), onPressed: () { - _showResourceSheet(context, tb.title, 'الكتاب المدرسي المعتمد', 'نسخة وزارة التربية والتعليم المنقحة والمحدثة', filePath: tb.filePath); + _showResourceSheet( + context, + tb.title, + 'كتاب أو ملف مصدر منشور', + 'مورد تمت مراجعته ونشره لهذه المادة.', + assetId: tb.assetId, + assetType: tb.assetType, + mimeType: tb.mimeType, + ); }, ), ], @@ -630,7 +607,35 @@ class _SubjectHubScreenState extends State with SingleTickerPr ); } - void _showResourceSheet(BuildContext context, String title, String subtitle, String description, {String? filePath}) { + Widget _buildUnavailableResourcesState({required IconData icon, required String message}) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: AppColors.textSecondaryDark, size: 34), + const SizedBox(height: 12), + Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 14, height: 1.5), + ), + ], + ), + ), + ); + } + + void _showResourceSheet( + BuildContext context, + String title, + String subtitle, + String description, { + required String assetId, + required String assetType, + required String mimeType, + }) { showModalBottomSheet( context: context, backgroundColor: AppColors.darkSurface, @@ -716,7 +721,9 @@ class _SubjectHubScreenState extends State with SingleTickerPr documentType: title.contains('كتاب') ? 'textbook' : 'worksheet', subjectTitle: widget.subject.title, subjectId: widget.subject.id, - filePath: filePath, + assetId: assetId, + assetType: assetType, + mimeType: mimeType, ), ), ); @@ -780,40 +787,9 @@ class _SubjectHubScreenState extends State with SingleTickerPr ), const SizedBox(height: 16), const Text( - 'شروحات هذا الدرس قيد التصوير والمراجعة من قبل نخبة المعلمين المعتمدين وفريق منصة صَقِل.\nيمكنك حالياً دراسة نتاجات وملخص الدرس عبر تبويب الكتب والمذكرات، أو خوض الاختبار التكيفي.', + 'لا يوجد شرح منشور لهذا الدرس بعد. ستظهر فقط الموارد التي تُراجع وتُنشر لهذا الدرس أو للمادة.', style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 13, height: 1.5), ), - const SizedBox(height: 24), - Row( - children: [ - Expanded( - 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, - filePath: lesson.markdownFilePath, - ), - ), - ); - }, - icon: const Icon(CupertinoIcons.book, size: 18), - label: const Text('قراءة محتوى وملخص الدرس 📖', style: TextStyle(fontWeight: FontWeight.w800)), - ), - ), - ], - ), ], ), ), diff --git a/apps/super_admin_app/lib/data/models/super_admin_models.dart b/apps/super_admin_app/lib/data/models/super_admin_models.dart index 1acb0e9..fe4c38d 100644 --- a/apps/super_admin_app/lib/data/models/super_admin_models.dart +++ b/apps/super_admin_app/lib/data/models/super_admin_models.dart @@ -11,12 +11,12 @@ class MacroTelemetryModel { final int totalSchools; final int totalStudents; final int totalTeachers; - final double grossMarginPercent; - final double treasuryBalanceJod; - final double totalCliqInflowJod; - final int pendingPayoutsCount; - final double r2BandwidthCostSavingsJod; - final double uptimePercent; + final double? grossMarginPercent; + final double? treasuryBalanceJod; + final double? totalCliqInflowJod; + final int? pendingPayoutsCount; + final double? r2BandwidthCostSavingsJod; + final double? uptimePercent; const MacroTelemetryModel({ required this.totalDirectorates, @@ -37,12 +37,12 @@ class MacroTelemetryModel { totalSchools: (json['total_schools'] as num?)?.toInt() ?? 0, totalStudents: (json['total_students'] as num?)?.toInt() ?? 0, totalTeachers: (json['total_teachers'] as num?)?.toInt() ?? 0, - grossMarginPercent: (json['gross_margin_percent'] as num?)?.toDouble() ?? 0, - treasuryBalanceJod: (json['treasury_balance_jod'] as num?)?.toDouble() ?? 0, - totalCliqInflowJod: (json['total_cliq_inflow_jod'] as num?)?.toDouble() ?? 0, - pendingPayoutsCount: (json['pending_payouts_count'] as num?)?.toInt() ?? 0, - r2BandwidthCostSavingsJod: (json['r2_cost_savings_jod'] as num?)?.toDouble() ?? 0, - uptimePercent: (json['uptime_percent'] as num?)?.toDouble() ?? 0, + grossMarginPercent: (json['gross_margin_percent'] as num?)?.toDouble(), + treasuryBalanceJod: (json['treasury_balance_jod'] as num?)?.toDouble(), + totalCliqInflowJod: (json['total_cliq_inflow_jod'] as num?)?.toDouble(), + pendingPayoutsCount: (json['pending_payouts_count'] as num?)?.toInt(), + r2BandwidthCostSavingsJod: (json['r2_cost_savings_jod'] as num?)?.toDouble(), + uptimePercent: (json['uptime_percent'] as num?)?.toDouble(), ); } } diff --git a/apps/super_admin_app/lib/logic/cubits/super_admin_cubit.dart b/apps/super_admin_app/lib/logic/cubits/super_admin_cubit.dart index 586254a..e02490c 100644 --- a/apps/super_admin_app/lib/logic/cubits/super_admin_cubit.dart +++ b/apps/super_admin_app/lib/logic/cubits/super_admin_cubit.dart @@ -26,19 +26,10 @@ class SuperAdminCubit extends Cubit { telemetry: telemetry, aiNodes: aiNodes, alerts: alerts, - isEmergencyKillSwitchActive: false, )); } catch (e) { emit(SuperAdminError('فشل تحميل لوحة القيادة السيادية: $e')); } } - void toggleEmergencyKillSwitch() { - if (state is SuperAdminLoaded) { - final cur = state as SuperAdminLoaded; - emit(cur.copyWith( - isEmergencyKillSwitchActive: !cur.isEmergencyKillSwitchActive, - )); - } - } } diff --git a/apps/super_admin_app/lib/logic/cubits/super_admin_state.dart b/apps/super_admin_app/lib/logic/cubits/super_admin_state.dart index 071398f..e8d5f01 100644 --- a/apps/super_admin_app/lib/logic/cubits/super_admin_state.dart +++ b/apps/super_admin_app/lib/logic/cubits/super_admin_state.dart @@ -12,26 +12,22 @@ class SuperAdminLoaded extends SuperAdminState { final MacroTelemetryModel telemetry; final List aiNodes; final List alerts; - final bool isEmergencyKillSwitchActive; const SuperAdminLoaded({ required this.telemetry, required this.aiNodes, required this.alerts, - this.isEmergencyKillSwitchActive = false, }); SuperAdminLoaded copyWith({ MacroTelemetryModel? telemetry, List? aiNodes, List? alerts, - bool? isEmergencyKillSwitchActive, }) { return SuperAdminLoaded( telemetry: telemetry ?? this.telemetry, aiNodes: aiNodes ?? this.aiNodes, alerts: alerts ?? this.alerts, - isEmergencyKillSwitchActive: isEmergencyKillSwitchActive ?? this.isEmergencyKillSwitchActive, ); } } diff --git a/apps/super_admin_app/lib/logic/cubits/treasury_cubit.dart b/apps/super_admin_app/lib/logic/cubits/treasury_cubit.dart index 27eaaf1..665ddad 100644 --- a/apps/super_admin_app/lib/logic/cubits/treasury_cubit.dart +++ b/apps/super_admin_app/lib/logic/cubits/treasury_cubit.dart @@ -12,24 +12,20 @@ class TreasuryLoading extends TreasuryState {} class TreasuryLoaded extends TreasuryState { final List queue; - final double totalTreasuryBalanceJod; - final double totalApprovedTodayJod; + final double? totalTreasuryBalanceJod; const TreasuryLoaded({ required this.queue, required this.totalTreasuryBalanceJod, - required this.totalApprovedTodayJod, }); TreasuryLoaded copyWith({ List? queue, double? totalTreasuryBalanceJod, - double? totalApprovedTodayJod, }) { return TreasuryLoaded( queue: queue ?? this.queue, totalTreasuryBalanceJod: totalTreasuryBalanceJod ?? this.totalTreasuryBalanceJod, - totalApprovedTodayJod: totalApprovedTodayJod ?? this.totalApprovedTodayJod, ); } } @@ -42,69 +38,21 @@ class TreasuryCubit extends Cubit { Future loadTreasury() async { emit(TreasuryLoading()); try { - final queue = await repository.getPayoutQueue(); + final results = await Future.wait([repository.getPayoutQueue(), repository.getMacroTelemetry()]); + final queue = results[0] as List; + final telemetry = results[1] as MacroTelemetryModel; emit(TreasuryLoaded( queue: queue, - totalTreasuryBalanceJod: 54200.0, - totalApprovedTodayJod: 0.0, - )); - } catch (_) { - emit(const TreasuryLoaded( - queue: [], - totalTreasuryBalanceJod: 54200.0, - totalApprovedTodayJod: 0.0, + totalTreasuryBalanceJod: telemetry.treasuryBalanceJod, )); + } catch (error) { + emit(TreasuryError('تعذر تحميل بيانات الخزينة: $error')); } } - void approvePayout(int payoutId) { - if (state is TreasuryLoaded) { - final cur = state as TreasuryLoaded; - double approvedAmt = 0.0; +} - final updated = cur.queue.map((item) { - if (item.id == payoutId) { - approvedAmt = item.amountJod; - return PayoutQueueItemModel( - id: item.id, - teacherName: item.teacherName, - cliqAlias: item.cliqAlias, - amountJod: item.amountJod, - requestedAt: item.requestedAt, - status: 'completed', - ); - } - return item; - }).toList(); - - emit(cur.copyWith( - queue: updated, - totalApprovedTodayJod: cur.totalApprovedTodayJod + approvedAmt, - )); - } - } - - void approveAllPayouts() { - if (state is TreasuryLoaded) { - final cur = state as TreasuryLoaded; - double sum = 0.0; - - final updated = cur.queue.map((item) { - if (item.status == 'queued') sum += item.amountJod; - return PayoutQueueItemModel( - id: item.id, - teacherName: item.teacherName, - cliqAlias: item.cliqAlias, - amountJod: item.amountJod, - requestedAt: item.requestedAt, - status: 'completed', - ); - }).toList(); - - emit(cur.copyWith( - queue: updated, - totalApprovedTodayJod: cur.totalApprovedTodayJod + sum, - )); - } - } +class TreasuryError extends TreasuryState { + final String message; + const TreasuryError(this.message); } diff --git a/apps/super_admin_app/lib/presentation/screens/super_admin_shell.dart b/apps/super_admin_app/lib/presentation/screens/super_admin_shell.dart index 44e66cc..8ea8317 100644 --- a/apps/super_admin_app/lib/presentation/screens/super_admin_shell.dart +++ b/apps/super_admin_app/lib/presentation/screens/super_admin_shell.dart @@ -73,28 +73,11 @@ class _SuperAdminShellState extends State { letterSpacing: 0.3, ), ), - const Spacer(), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: SuperAdminTheme.emeraldGreen.withOpacity(0.15), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: SuperAdminTheme.emeraldGreen.withOpacity(0.3)), - ), - child: const Row( - mainAxisSize: MainAxisSize.min, - children: [ - CircleAvatar(radius: 3, backgroundColor: SuperAdminTheme.emeraldGreen), - SizedBox(width: 5), - Text('Sovereign Live', style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: SuperAdminTheme.emeraldGreen)), - ], - ), - ), ], ), const SizedBox(height: 3), const Text( - 'المؤسس والمهندس المعماري: حمزة العائد (Hamza Ayed)', + 'البيانات المعروضة تعتمد على مصادر الخادم المتصلة فقط', style: TextStyle(fontSize: 11.5, color: Colors.white60), ), ], @@ -155,10 +138,7 @@ class _SuperAdminShellState extends State { MacroRadarTab(telemetry: state.telemetry), AiClusterTab(nodes: state.aiNodes), const TreasuryCliqTab(), - SecurityIntegrityTab( - alerts: state.alerts, - isKillSwitchActive: state.isEmergencyKillSwitchActive, - ), + SecurityIntegrityTab(alerts: state.alerts), const OrganizationTab(), ], ); diff --git a/apps/super_admin_app/lib/presentation/screens/tabs/ai_cluster_tab.dart b/apps/super_admin_app/lib/presentation/screens/tabs/ai_cluster_tab.dart index 4e30213..0d3c5af 100644 --- a/apps/super_admin_app/lib/presentation/screens/tabs/ai_cluster_tab.dart +++ b/apps/super_admin_app/lib/presentation/screens/tabs/ai_cluster_tab.dart @@ -1,284 +1,29 @@ -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; + import '../../../core/theme/super_admin_theme.dart'; import '../../../data/models/super_admin_models.dart'; -/** - * ============================================================================== - * SAQEL SOVEREIGN COMMAND - LOCAL AI & GPU CLUSTER TAB - * ============================================================================== - * - * رصد فوري لعناقيد الذكاء الاصطناعي السيادي المحلي: - * - نماذج Qwen 2.5-VL (تحليل ومراجعة كراسات الحصص والمحتوى المرئي) - * - نموذج DeepSeek-R1 (المحاكمة المنطقية والاستدلال التربوي والردود السقراطية) - * - قياس استهلاك الذاكرة الرسومية VRAM وزمن الاستجابة (Latency ms) - * - ضمان السيادة الرقمية التامة (Zero-Egress Sovereignty) بدون تسريب أي بيانات للخارج. - */ class AiClusterTab extends StatelessWidget { final List nodes; - const AiClusterTab({super.key, required this.nodes}); @override - Widget build(BuildContext context) { - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Sovereign AI Sovereignty Moat Card - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFF0F172A), Color(0xFF1E293B)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: SuperAdminTheme.cyberCyan.withOpacity(0.4)), - boxShadow: [ - BoxShadow( - color: SuperAdminTheme.cyberCyan.withOpacity(0.12), - blurRadius: 18, - offset: const Offset(0, 6), - ) - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Row( - children: [ - Icon(CupertinoIcons.sparkles, color: SuperAdminTheme.cyberCyan, size: 22), - SizedBox(width: 8), - Text( - 'عنقود الذكاء الاصطناعي السيادي (Local Inference)', - style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.white), - ), - ], - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: SuperAdminTheme.emeraldGreen.withOpacity(0.2), - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - '100% Zero-Egress', - style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: SuperAdminTheme.emeraldGreen), - ), - ), - ], - ), - const SizedBox(height: 12), - const Text( - 'يعمل هذا العنقود داخل البنية التحتية الوطنية المغلقة. يتم معالجة تسجيلات الحصص المدرسية وكراسات التقييم باستخدام نماذج Qwen 2.5-VL و DeepSeek-R1 دون خروج أي بايت إلى خوادم أجنبية.', - style: TextStyle(fontSize: 12.5, color: Colors.white70, height: 1.5), - ), - const SizedBox(height: 16), - Row( - children: [ - _buildQuickMetric('إجمالي العقد النشطة', '${nodes.length} عقد مخصصة', CupertinoIcons.layers_alt_fill), - const SizedBox(width: 12), - _buildQuickMetric('متوسط زمن الاستجابة', '120 ms', CupertinoIcons.bolt_fill), - ], - ), - ], - ), - ), - const SizedBox(height: 20), + Widget build(BuildContext context) => ListView(padding: const EdgeInsets.all(16), children: [ + const Text('عقد الذكاء الاصطناعي', style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 6), + const Text('تعرض هذه الشاشة فقط العقد المعرّفة من مصدر تشغيل الخادم. لا تستنتج مكان المعالجة أو زمن الاستجابة أو سياسة البيانات عند غياب telemetry حقيقية.', style: TextStyle(color: Colors.white60, height: 1.4)), + const SizedBox(height: 16), + if (nodes.isEmpty) const Padding(padding: EdgeInsets.all(24), child: Center(child: Text('لا توجد بيانات عقد متاحة من الخادم.', style: TextStyle(color: Colors.white60)))) else ...nodes.map(_node), + ]); - // Nodes List Section Title - const Row( - children: [ - Icon(CupertinoIcons.circle_grid_hex_fill, color: SuperAdminTheme.royalGold, size: 18), - SizedBox(width: 8), - Text( - 'حالة عقد المعالجة الرسومية (GPU Nodes Telemetry)', - style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.white), - ), - ], - ), - const SizedBox(height: 12), - - // Render Nodes - ...nodes.map((node) => _buildNodeCard(node)), - - const SizedBox(height: 24), - - // Inference Model Architecture Guide - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: SuperAdminTheme.surfaceCard, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Colors.white10), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'توزيع مهام النماذج المتخصصة:', - style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.white), - ), - const SizedBox(height: 10), - _buildModelArchitectureRow( - 'Qwen 2.5-VL 7B / 72B', - 'تحليل كراسات الطلاب، تدقيق فيديوهات الحصص (MIT 20-25 Min Limit)، واستخراج الرسوم التوضيحية.', - SuperAdminTheme.cyberCyan, - ), - const Divider(color: Colors.white10, height: 20), - _buildModelArchitectureRow( - 'DeepSeek-R1 (Distill / Dense)', - 'الاستدلال الرياضي المتقدم، المحاكمة المنطقية، وبناء الحوارات السقراطية لغرف التساؤلات المدرسية.', - SuperAdminTheme.imperialPurple, - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildQuickMetric(String label, String value, IconData icon) { - return Expanded( - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.3), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.white12), - ), - child: Row( - children: [ - Icon(icon, size: 18, color: SuperAdminTheme.cyberCyan), - const SizedBox(width: 8), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: const TextStyle(fontSize: 11, color: Colors.white54)), - const SizedBox(height: 2), - Text(value, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.white)), - ], - ), - ], - ), - ), - ); - } - - Widget _buildNodeCard(AiClusterNodeModel node) { - final double vramPercentage = (node.gpuVramUsageGb / node.gpuTotalVramGb).clamp(0.0, 1.0); - final Color progressColor = vramPercentage > 0.85 - ? SuperAdminTheme.crimsonRed - : (vramPercentage > 0.65 ? SuperAdminTheme.royalGold : SuperAdminTheme.emeraldGreen); - - return Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: SuperAdminTheme.surfaceCard, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Colors.white12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: node.status == 'active_online' ? SuperAdminTheme.emeraldGreen : SuperAdminTheme.royalGold, - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: (node.status == 'active_online' ? SuperAdminTheme.emeraldGreen : SuperAdminTheme.royalGold).withOpacity(0.6), - blurRadius: 6, - ) - ], - ), - ), - const SizedBox(width: 8), - Text( - node.nodeName, - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.white), - ), - ], - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: Colors.black45, - borderRadius: BorderRadius.circular(6), - border: Border.all(color: Colors.white12), - ), - child: Text( - '${node.latencyMs} ms', - style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: SuperAdminTheme.cyberCyan), - ), - ), - ], - ), - const SizedBox(height: 8), - Text( - '${node.modelName} • ${node.role}', - style: const TextStyle(fontSize: 12, color: Colors.white70), - ), - const SizedBox(height: 12), - - // VRAM Progress - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('استهلاك VRAM للبطاقة الرسومية', style: TextStyle(fontSize: 11.5, color: Colors.white54)), - Text( - '${node.gpuVramUsageGb.toStringAsFixed(1)} GB / ${node.gpuTotalVramGb.toStringAsFixed(0)} GB (${(vramPercentage * 100).toStringAsFixed(0)}%)', - style: TextStyle(fontSize: 11.5, fontWeight: FontWeight.w600, color: progressColor), - ), - ], - ), - const SizedBox(height: 6), - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: vramPercentage, - minHeight: 6, - backgroundColor: Colors.white10, - valueColor: AlwaysStoppedAnimation(progressColor), - ), - ), - ], - ), - ); - } - - Widget _buildModelArchitectureRow(String model, String role, Color accentColor) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(CupertinoIcons.checkmark_seal_fill, size: 16, color: accentColor), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(model, style: TextStyle(fontSize: 12.5, fontWeight: FontWeight.bold, color: accentColor)), - const SizedBox(height: 2), - Text(role, style: const TextStyle(fontSize: 11.5, color: Colors.white60, height: 1.4)), - ], - ), - ), - ], - ); + Widget _node(AiClusterNodeModel node) { + final usableVram = node.gpuTotalVramGb > 0; + final ratio = usableVram ? (node.gpuVramUsageGb / node.gpuTotalVramGb).clamp(0.0, 1.0) : 0.0; + return Card(color: SuperAdminTheme.surfaceCard, child: Padding(padding: const EdgeInsets.all(16), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(node.nodeName, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + const SizedBox(height: 4), Text('${node.modelName} • ${node.role} • ${node.status}', style: const TextStyle(color: Colors.white60)), + if (usableVram) ...[const SizedBox(height: 12), LinearProgressIndicator(value: ratio, color: SuperAdminTheme.cyberCyan), const SizedBox(height: 4), Text('${node.gpuVramUsageGb.toStringAsFixed(1)} / ${node.gpuTotalVramGb.toStringAsFixed(1)} GB', style: const TextStyle(color: Colors.white60))], + if (node.latencyMs > 0) Padding(padding: const EdgeInsets.only(top: 8), child: Text('${node.latencyMs} ms', style: const TextStyle(color: Colors.white60))), + ]))); } } diff --git a/apps/super_admin_app/lib/presentation/screens/tabs/macro_radar_tab.dart b/apps/super_admin_app/lib/presentation/screens/tabs/macro_radar_tab.dart index 4317379..875c4ad 100644 --- a/apps/super_admin_app/lib/presentation/screens/tabs/macro_radar_tab.dart +++ b/apps/super_admin_app/lib/presentation/screens/tabs/macro_radar_tab.dart @@ -1,228 +1,26 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; + import '../../../core/theme/super_admin_theme.dart'; import '../../../data/models/super_admin_models.dart'; class MacroRadarTab extends StatelessWidget { final MacroTelemetryModel telemetry; - const MacroRadarTab({super.key, required this.telemetry}); @override - Widget build(BuildContext context) { - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Unit Economics & Gross Margin Sovereign Moat Banner - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFF1E1B4B), Color(0xFF0F172A)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: SuperAdminTheme.imperialPurple.withOpacity(0.5)), - boxShadow: [ - BoxShadow( - color: SuperAdminTheme.imperialPurple.withOpacity(0.15), - blurRadius: 20, - offset: const Offset(0, 8), - ) - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Row( - children: [ - Icon(CupertinoIcons.sparkles, color: SuperAdminTheme.royalGold, size: 20), - SizedBox(width: 8), - Text( - 'حماية هوامش الربح ووحدة الاقتصاد (Unit Economics)', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.w800, color: Colors.white), - ), - ], - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: SuperAdminTheme.emeraldGreen.withOpacity(0.2), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - 'هامش ربح ${telemetry.grossMarginPercent}%', - style: const TextStyle(fontSize: 12.5, fontWeight: FontWeight.w900, color: SuperAdminTheme.emeraldGreen), - ), - ), - ], - ), - const SizedBox(height: 14), - const Text( - 'وفر معمارية صَقِل السيادية مقارنة بالمنصات التقليدية:\n' - '• خفض تكلفة الخرائط والبث بمقدار 0.30 دولار لكل طالب.\n' - '• استبدال اشتراكات السحابة الأجنبية بالذكاء الاصطناعي المحلي (Qwen/DeepSeek).\n' - '• التخزين السيادي عبر كودك البث المشفر لتوفير آلاف الدنانير شهرياً.', - style: TextStyle(fontSize: 12.5, color: Color(0xFFCBD5E1), height: 1.5), - ), - ], - ), - ), - const SizedBox(height: 16), + Widget build(BuildContext context) => ListView(padding: const EdgeInsets.all(16), children: [ + const Text('المؤشرات المتاحة من الخادم', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 18)), + const SizedBox(height: 6), + const Text('لا تعرض هذه الشاشة إلا عدادات قاعدة البيانات. القياسات المالية والتشغيلية تبقى غير متاحة حتى ربط مصادرها الموثقة.', style: TextStyle(color: Colors.white60, height: 1.4)), + const SizedBox(height: 16), + _grid(), + const SizedBox(height: 18), + _unavailable('المالية والتسوية', telemetry.treasuryBalanceJod == null ? 'غير متاحة: مزود الدفع ودفتر التسوية غير مربوطين.' : '${telemetry.treasuryBalanceJod} د.أ'), + _unavailable('مراقبة الاستقرار', telemetry.uptimePercent == null ? 'غير متاحة: لا يوجد مزود مراقبة متصل.' : '${telemetry.uptimePercent}%'), + ]); - // Core Metric Tiles (2x2 Grid) - Row( - children: [ - Expanded( - child: _metricCard( - title: 'إجمالي المدارس', - value: '${telemetry.totalSchools} مدرسة', - subtitle: '43 ثقافة عسكرية + مجمعات خاصة', - icon: CupertinoIcons.building_2_fill, - color: SuperAdminTheme.cyberCyan, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _metricCard( - title: 'الطلبة المسجلون', - value: '${telemetry.totalStudents}', - subtitle: '19,350 برقم وطني مشفر', - icon: CupertinoIcons.person_3_fill, - color: SuperAdminTheme.imperialPurple, - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: _metricCard( - title: 'الكادر التعليمي', - value: '${telemetry.totalTeachers} معلماً', - subtitle: 'معتمدون ومصنفون بالجدارة', - icon: CupertinoIcons.star_circle_fill, - color: SuperAdminTheme.royalGold, - ), - ), - const SizedBox(width: 12), - Expanded( - child: _metricCard( - title: 'استقرار النظام', - value: '${telemetry.uptimePercent}%', - subtitle: 'خوادم سيادية بلا انقطاع', - icon: CupertinoIcons.checkmark_shield_fill, - color: SuperAdminTheme.emeraldGreen, - ), - ), - ], - ), - const SizedBox(height: 16), - - // Directorate Matrix Overview - Container( - padding: const EdgeInsets.all(18), - decoration: BoxDecoration( - color: SuperAdminTheme.surfaceCard, - borderRadius: BorderRadius.circular(18), - border: Border.all(color: SuperAdminTheme.border), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'المظلات المركزية المعتمدة في المنظومة 🏛️', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.w800, color: Colors.white), - ), - const SizedBox(height: 12), - _directorateRow( - name: 'مديرية التربية والتعليم والثقافة العسكرية', - schools: 43, - students: 19350, - badge: 'عقد مؤسسي سيادي', - color: SuperAdminTheme.emeraldGreen, - ), - const Divider(color: SuperAdminTheme.border, height: 20), - _directorateRow( - name: 'مجمعات المدارس الخاصة المعتمدة (النمو السحابي)', - schools: 12, - students: 4200, - badge: 'سوق صَقِل المفتوح', - color: SuperAdminTheme.cyberCyan, - ), - ], - ), - ), - ], - ), - ); - } - - Widget _metricCard({ - required String title, - required String value, - required String subtitle, - required IconData icon, - required Color color, - }) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: SuperAdminTheme.surfaceCard, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: SuperAdminTheme.border), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(icon, color: color, size: 24), - const SizedBox(height: 10), - Text(title, style: const TextStyle(fontSize: 12, color: Color(0xFF94A3B8))), - const SizedBox(height: 4), - Text(value, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w900, color: Colors.white)), - const SizedBox(height: 2), - Text(subtitle, style: const TextStyle(fontSize: 10.5, color: Color(0xFF64748B))), - ], - ), - ); - } - - Widget _directorateRow({ - required String name, - required int schools, - required int students, - required String badge, - required Color color, - }) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(name, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: Colors.white)), - const SizedBox(height: 3), - Text('$schools مدرسة · $students طالباً', style: const TextStyle(fontSize: 11, color: Color(0xFF94A3B8))), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: color.withOpacity(0.15), - borderRadius: BorderRadius.circular(6), - ), - child: Text(badge, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: color)), - ), - ], - ); - } + Widget _grid() => GridView.count(crossAxisCount: 2, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), childAspectRatio: 1.55, children: [_metric('المديريات', telemetry.totalDirectorates, CupertinoIcons.building_2_fill), _metric('المدارس', telemetry.totalSchools, CupertinoIcons.building_2_fill), _metric('الطلبة', telemetry.totalStudents, CupertinoIcons.person_3_fill), _metric('المعلمون', telemetry.totalTeachers, CupertinoIcons.person_badge_plus_fill)]); + Widget _metric(String label, int value, IconData icon) => Card(color: SuperAdminTheme.surfaceCard, child: Padding(padding: const EdgeInsets.all(14), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Icon(icon, color: SuperAdminTheme.cyberCyan), const Spacer(), Text(label, style: const TextStyle(color: Colors.white60)), Text('$value', style: const TextStyle(color: Colors.white, fontSize: 22, fontWeight: FontWeight.bold))]))); + Widget _unavailable(String label, String value) => Padding(padding: const EdgeInsets.only(bottom: 10), child: ListTile(tileColor: SuperAdminTheme.surfaceCard, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), title: Text(label, style: const TextStyle(color: Colors.white)), subtitle: Text(value, style: const TextStyle(color: Colors.white60)))); } diff --git a/apps/super_admin_app/lib/presentation/screens/tabs/security_integrity_tab.dart b/apps/super_admin_app/lib/presentation/screens/tabs/security_integrity_tab.dart index 5eed7c9..44f2853 100644 --- a/apps/super_admin_app/lib/presentation/screens/tabs/security_integrity_tab.dart +++ b/apps/super_admin_app/lib/presentation/screens/tabs/security_integrity_tab.dart @@ -1,257 +1,24 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; + import '../../../core/theme/super_admin_theme.dart'; import '../../../data/models/super_admin_models.dart'; -import '../../../logic/cubits/super_admin_cubit.dart'; -/** - * ============================================================================== - * SAQEL SOVEREIGN COMMAND - CYBER & EXAM INTEGRITY TAB - * ============================================================================== - * - * رادار النزاهة الأكاديمية والأمن السيبراني السيادي: - * - تشفير الأرقام الوطنية AES-256-GCM وحماية السجلات من أي تسريب - * - مراقبة فحص الترفع الصفي والتحقق من صلاحيات الدخول المؤسسي - * - إنذارات فورية لمحاولات الغش وحل الاختبارات بسرعة مستحيلة فلكياً (Impossible Speed) - * - زر الإغلاق السيادي الطارئ (Emergency Sovereign Kill-Switch). - */ class SecurityIntegrityTab extends StatelessWidget { final List alerts; - final bool isKillSwitchActive; - - const SecurityIntegrityTab({ - super.key, - required this.alerts, - required this.isKillSwitchActive, - }); + const SecurityIntegrityTab({super.key, required this.alerts}); @override - Widget build(BuildContext context) { - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Emergency Kill-Switch Card - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - color: isKillSwitchActive ? SuperAdminTheme.crimsonRed.withOpacity(0.2) : SuperAdminTheme.surfaceCard, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: isKillSwitchActive ? SuperAdminTheme.crimsonRed : Colors.white12, - width: 1.5, - ), - ), - child: Row( - children: [ - Icon( - isKillSwitchActive ? CupertinoIcons.exclamationmark_octagon_fill : CupertinoIcons.shield_fill, - color: isKillSwitchActive ? SuperAdminTheme.crimsonRed : SuperAdminTheme.emeraldGreen, - size: 32, - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - isKillSwitchActive ? 'وضع العزل الطارئ نشط (Kill-Switch Active)' : 'منظومة الدفاع السيادية تعمل بنجاح', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: isKillSwitchActive ? SuperAdminTheme.crimsonRed : Colors.white, - ), - ), - const SizedBox(height: 3), - Text( - isKillSwitchActive - ? 'تم إيقاف استلام الطلبات الخارجية وتجميد جلسات الامتحانات احترازياً.' - : 'جميع جلسات الاختبارات مشفرة وتخضع لرقابة النزاهة اللحظية.', - style: const TextStyle(fontSize: 11.5, color: Colors.white60), - ), - ], - ), - ), - const SizedBox(width: 8), - CupertinoSwitch( - value: isKillSwitchActive, - activeColor: SuperAdminTheme.crimsonRed, - onChanged: (val) { - context.read().toggleEmergencyKillSwitch(); - }, - ), - ], - ), - ), - const SizedBox(height: 20), + Widget build(BuildContext context) => ListView(padding: const EdgeInsets.all(16), children: [ + const Text('الأمن والنزاهة', style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + _notice('لا توجد مراقبة أمنية موثقة متصلة حالياً. لذلك لا تعني القائمة الفارغة أن النظام آمن أو أن الإنذارات تساوي صفراً.'), + const SizedBox(height: 18), + const Text('الإنذارات المسجلة', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + const SizedBox(height: 10), + if (alerts.isEmpty) const Text('مصدر إنذارات النزاهة غير متاح حالياً.', style: TextStyle(color: Colors.white60)) else ...alerts.map(_alert), + ]); - // Sovereign Encryption & Integrity Badges - Row( - children: [ - _buildSecurityStatBadge( - 'تشفير الأرقام الوطنية', - 'AES-256-GCM', - 'صفر تسريب بيانات', - SuperAdminTheme.cyberCyan, - CupertinoIcons.lock_shield_fill, - ), - const SizedBox(width: 12), - _buildSecurityStatBadge( - 'بوابة الحصص والصفوف', - 'Grade-Gate 100%', - 'فصل تام للصفوف 8 - 12', - SuperAdminTheme.emeraldGreen, - CupertinoIcons.checkmark_seal_fill, - ), - ], - ), - const SizedBox(height: 24), - - // Security Incidents Header - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Row( - children: [ - Icon(CupertinoIcons.shield_slash_fill, color: SuperAdminTheme.royalGold, size: 18), - SizedBox(width: 8), - Text( - 'إنذارات النزاهة السيادية اللحظية', - style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.white), - ), - ], - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: SuperAdminTheme.crimsonRed.withOpacity(0.15), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - '${alerts.length} إنذارات', - style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: SuperAdminTheme.crimsonRed), - ), - ), - ], - ), - const SizedBox(height: 12), - - // Alerts List - ...alerts.map((alert) => _buildAlertCard(alert)), - - const SizedBox(height: 20), - - // Audit Log Integrity Explanation - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: SuperAdminTheme.surfaceCard, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Colors.white10), - ), - child: const Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(CupertinoIcons.eye_solid, size: 16, color: SuperAdminTheme.cyberCyan), - SizedBox(width: 8), - Text( - 'خوارزمية كشف الشذوذ الأكاديمي (Anomaly Detection):', - style: TextStyle(fontSize: 12.5, fontWeight: FontWeight.bold, color: Colors.white), - ), - ], - ), - SizedBox(height: 8), - Text( - 'تقوم المنظومة بمقارنة زمن حل الطالب لكل سؤال رياضي بالحد الأدنى المعرفي. إذا تم تقديم اختبار يحتوي 30 مسألة تفاضل في أقل من 12 ثانية، يُصنف الاختبار فوراً كـ Anomaly ويتم تجميد العلامة لتدقيق المعلم والمشرف.', - style: TextStyle(fontSize: 11.5, color: Colors.white60, height: 1.5), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildSecurityStatBadge(String title, String value, String subtitle, Color color, IconData icon) { - return Expanded( - child: Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: SuperAdminTheme.surfaceCard, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: color.withOpacity(0.3)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(icon, color: color, size: 20), - const SizedBox(height: 10), - Text(value, style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: color)), - const SizedBox(height: 2), - Text(title, style: const TextStyle(fontSize: 11.5, fontWeight: FontWeight.w600, color: Colors.white)), - const SizedBox(height: 2), - Text(subtitle, style: const TextStyle(fontSize: 10, color: Colors.white54)), - ], - ), - ), - ); - } - - Widget _buildAlertCard(SecurityIntegrityAlertModel alert) { - final bool isCritical = alert.severity == 'critical'; - final Color alertColor = isCritical ? SuperAdminTheme.crimsonRed : SuperAdminTheme.royalGold; - - return Container( - margin: const EdgeInsets.only(bottom: 10), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: SuperAdminTheme.surfaceCard, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: alertColor.withOpacity(0.35)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - Icon( - isCritical ? CupertinoIcons.exclamationmark_triangle_fill : CupertinoIcons.bell_fill, - color: alertColor, - size: 16, - ), - const SizedBox(width: 8), - Text( - alert.title, - style: const TextStyle(fontSize: 13.5, fontWeight: FontWeight.bold, color: Colors.white), - ), - ], - ), - Text( - alert.timeAgo, - style: const TextStyle(fontSize: 11, color: Colors.white38), - ), - ], - ), - const SizedBox(height: 6), - Text( - 'الموقع / المدرسة: ${alert.schoolName}', - style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: SuperAdminTheme.cyberCyan), - ), - const SizedBox(height: 4), - Text( - alert.details, - style: const TextStyle(fontSize: 11.5, color: Colors.white70, height: 1.4), - ), - ], - ), - ); - } + Widget _notice(String text) => Container(padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: SuperAdminTheme.royalGold.withOpacity(.12), borderRadius: BorderRadius.circular(14)), child: Row(children: [const Icon(CupertinoIcons.exclamationmark_triangle, color: SuperAdminTheme.royalGold), const SizedBox(width: 10), Expanded(child: Text(text, style: const TextStyle(color: Colors.white70, height: 1.4)))])); + Widget _alert(SecurityIntegrityAlertModel item) => Card(color: SuperAdminTheme.surfaceCard, child: ListTile(title: Text(item.title, style: const TextStyle(color: Colors.white)), subtitle: Text('${item.schoolName}\n${item.details}', style: const TextStyle(color: Colors.white60)), trailing: Text(item.severity, style: const TextStyle(color: SuperAdminTheme.royalGold)))); } diff --git a/apps/super_admin_app/lib/presentation/screens/tabs/treasury_cliq_tab.dart b/apps/super_admin_app/lib/presentation/screens/tabs/treasury_cliq_tab.dart index 6cc6b42..b7fecbc 100644 --- a/apps/super_admin_app/lib/presentation/screens/tabs/treasury_cliq_tab.dart +++ b/apps/super_admin_app/lib/presentation/screens/tabs/treasury_cliq_tab.dart @@ -1,21 +1,12 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; + import '../../../core/theme/super_admin_theme.dart'; import '../../../data/models/super_admin_models.dart'; import '../../../logic/cubits/treasury_cubit.dart'; -/** - * ============================================================================== - * SAQEL SOVEREIGN COMMAND - TREASURY & CLIQ PAYOUT QUEUE TAB - * ============================================================================== - * - * إدارة الخزينة المركزية السيادية والموافقة الفورية على دفعات المعلمين عبر CliQ: - * - رصيد الخزينة الإجمالي (54,200 دينار أردني) - * - موافقة جماعية بنقرة واحدة (1-Click Mass Payout Approval) - * - معالجة فورية عبر نمط Siro-Engine بدون عمولات وسيطة (Zero-Intermediary Fee) - * - سجل تفصيلي لطلبات السحب المعلقة والمكتملة. - */ +/// Read-only until a payment provider and an auditable settlement ledger exist. class TreasuryCliqTab extends StatelessWidget { const TreasuryCliqTab({super.key}); @@ -23,299 +14,27 @@ class TreasuryCliqTab extends StatelessWidget { Widget build(BuildContext context) { return BlocBuilder( builder: (context, state) { - if (state is TreasuryLoading) { - return const Center( - child: CupertinoActivityIndicator(color: SuperAdminTheme.cyberCyan), - ); - } - - if (state is TreasuryLoaded) { - final pendingItems = state.queue.where((item) => item.status == 'queued').toList(); - final double pendingSum = pendingItems.fold(0.0, (acc, item) => acc + item.amountJod); - - return SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Sovereign Treasury Card - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFF064E3B), Color(0xFF0F172A)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: SuperAdminTheme.emeraldGreen.withOpacity(0.5)), - boxShadow: [ - BoxShadow( - color: SuperAdminTheme.emeraldGreen.withOpacity(0.18), - blurRadius: 20, - offset: const Offset(0, 8), - ) - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Row( - children: [ - Icon(CupertinoIcons.money_dollar_circle_fill, color: SuperAdminTheme.emeraldGreen, size: 22), - SizedBox(width: 8), - Text( - 'الخزينة السيادية المركزية (Sovereign Treasury)', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.white), - ), - ], - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: Colors.black38, - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - 'CliQ Direct Rail', - style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: SuperAdminTheme.emeraldGreen), - ), - ), - ], - ), - const SizedBox(height: 16), - Text( - '${state.totalTreasuryBalanceJod.toStringAsFixed(2)} د.أ', - style: const TextStyle( - fontSize: 32, - fontWeight: FontWeight.w900, - color: Colors.white, - letterSpacing: 0.5, - ), - ), - const SizedBox(height: 6), - const Text( - 'صافي السيولة النقدية المودعة والمحمية في الحساب المصرفي المركزي الموحد.', - style: TextStyle(fontSize: 12, color: Colors.white70), - ), - const SizedBox(height: 16), - Row( - children: [ - _buildStatBadge('المعلق للسحب', '${pendingSum.toStringAsFixed(2)} د.أ', SuperAdminTheme.royalGold), - const SizedBox(width: 10), - _buildStatBadge('المصروف اليوم', '${state.totalApprovedTodayJod.toStringAsFixed(2)} د.أ', SuperAdminTheme.cyberCyan), - ], - ), - ], - ), - ), - const SizedBox(height: 20), - - // 1-Click Mass Approval Action - if (pendingItems.isNotEmpty) - Container( - margin: const EdgeInsets.only(bottom: 20), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: SuperAdminTheme.surfaceCard, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: SuperAdminTheme.royalGold.withOpacity(0.4)), - ), - child: Row( - children: [ - const Icon(CupertinoIcons.checkmark_shield_fill, color: SuperAdminTheme.royalGold, size: 26), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'يوجد ${pendingItems.length} طلبات سحب معلقة بقيمة ${pendingSum.toStringAsFixed(2)} د.أ', - style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold, color: Colors.white), - ), - const SizedBox(height: 2), - const Text( - 'يمكنك اعتماد وإرسال التحويلات فورياً عبر شبكة كليك المركزية.', - style: TextStyle(fontSize: 11.5, color: Colors.white60), - ), - ], - ), - ), - const SizedBox(width: 10), - CupertinoButton( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - color: SuperAdminTheme.royalGold, - borderRadius: BorderRadius.circular(10), - onPressed: () { - context.read().approveAllPayouts(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('تم اعتماد وصرف جميع مستحقات المعلمين بنجاح عبر شبكة CliQ!'), - backgroundColor: SuperAdminTheme.emeraldGreen, - ), - ); - }, - child: const Text( - 'اعتماد الكل', - style: TextStyle(fontSize: 12.5, fontWeight: FontWeight.bold, color: Colors.black), - ), - ), - ], - ), - ), - - // Payout Queue Section Header - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Row( - children: [ - Icon(CupertinoIcons.arrow_right_arrow_left, color: SuperAdminTheme.cyberCyan, size: 18), - SizedBox(width: 8), - Text( - 'طابور سحوبات المعلمين (Payout Queue)', - style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.white), - ), - ], - ), - Text( - '${state.queue.length} عمليات', - style: const TextStyle(fontSize: 12, color: Colors.white54), - ), - ], - ), - const SizedBox(height: 12), - - // List Items - ...state.queue.map((item) => _buildPayoutCard(context, item)), - - if (state.queue.isEmpty) - Container( - padding: const EdgeInsets.all(28), - alignment: Alignment.center, - child: const Text( - 'لا توجد طلبات سحب حالياً في الطابور.', - style: TextStyle(color: Colors.white38, fontSize: 13), - ), - ), - ], - ), - ); - } - - return const SizedBox.shrink(); + if (state is TreasuryLoading) return const Center(child: CupertinoActivityIndicator(color: SuperAdminTheme.cyberCyan)); + if (state is TreasuryError) return Center(child: Padding(padding: const EdgeInsets.all(24), child: Text(state.message, textAlign: TextAlign.center, style: const TextStyle(color: Colors.white70)))); + if (state is! TreasuryLoaded) return const SizedBox.shrink(); + final queued = state.queue.where((item) => item.status == 'queued').toList(); + final queuedAmount = queued.fold(0, (sum, item) => sum + item.amountJod); + return ListView(padding: const EdgeInsets.all(16), children: [ + _notice('الخزينة للقراءة فقط', 'لا يوجد مزود دفع أو دفتر تسوية موثق متصل حالياً؛ لا يمكن اعتماد أو إرسال أي سحب من هذه الشاشة.'), + const SizedBox(height: 16), + _metric('رصيد قابل للتسوية', state.totalTreasuryBalanceJod == null ? 'غير متاح' : '${state.totalTreasuryBalanceJod!.toStringAsFixed(2)} د.أ'), + const SizedBox(height: 16), + Text('طلبات السحب المسجلة (${state.queue.length})', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 6), + Text('قيمة الطلبات بحالة queued: ${queuedAmount.toStringAsFixed(2)} د.أ', style: const TextStyle(color: Colors.white60)), + const SizedBox(height: 12), + if (state.queue.isEmpty) const Padding(padding: EdgeInsets.all(24), child: Center(child: Text('لا توجد سجلات سحب متاحة.', style: TextStyle(color: Colors.white60)))) else ...state.queue.map(_payout), + ]); }, ); } - Widget _buildStatBadge(String label, String value, Color color) { - return Expanded( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: Colors.black.withOpacity(0.35), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.white12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: const TextStyle(fontSize: 11, color: Colors.white54)), - const SizedBox(height: 2), - Text(value, style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: color)), - ], - ), - ), - ); - } - - Widget _buildPayoutCard(BuildContext context, PayoutQueueItemModel item) { - final bool isQueued = item.status == 'queued'; - - return Container( - margin: const EdgeInsets.only(bottom: 10), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: SuperAdminTheme.surfaceCard, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: isQueued ? SuperAdminTheme.royalGold.withOpacity(0.3) : Colors.white10, - ), - ), - child: Row( - children: [ - CircleAvatar( - backgroundColor: isQueued ? SuperAdminTheme.royalGold.withOpacity(0.15) : SuperAdminTheme.emeraldGreen.withOpacity(0.15), - child: Icon( - isQueued ? CupertinoIcons.clock_fill : CupertinoIcons.checkmark_alt, - color: isQueued ? SuperAdminTheme.royalGold : SuperAdminTheme.emeraldGreen, - size: 18, - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.teacherName, - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold, color: Colors.white), - ), - const SizedBox(height: 2), - Text( - 'اسم مستعار كليك: ${item.cliqAlias} • ${item.requestedAt}', - style: const TextStyle(fontSize: 11.5, color: Colors.white54), - ), - ], - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '${item.amountJod.toStringAsFixed(2)} د.أ', - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w800, color: Colors.white), - ), - const SizedBox(height: 6), - if (isQueued) - CupertinoButton( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - color: SuperAdminTheme.emeraldGreen, - borderRadius: BorderRadius.circular(6), - minSize: 26, - onPressed: () { - context.read().approvePayout(item.id); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('تم اعتماد تحويل ${item.amountJod} د.أ إلى المعلم ${item.teacherName} عبر CliQ'), - backgroundColor: SuperAdminTheme.emeraldGreen, - ), - ); - }, - child: const Text( - 'اعتماد', - style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: Colors.black), - ), - ) - else - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: SuperAdminTheme.emeraldGreen.withOpacity(0.15), - borderRadius: BorderRadius.circular(6), - ), - child: const Text( - 'مكتمل ومحوّل', - style: TextStyle(fontSize: 10, fontWeight: FontWeight.bold, color: SuperAdminTheme.emeraldGreen), - ), - ), - ], - ), - ], - ), - ); - } + Widget _notice(String title, String body) => Container(padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: SuperAdminTheme.royalGold.withOpacity(.12), borderRadius: BorderRadius.circular(14), border: Border.all(color: SuperAdminTheme.royalGold.withOpacity(.4))), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(title, style: const TextStyle(color: SuperAdminTheme.royalGold, fontWeight: FontWeight.bold)), const SizedBox(height: 6), Text(body, style: const TextStyle(color: Colors.white70, height: 1.4))])); + Widget _metric(String label, String value) => Container(padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: SuperAdminTheme.surfaceCard, borderRadius: BorderRadius.circular(14)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(label, style: const TextStyle(color: Colors.white60)), const SizedBox(height: 4), Text(value, style: const TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold))])); + Widget _payout(PayoutQueueItemModel item) => Card(color: SuperAdminTheme.surfaceCard, child: ListTile(leading: const Icon(CupertinoIcons.money_dollar_circle, color: SuperAdminTheme.cyberCyan), title: Text(item.teacherName, style: const TextStyle(color: Colors.white)), subtitle: Text('${item.cliqAlias} • ${item.requestedAt} • ${item.status}', style: const TextStyle(color: Colors.white60)), trailing: Text('${item.amountJod.toStringAsFixed(2)} د.أ', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)))); } diff --git a/apps/teacher_app/lib/data/repositories/teacher_repository.dart b/apps/teacher_app/lib/data/repositories/teacher_repository.dart index 0b2cf27..96c6fba 100644 --- a/apps/teacher_app/lib/data/repositories/teacher_repository.dart +++ b/apps/teacher_app/lib/data/repositories/teacher_repository.dart @@ -387,6 +387,7 @@ class TeacherRepository { required String gradeLevel, required String subject, required String curriculumKey, + required String videoVersionId, String? fileName, double? fileSizeMb, String? filePath, @@ -410,6 +411,7 @@ class TeacherRepository { 'grade_level': gradeLevel, 'subject': subject, 'curriculum_key': curriculumKey, + 'video_version_id': videoVersionId, }); AppLogger.request( method: 'POST', @@ -420,6 +422,7 @@ class TeacherRepository { 'grade_level': gradeLevel, 'subject': subject, 'curriculum_key': curriculumKey, + 'video_version_id': videoVersionId, 'file_name': fileName, 'file_size_mb': fileSizeMb }, @@ -468,6 +471,42 @@ class TeacherRepository { } } + Future> preflightSubmission({ + required String curriculumLessonId, + required String idempotencyKey, + bool replacement = false, + String? submissionId, + }) async { + final uri = Uri.parse('$baseUrl/api/teacher/submissions/preflight'); + try { + final headers = await _authHeaders(); + headers['Idempotency-Key'] = idempotencyKey; + final response = await _client + .post( + uri, + headers: headers, + body: json.encode({ + 'curriculum_lesson_id': curriculumLessonId, + 'replacement': replacement, + if (submissionId != null) 'submission_id': submissionId, + }), + ) + .timeout(const Duration(seconds: 15)); + final decoded = json.decode(response.body); + if (decoded is! Map) { + throw StateError('استجاب الخادم بصيغة غير صالحة عند تجهيز الحصة.'); + } + final result = Map.from(decoded); + if (response.statusCode == 201 || response.statusCode == 200 || response.statusCode == 409 || response.statusCode == 400 || response.statusCode == 404) { + return result; + } + throw StateError(result['message']?.toString() ?? 'تعذر تجهيز نسخة الحصة.'); + } catch (error) { + if (error is StateError) rethrow; + throw StateError('تعذر تجهيز نسخة الحصة: $error'); + } + } + Future> getCurriculumTree() async { final headers = await _authHeaders(); final response = await _client 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 6d7f399..06bb445 100644 --- a/apps/teacher_app/lib/logic/cubits/teacher_studio_cubit.dart +++ b/apps/teacher_app/lib/logic/cubits/teacher_studio_cubit.dart @@ -1,4 +1,5 @@ import 'package:flutter_bloc/flutter_bloc.dart'; +import 'dart:math'; import 'dart:typed_data'; import '../../data/models/teacher_models.dart'; import '../../data/repositories/teacher_repository.dart'; @@ -267,6 +268,20 @@ class TeacherStudioCubit extends Cubit { state.unitKey, state.lessonKey ].where((e) => e.isNotEmpty).join('/'); + + String? get selectedCurriculumLessonId { + final lessons = _units[state.unitKey]?['lessons']; + if (lessons is! List) return null; + for (final item in lessons) { + if (item is Map && item['id']?.toString() == state.lessonKey) { + final id = item['curriculum_lesson_id']?.toString(); + return id != null && id.isNotEmpty ? id : null; + } + } + return null; + } + + String _newIdempotencyKey() => 'teacher_${DateTime.now().microsecondsSinceEpoch}_${Random.secure().nextInt(1 << 32)}'; void reportError(String message) => emit(state.copyWith(errorMessage: message)); @@ -300,6 +315,11 @@ class TeacherStudioCubit extends Cubit { errorMessage: 'اختر الصف والمبحث والفصل والوحدة والدرس أولاً.')); return; } + if (selectedCurriculumLessonId == null) { + emit(state.copyWith( + errorMessage: 'هذا الدرس غير معتمد للنشر بعد، لذلك لا يمكن رفع حصة له.')); + return; + } await uploadAndPublishLesson(); } @@ -316,12 +336,31 @@ class TeacherStudioCubit extends Cubit { clearError: true, )); try { + final preflight = await repository.preflightSubmission( + curriculumLessonId: selectedCurriculumLessonId!, + idempotencyKey: _newIdempotencyKey(), + ); + if (preflight['status']?.toString() != 'ready_for_upload') { + emit(state.copyWith( + isAuditing: false, + isUploading: false, + uploadProgress: 0.0, + uploadPhase: 'idle', + errorMessage: preflight['message']?.toString() ?? 'تعذر تجهيز نسخة الحصة للرفع.', + )); + return false; + } + final videoVersionId = preflight['video_version_id']?.toString(); + if (videoVersionId == null || videoVersionId.isEmpty) { + throw StateError('لم ينشئ الخادم نسخة فيديو صالحة للرفع.'); + } final res = await repository.uploadLesson( title: state.lessonTitle, durationMinutes: state.durationMinutes, gradeLevel: state.gradeLevel, subject: selectedSubjectName, curriculumKey: curriculumKey, + videoVersionId: videoVersionId, fileName: state.selectedFileName!, fileSizeMb: state.selectedFileSizeMb, filePath: state.selectedFilePath, @@ -368,7 +407,7 @@ class TeacherStudioCubit extends Cubit { return false; } - final msg = res['message']?.toString() ?? 'تم اعتماد الحصة ورفعها بنجاح.'; + final msg = res['message']?.toString() ?? 'تم رفع الحصة إلى طابور المراجعة.'; emit(state.copyWith( isAuditing: false, isUploading: false, diff --git a/backend/app/Controllers/CurriculumController.php b/backend/app/Controllers/CurriculumController.php index 42caef2..6c02bc7 100644 --- a/backend/app/Controllers/CurriculumController.php +++ b/backend/app/Controllers/CurriculumController.php @@ -183,6 +183,29 @@ class CurriculumController public function getTree(Request $request, Response $response): void { $tree = CurriculumService::getCurriculumTree(); + + // Manifest resource paths are intake metadata, not student-facing grants. + // Until they are represented by approved assets in a published bundle, do + // not expose them as available textbooks or worksheets. + $removeUnpublishedResources = function (&$node) use (&$removeUnpublishedResources): void { + if (!is_array($node)) { + return; + } + if (isset($node['resources']) && is_array($node['resources'])) { + foreach ($node['resources'] as &$resourceGroup) { + if (is_array($resourceGroup) && array_key_exists('items', $resourceGroup)) { + $resourceGroup['items'] = []; + } + } + unset($resourceGroup); + } + foreach ($node as &$child) { + $removeUnpublishedResources($child); + } + unset($child); + }; + $removeUnpublishedResources($tree); + try { $published = Database::select("SELECT cl.uuid, cl.source_manifest_path, COUNT(vv.id) AS video_count FROM curriculum_lessons cl LEFT JOIN teacher_submissions ts ON ts.curriculum_lesson_id=cl.id AND ts.status='published' LEFT JOIN video_versions vv ON vv.id=ts.current_published_video_version_id AND vv.status='published' WHERE cl.source_status='approved' GROUP BY cl.id, cl.uuid, cl.source_manifest_path"); $byPath=[]; @@ -192,6 +215,52 @@ class CurriculumController if (isset($byPath[$path])) $lesson=array_merge($lesson,$byPath[$path]); } unset($grade,$subject,$semester,$unit,$lesson); + + // The manifest describes intake files only. Student-visible resources + // are rebuilt from approved, rights-cleared assets in a published + // bundle, and expose an opaque asset UUID rather than a storage path. + $resourceRows = Database::select( + "SELECT cl.subject_key, a.uuid AS asset_id, a.asset_type, a.mime_type, + pba.role, pba.sort_order + FROM publication_bundles pb + JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id + JOIN publication_bundle_assets pba ON pba.publication_bundle_id = pb.id + JOIN content_assets a ON a.id = pba.content_asset_id + WHERE pb.status = 'published' + AND cl.source_status = 'approved' + AND a.review_status = 'approved' + AND a.rights_status = 'cleared' + AND pba.role IN ('textbook', 'worksheet') + ORDER BY cl.subject_key, pba.role, pba.sort_order, a.id" + ); + $resourcesBySubject = []; + foreach ($resourceRows as $row) { + $group = $row['role'] === 'textbook' ? 'textbooks' : 'worksheets'; + $subjectKey = (string)$row['subject_key']; + $resourcesBySubject[$subjectKey] ??= ['textbooks' => [], 'worksheets' => []]; + $resourcesBySubject[$subjectKey][$group][] = [ + 'asset_id' => (string)$row['asset_id'], + 'asset_type' => (string)$row['asset_type'], + 'mime_type' => (string)$row['mime_type'], + 'type' => $group === 'textbooks' ? 'textbook' : 'worksheet', + ]; + } + foreach ($tree as &$grade) foreach (($grade['subjects'] ?? []) as $subjectKey => &$subject) { + $subjectResources = $resourcesBySubject[(string)$subjectKey] ?? ['textbooks' => [], 'worksheets' => []]; + foreach (['textbooks', 'worksheets'] as $group) { + foreach ($subjectResources[$group] as $index => &$resource) { + $resource['title'] = $group === 'textbooks' + ? 'كتاب منشور ' . ($index + 1) + : 'ورقة عمل منشورة ' . ($index + 1); + } + unset($resource); + } + $subject['resources'] = [ + 'textbooks' => ['items' => $subjectResources['textbooks']], + 'worksheets' => ['items' => $subjectResources['worksheets']], + ]; + } + unset($grade, $subject); } catch (\Throwable $e) { error_log('Published curriculum tree enrichment unavailable: '.$e->getMessage()); } $response->json(['status'=>'success','data'=>$tree]); } diff --git a/backend/app/Controllers/SuperAdminController.php b/backend/app/Controllers/SuperAdminController.php index bc9579e..ce18767 100644 --- a/backend/app/Controllers/SuperAdminController.php +++ b/backend/app/Controllers/SuperAdminController.php @@ -61,35 +61,25 @@ class SuperAdminController } public function overview(Request $request, Response $response): void { - CliqPaymentService::ensureSchema(); - $counts = [ 'total_directorates' => $this->count('directorates'), 'total_schools' => $this->count('schools'), 'total_students' => $this->count('students'), 'total_teachers' => $this->count('teachers'), ]; - $payments = Database::selectOne( - "SELECT COALESCE(SUM(CASE WHEN verification_status = 'verified' THEN amount_jod ELSE 0 END), 0) AS inflow FROM cliq_payments" - ); - $payouts = Database::selectOne( - "SELECT SUM(CASE WHEN status IN ('queued', 'processing') THEN 1 ELSE 0 END) AS pending_count, - COALESCE(SUM(CASE WHEN status = 'completed' THEN amount_jod ELSE 0 END), 0) AS paid_out - FROM payout_queue" - ); - $inflow = (float)($payments['inflow'] ?? 0); - $paidOut = (float)($payouts['paid_out'] ?? 0); - $response->json([ 'status' => 'success', 'data' => array_merge($counts, [ - 'gross_margin_percent' => $inflow > 0 ? round((($inflow - $paidOut) / $inflow) * 100, 2) : 0.0, - 'treasury_balance_jod' => max(0, $inflow - $paidOut), - 'total_cliq_inflow_jod' => $inflow, - 'pending_payouts_count' => (int)($payouts['pending_count'] ?? 0), - 'r2_cost_savings_jod' => 0.0, - 'uptime_percent' => 0.0, + // Payment settlement and infrastructure monitoring are not + // connected yet. Null is intentional: zero would be a claim. + 'gross_margin_percent' => null, + 'treasury_balance_jod' => null, + 'total_cliq_inflow_jod' => null, + 'pending_payouts_count' => null, + 'r2_cost_savings_jod' => null, + 'uptime_percent' => null, 'measurement_notes' => [ + 'financial_metrics' => 'غير متاحة حتى ربط مزود الدفع ودفتر التسوية.', 'r2_cost_savings_jod' => 'يتطلب ربط Cloudflare Billing Analytics', 'uptime_percent' => 'يتطلب ربط مزود مراقبة خارجي', ], @@ -124,7 +114,7 @@ class SuperAdminController public function securityAlerts(Request $request, Response $response): void { // No synthetic alerts: an audit-event pipeline will populate this endpoint. - $response->json(['status' => 'success', 'data' => []]); + $response->json(['status' => 'success', 'data' => [], 'measurement_status' => 'unavailable']); } private function count(string $table): int diff --git a/backend/app/Controllers/VideoController.php b/backend/app/Controllers/VideoController.php index 91d11a2..2caa88b 100644 --- a/backend/app/Controllers/VideoController.php +++ b/backend/app/Controllers/VideoController.php @@ -327,6 +327,9 @@ class VideoController ); $auditId = $this->recordUploadAudit($request, $courseId, $_FILES['video'], $preflight); if (($preflight['decision'] ?? '') !== 'approved') { + // The candidate contains no accepted media yet, so make it reusable. + // Do not leave a failed local quality gate blocking a future upload. + TeacherSubmissionService::releaseUploadReservation((int)$request->user_id, $videoVersionId); $response->status(422)->json([ 'status' => 'needs_review', 'message' => 'لم يتم حفظ الفيديو في Cloudflare R2 قبل اجتياز تدقيق الجودة.', @@ -335,6 +338,7 @@ class VideoController return; } + $reviewQueued = false; try { // Fast direct upload: save file locally and slice HLS instantly via stream copy (-c copy) $uploadResult = VideoService::handleDirectUpload($_FILES['video'], $courseId, $title, false); @@ -364,11 +368,12 @@ class VideoController $lessonId, hash_file('sha256', (string)$_FILES['video']['tmp_name']) ); + $reviewQueued = true; // Immediately send HTTP 201 response to Flutter client and close connection $responsePayload = [ 'status' => 'success', - 'message' => 'تم رفع الفيديو واعتماده مبدئياً بنجاح. تجري المزامنة السحابية والتحليل السقراطي في الخلفية.', + 'message' => 'تم رفع الفيديو إلى طابور المراجعة. لن يظهر للطلاب قبل اكتمال الأدلة وقرار المراجع البشري.', 'data' => array_merge($uploadResult, [ 'lesson_id' => $lessonId, 'title' => $title, @@ -406,6 +411,9 @@ class VideoController exit; } catch (\Throwable $e) { + if (!$reviewQueued) { + TeacherSubmissionService::releaseUploadReservation((int)$request->user_id, $videoVersionId); + } $response->status(500)->json([ 'status' => 'error', 'message' => $e->getMessage() diff --git a/backend/app/Services/CurriculumService.php b/backend/app/Services/CurriculumService.php index bbf49f1..d2f0b59 100644 --- a/backend/app/Services/CurriculumService.php +++ b/backend/app/Services/CurriculumService.php @@ -154,74 +154,6 @@ class CurriculumService return []; } - // Dynamically enrich manifest nodes with live uploaded videos from MySQL - try { - $dbLessons = \App\Core\Database::select( - "SELECT id, title, curriculum_key, hls_url, duration_seconds - FROM lessons - WHERE hls_url IS NOT NULL AND hls_url != ''" - ); - - if (!empty($dbLessons)) { - $attachVideos = function (&$node) use (&$attachVideos, $dbLessons) { - if (!is_array($node)) return; - if (isset($node['lessons']) && is_array($node['lessons'])) { - foreach ($node['lessons'] as &$lesson) { - if (!is_array($lesson)) continue; - $lessonId = (string)($lesson['id'] ?? ''); - $lessonFile = (string)($lesson['file'] ?? ''); - $lessonFileNoExt = preg_replace('/\.md$/i', '', $lessonFile); - $lessonTitle = (string)($lesson['title'] ?? ''); - - foreach ($dbLessons as $dbl) { - $currKey = (string)($dbl['curriculum_key'] ?? ''); - $currKeyNoExt = preg_replace('/\.md$/i', '', $currKey); - $dbTitle = (string)($dbl['title'] ?? ''); - - $matched = false; - 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) { - $lesson['has_video'] = true; - $lesson['video_url'] = $dbl['hls_url']; - if (!empty($dbl['duration_seconds'])) { - $lesson['duration_seconds'] = (int)$dbl['duration_seconds']; - } - break; - } - } - } - } - foreach ($node as &$child) { - if (is_array($child)) { - $attachVideos($child); - } - } - }; - - $attachVideos($tree); - } - } catch (\Throwable $e) { - error_log("Enrich curriculum tree notice: " . $e->getMessage()); - } - return $tree; } diff --git a/backend/app/Services/PublishedContentService.php b/backend/app/Services/PublishedContentService.php index 02f5deb..445c836 100644 --- a/backend/app/Services/PublishedContentService.php +++ b/backend/app/Services/PublishedContentService.php @@ -28,6 +28,7 @@ final class PublishedContentService JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id WHERE a.uuid = ? AND a.review_status = 'approved' + AND a.rights_status = 'cleared' AND pb.status = 'published' AND cl.source_status = 'approved' ORDER BY pb.published_at DESC, pb.id DESC diff --git a/backend/app/Services/TeacherSubmissionService.php b/backend/app/Services/TeacherSubmissionService.php index 0904686..163b098 100644 --- a/backend/app/Services/TeacherSubmissionService.php +++ b/backend/app/Services/TeacherSubmissionService.php @@ -41,6 +41,17 @@ final class TeacherSubmissionService { [$versionUuid, $teacherId] ) === 1; } + /** Releases a reservation only when no media was accepted for review. */ + public static function releaseUploadReservation(int $teacherId, string $versionUuid): void { + if (!self::uuidValid($versionUuid)) return; + Database::execute( + "UPDATE video_versions vv JOIN teacher_submissions ts ON ts.id=vv.teacher_submission_id + SET vv.status='draft', + ts.status=CASE WHEN ts.current_published_video_version_id IS NULL THEN 'draft' ELSE 'published' END + WHERE vv.uuid=? AND ts.teacher_id=? AND vv.status='uploading'", + [$versionUuid, $teacherId] + ); + } private static function commit(\PDO $pdo,int $teacher,string $op,string $key,string $hash,array $r):array {Database::insert('INSERT INTO submission_idempotency_keys (teacher_id,operation,idempotency_key,request_sha256,response_json,http_status) VALUES (?,?,?,?,?,?)',[$teacher,$op,$key,$hash,json_encode($r,JSON_UNESCAPED_UNICODE),$r['http_status']]);$pdo->commit();return $r;} private static function result(int $status,string $code,string $message):array{return ['http_status'=>$status,'status'=>$code,'message'=>$message];} private static function uuidValid(string $v):bool{return(bool)preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',$v);} diff --git a/backend/public/index.php b/backend/public/index.php index 94e0460..d9bb977 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']); +$router->get('/api/curriculum/lesson', [\App\Controllers\CurriculumController::class, 'getLessonContent'], $curriculumManagerMiddleware); $router->post('/api/curriculum/save-lesson', [\App\Controllers\CurriculumController::class, 'saveLessonContent'], $curriculumManagerMiddleware); $router->post('/api/curriculum/generate-ai-assets', [\App\Controllers\CurriculumController::class, 'generateAiAssets'], $curriculumManagerMiddleware); $router->post('/api/curriculum/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']); +$router->get('/api/curriculum/document', [\App\Controllers\CurriculumController::class, 'getDocumentContent'], $curriculumManagerMiddleware); $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); diff --git a/backend/scripts/import_grade10_book_sources.php b/backend/scripts/import_grade10_book_sources.php new file mode 100644 index 0000000..14c593e --- /dev/null +++ b/backend/scripts/import_grade10_book_sources.php @@ -0,0 +1,171 @@ + $subject) { + foreach (array_keys($subject['semesters'] ?? []) as $semesterKey) { + $knownSemesters[$subjectKey][$semesterKey] = true; + } +} + +$subjectPatterns = [ + 'arabic_10' => ['اللغة العربية', 'العربية لغتي'], + 'english_10' => ['اللغة الإنجليزية'], + 'math_10' => ['الرياضيات'], + 'physics_10' => ['الفيزياء'], + 'chemistry_10' => ['الكيمياء'], + 'biology_10' => ['العلوم الحياتية'], + 'earth_science_10' => ['علوم الأرض والبيئة'], + 'digital_skills_10' => ['المهارات الرقمية'], + 'islamic_10' => ['التربية الإسلامية'], + 'history_10' => ['التاريخ'], + 'geography_10' => ['الجغرافيا'], + 'civic_10' => ['التربية الوطنية والمدنية'], + 'financial_literacy_10' => ['الثقافة المالية'], +]; + +$rows = []; +foreach (glob($sourceRoot . '/*.pdf') ?: [] as $sourcePath) { + $filename = basename($sourcePath); + $subjectKey = null; + foreach ($subjectPatterns as $candidate => $patterns) { + foreach ($patterns as $pattern) { + if (str_contains($filename, $pattern)) { + $subjectKey = $candidate; + break 2; + } + } + } + $semesterKey = str_contains($filename, 'الفصل الأول') ? 'semester_1' + : (str_contains($filename, 'الفصل الثاني') ? 'semester_2' : null); + $isInstitutionalBook = str_starts_with($filename, 'كتاب الطالب') || str_starts_with($filename, 'كتاب التمارين'); + $sha256 = hash_file('sha256', $sourcePath); + $safeName = preg_replace('/[^A-Za-z0-9._-]+/', '-', pathinfo($filename, PATHINFO_FILENAME)) ?: 'book'; + // Arabic filenames normalize to "book" with the ASCII-only fallback; + // append integrity bytes so no two supplied books can overwrite each other. + $storageKey = sprintf('sources/grade_10/%s/%s/%s-%s.pdf', $subjectKey ?: 'unclassified', $semesterKey ?: 'unclassified', trim($safeName, '-'), substr($sha256, 0, 12)); + $rows[] = [ + 'filename' => $filename, + 'source_path' => $sourcePath, + 'subject_key' => $subjectKey, + 'semester_key' => $semesterKey, + 'asset_type' => $isInstitutionalBook ? 'textbook_pdf' : 'other', + 'pages' => pdfPages($sourcePath), + 'byte_size' => filesize($sourcePath), + 'sha256' => $sha256, + 'storage_key' => $storageKey, + 'manifest_scope_exists' => $subjectKey !== null && $semesterKey !== null && isset($knownSemesters[$subjectKey][$semesterKey]), + 'intake_status' => !$isInstitutionalBook ? 'manual_rights_and_academic_review_required' + : ($subjectKey === null || $semesterKey === null ? 'unclassified_metadata' : 'ready_for_source_review'), + ]; +} + +usort($rows, static fn(array $a, array $b): int => strcmp($a['filename'], $b['filename'])); +$report = [ + 'mode' => $apply ? 'apply' : 'dry_run', + 'policy' => 'Draft source registration only; no publication or rights clearance.', + 'books_found' => count($rows), + 'manifest_supported_sources' => count(array_filter($rows, static fn(array $row): bool => $row['manifest_scope_exists'])), + 'sources_outside_current_manifest' => count(array_filter($rows, static fn(array $row): bool => !$row['manifest_scope_exists'])), + 'rows' => $rows, +]; + +if (!$apply) { + echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL; + exit(0); +} + +require_once dirname(__DIR__) . '/app/bootstrap.php'; + +$pdo = Database::getConnection(); +$pdo->beginTransaction(); +try { + foreach ($rows as $row) { + if ($row['asset_type'] !== 'textbook_pdf' || $row['subject_key'] === null || $row['semester_key'] === null) { + continue; + } + $destination = $curriculumRoot . '/' . $row['storage_key']; + if (!is_dir(dirname($destination)) && !mkdir(dirname($destination), 0750, true) && !is_dir(dirname($destination))) { + throw new RuntimeException('Unable to create textbook storage directory.'); + } + if (!is_file($destination)) { + if (!copy($row['source_path'], $destination)) { + throw new RuntimeException('Unable to copy textbook source: ' . $row['filename']); + } + } + if (!hash_equals($row['sha256'], hash_file('sha256', $destination))) { + throw new RuntimeException('Copied textbook checksum mismatch: ' . $row['filename']); + } + Database::query( + "INSERT INTO content_assets (uuid, asset_type, storage_driver, storage_key, mime_type, byte_size, sha256, source_reference, rights_status, review_status) + VALUES (?, 'textbook_pdf', 'local', ?, 'application/pdf', ?, ?, ?, 'review_required', 'draft') + ON DUPLICATE KEY UPDATE byte_size=VALUES(byte_size), source_reference=VALUES(source_reference)", + [uuid(), $row['storage_key'], $row['byte_size'], $row['sha256'], 'books/' . $row['filename']] + ); + } + $pdo->commit(); + $report['registered_draft_sources'] = true; + echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . PHP_EOL; +} catch (Throwable $e) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + fwrite(STDERR, "Source import rolled back: {$e->getMessage()}\n"); + exit(1); +} + +function pdfPages(string $path): ?int +{ + $output = []; + $status = 0; + exec('pdfinfo ' . escapeshellarg($path) . ' 2>/dev/null', $output, $status); + if ($status !== 0) { + return null; + } + foreach ($output as $line) { + if (preg_match('/^Pages:\s+(\d+)$/', $line, $match)) { + return (int) $match[1]; + } + } + return null; +} + +function uuid(): string +{ + $bytes = random_bytes(16); + $bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40); + $bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80); + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4)); +} diff --git a/deploy.sh b/deploy.sh index a724be2..60ebe22 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,4 +1,5 @@ #!/bin/bash +set -euo pipefail # ============================================================================== # SAQEL PLATFORM - DEPLOY & GIT SYNCHRONIZATION SCRIPT @@ -14,10 +15,14 @@ echo "📦 Staging all files..." git add . echo "📝 Committing: $COMMIT_MSG" -git commit -m "$COMMIT_MSG" +if git diff --cached --quiet; then + echo "ℹ️ No staged changes to commit." +else + git commit -m "$COMMIT_MSG" +fi echo "🚀 Pushing to origin..." -git push origin --all +git push origin main echo "✅ Done! On the production server run:" echo " git pull" echo " (and if websocket server changed: php backend/websocket/server.php restart -d)"