Update Saqel Platform: 2026-09-10 12:26:22

This commit is contained in:
Hamza-Ayed
2026-09-10 12:32:01 +03:00
parent 09dfd665b5
commit 960d4c8604
28 changed files with 690 additions and 2864 deletions
+5
View File
@@ -46,6 +46,11 @@ apps/**/.dart_tool/
apps/**/build/ apps/**/build/
apps/**/.flutter-plugins* 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 # IDE & Editor files
.idea/ .idea/
.vscode/ .vscode/
+1 -1
View File
@@ -183,7 +183,7 @@ class _UnifiedSupervisorShellState extends State<UnifiedSupervisorShell> {
), ),
_buildModeTab( _buildModeTab(
index: 1, index: 1,
title: 'لوحة القائد الأعلى (43 مدرسة)', title: 'لوحة المديرية',
icon: CupertinoIcons.shield_lefthalf_fill, icon: CupertinoIcons.shield_lefthalf_fill,
), ),
], ],
@@ -83,7 +83,7 @@ class _DirectorateCommandScreenState extends State<DirectorateCommandScreen>
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
dir['name'] ?? 'مديرية الثقافة العسكرية', dir['name']?.toString() ?? 'مديرية غير محددة',
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
@@ -93,7 +93,7 @@ class _DirectorateCommandScreenState extends State<DirectorateCommandScreen>
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
const Text( const Text(
'لوحة القائد الأعلى للرقابة والسيادة الرقمية (43 مدرسة في المملكة)', 'تعرض البيانات المتاحة ضمن نطاق صلاحيات الحساب.',
style: TextStyle(fontSize: 12, color: Color(0xFF94A3B8)), style: TextStyle(fontSize: 12, color: Color(0xFF94A3B8)),
), ),
], ],
@@ -163,7 +163,7 @@ class _DirectorateCommandScreenState extends State<DirectorateCommandScreen>
Expanded( Expanded(
child: _kpiCell( child: _kpiCell(
'إجمالي المدارس', 'إجمالي المدارس',
'${dir['total_schools'] ?? 43} مدرسة', '${dir['total_schools'] ?? 'غير متاح'} مدرسة',
CupertinoIcons.building_2_fill, CupertinoIcons.building_2_fill,
const Color(0xFF818CF8), const Color(0xFF818CF8),
), ),
@@ -171,7 +171,7 @@ class _DirectorateCommandScreenState extends State<DirectorateCommandScreen>
Expanded( Expanded(
child: _kpiCell( child: _kpiCell(
'الطلبة المسجلون', 'الطلبة المسجلون',
'${dir['total_students'] ?? 19350}', '${dir['total_students'] ?? 'غير متاح'}',
CupertinoIcons.person_3_fill, CupertinoIcons.person_3_fill,
const Color(0xFF38BDF8), const Color(0xFF38BDF8),
), ),
@@ -184,7 +184,7 @@ class _DirectorateCommandScreenState extends State<DirectorateCommandScreen>
Expanded( Expanded(
child: _kpiCell( child: _kpiCell(
'معلمو الثقافة العسكرية', 'معلمو الثقافة العسكرية',
'${dir['total_teachers'] ?? 812} معلم', '${dir['total_teachers'] ?? 'غير متاح'} معلم',
CupertinoIcons.person_badge_plus_fill, CupertinoIcons.person_badge_plus_fill,
const Color(0xFFFBBF24), const Color(0xFFFBBF24),
), ),
@@ -192,7 +192,7 @@ class _DirectorateCommandScreenState extends State<DirectorateCommandScreen>
Expanded( Expanded(
child: _kpiCell( child: _kpiCell(
'نسبة كوتة الحصص', 'نسبة كوتة الحصص',
'${dir['compliance_rate'] ?? 94.5}%', dir['compliance_rate'] == null ? 'غير متاح' : '${dir['compliance_rate']}%',
CupertinoIcons.check_mark_circled_solid, CupertinoIcons.check_mark_circled_solid,
const Color(0xFF34D399), const Color(0xFF34D399),
), ),
@@ -209,14 +209,14 @@ class _DirectorateCommandScreenState extends State<DirectorateCommandScreen>
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Text( const Text(
'توزيع مدارس الثقافة العسكرية الـ 43 حسب تصنيف الوزن:', 'المدارس ضمن النطاق:',
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Colors.white), color: Colors.white),
), ),
Text( const Text(
'إجمالي العقد: 20,000 د.أ', 'القيمة التعاقدية غير معروضة',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@@ -325,7 +325,7 @@ class _DirectorateCommandScreenState extends State<DirectorateCommandScreen>
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// Monetization Model Header // Revenue and teacher ranking cannot be inferred without a verified ledger.
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -347,7 +347,7 @@ class _DirectorateCommandScreenState extends State<DirectorateCommandScreen>
color: Color(0xFF4ADE80), size: 22), color: Color(0xFF4ADE80), size: 22),
SizedBox(width: 8), SizedBox(width: 8),
Text( Text(
'سوق تسييل المعلمين المتميزين (شراكة الأرباح)', 'بيانات المعلمين المتاحة',
style: TextStyle( style: TextStyle(
fontSize: 14.5, fontSize: 14.5,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@@ -357,7 +357,7 @@ class _DirectorateCommandScreenState extends State<DirectorateCommandScreen>
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text( const Text(
'المعلمون الحاصلون على تقييم 95%+ يتم منحهم اعتماد "معلم صَقِل المعتمد" وتُباع شروحاتهم خارج الثقافة العسكرية مع توزيع العائد: 55% للمعلم، 15% للمديرية، 30% لصَقِل.', 'لا توجد سياسة دخل أو اعتماد معلّم منشورة في هذه الشاشة. لا يظهر ترتيب إلا إذا أعاده الخادم بدليل موثق.',
style: TextStyle( style: TextStyle(
fontSize: 12.5, color: Color(0xFFCBD5E1), height: 1.5), fontSize: 12.5, color: Color(0xFFCBD5E1), height: 1.5),
), ),
@@ -368,12 +368,14 @@ class _DirectorateCommandScreenState extends State<DirectorateCommandScreen>
// Top Teachers List // Top Teachers List
const Text( const Text(
'🌟 أفضل المعلمين المرشحين للتسييل والاعتماد الرسمي:', 'المعلمون الذين أعادهم الخادم:',
style: TextStyle( style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white), fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white),
), ),
const SizedBox(height: 12), 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), margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -419,7 +421,7 @@ class _DirectorateCommandScreenState extends State<DirectorateCommandScreen>
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
), ),
child: Text( child: Text(
'${t['score']}% تقييم AI', '${t['score'] ?? 'غير متاح'}',
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
@@ -433,12 +435,14 @@ class _DirectorateCommandScreenState extends State<DirectorateCommandScreen>
// Early Intervention Radar // Early Intervention Radar
const Text( const Text(
'⚠️ رادار الإنذار والتوجيه المبكر (معالجة القصور قبل التوجيهي):', 'حالات الدعم التي أعادها الخادم:',
style: TextStyle( style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white), fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white),
), ),
const SizedBox(height: 12), 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), margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -532,7 +536,7 @@ class _DirectorateCommandScreenState extends State<DirectorateCommandScreen>
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text( const Text(
'مراقبة لحظية لنحو 19,000 طالب يخوضون الامتحان الموحد المتزامن عبر 43 مدرسة، برصد فوري للسرعة المستحيلة وتكتل الأخطاء.', 'تظهر هنا فقط الشذوذات التي سجلها الخادم من جلسات امتحان فعلية ضمن نطاق الحساب.',
style: TextStyle( style: TextStyle(
fontSize: 12.5, color: Color(0xFF94A3B8), height: 1.5), fontSize: 12.5, color: Color(0xFF94A3B8), height: 1.5),
), ),
@@ -556,12 +560,7 @@ class _DirectorateCommandScreenState extends State<DirectorateCommandScreen>
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFF1E293B)), border: Border.all(color: const Color(0xFF1E293B)),
), ),
child: const Center( child: const Center(child: Text('لا توجد سجلات شذوذ متاحة من الخادم.', style: TextStyle(color: Color(0xFF94A3B8), fontSize: 13.5))),
child: Text(
'✅ لا توجد أي تنبيهات شذوذ حالياً · كافة المدارس تعمل بانضباط تام',
style: TextStyle(color: Color(0xFF10B981), fontSize: 13.5),
),
),
) )
else else
...anomalies.map((anm) => Container( ...anomalies.map((anm) => Container(
@@ -613,19 +612,11 @@ class _DirectorateCommandScreenState extends State<DirectorateCommandScreen>
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: () { onPressed: null,
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'🎥 جاري فتح العينة البانورامية المسجلة (16.5 MB) لتلك الدقيقة للتحقق البصري المباشر'),
backgroundColor: Color(0xFF0284C7),
),
);
},
icon: const Icon(CupertinoIcons.play_circle_fill, icon: const Icon(CupertinoIcons.play_circle_fill,
size: 16), size: 16),
label: const Text( label: const Text(
'عرض العينة البانورامية المسجلة في هذه الدقيقة 👁️', 'عرض العينة غير متاح من هذه الشاشة',
style: TextStyle(fontSize: 12.5), style: TextStyle(fontSize: 12.5),
), ),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
File diff suppressed because it is too large Load Diff
@@ -123,10 +123,9 @@ class DirectorateApiService {
return _decode(response); return _decode(response);
} }
static Future<Map<String, dynamic>> fetchSchoolDashboard( static Future<Map<String, dynamic>> fetchSchoolDashboard({int? schoolId}) async {
{int schoolId = 1}) async {
final uri = Uri.parse('$baseUrl/api/supervisor/school-dashboard').replace( 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 final response = await http
.get(uri, headers: await _headers()) .get(uri, headers: await _headers())
@@ -242,8 +241,7 @@ class DirectorateApiService {
return Map<String, dynamic>.from(data['data'] as Map? ?? const {}); return Map<String, dynamic>.from(data['data'] as Map? ?? const {});
} }
static Future<Map<String, dynamic>> dispatchParentReports( static Future<Map<String, dynamic>> dispatchParentReports({required int schoolId}) async {
{int schoolId = 1}) async {
final response = await http final response = await http
.post( .post(
Uri.parse('$baseUrl/api/parent-reports/dispatch'), Uri.parse('$baseUrl/api/parent-reports/dispatch'),
@@ -255,7 +253,7 @@ class DirectorateApiService {
} }
static Future<Map<String, dynamic>> importSchoolRoster({ static Future<Map<String, dynamic>> importSchoolRoster({
int schoolId = 1, required int schoolId,
required List<Map<String, dynamic>> records, required List<Map<String, dynamic>> records,
}) async { }) async {
final response = await http final response = await http
@@ -255,19 +255,25 @@ class CurriculumLessonItemModel {
/// Model representing a Resource Item (PDF Textbook or Worksheet) /// Model representing a Resource Item (PDF Textbook or Worksheet)
class ResourceItemModel { class ResourceItemModel {
final String title; final String title;
final String filePath; final String assetId;
final String assetType;
final String mimeType;
final String type; // textbook, worksheet, summary final String type; // textbook, worksheet, summary
const ResourceItemModel({ const ResourceItemModel({
required this.title, required this.title,
required this.filePath, required this.assetId,
required this.assetType,
required this.mimeType,
this.type = 'textbook', this.type = 'textbook',
}); });
factory ResourceItemModel.fromJson(Map<String, dynamic> json) { factory ResourceItemModel.fromJson(Map<String, dynamic> json) {
return ResourceItemModel( return ResourceItemModel(
title: json['title']?.toString() ?? 'ملف وزاري', 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', type: json['type']?.toString() ?? 'textbook',
); );
} }
@@ -28,7 +28,9 @@ class CurriculumDocumentViewerScreen extends StatefulWidget {
final String documentType; // 'worksheet', 'summary', 'textbook', 'lesson' final String documentType; // 'worksheet', 'summary', 'textbook', 'lesson'
final String subjectTitle; final String subjectTitle;
final String? subjectId; final String? subjectId;
final String? filePath; final String? assetId;
final String? assetType;
final String? mimeType;
final String? customContent; final String? customContent;
final String? simulationSlug; final String? simulationSlug;
@@ -38,7 +40,9 @@ class CurriculumDocumentViewerScreen extends StatefulWidget {
required this.documentType, required this.documentType,
required this.subjectTitle, required this.subjectTitle,
this.subjectId, this.subjectId,
this.filePath, this.assetId,
this.assetType,
this.mimeType,
this.customContent, this.customContent,
this.simulationSlug, this.simulationSlug,
}); });
@@ -68,11 +72,10 @@ class _CurriculumDocumentViewerScreenState extends State<CurriculumDocumentViewe
widget.subjectTitle.contains('رياضيات') || widget.subjectTitle.contains('رياضيات') ||
(widget.subjectId ?? '').toLowerCase().contains('math') || (widget.subjectId ?? '').toLowerCase().contains('math') ||
widget.title.contains('معادلات') || widget.title.contains('معادلات') ||
widget.title.contains('جيوجبرا') || widget.title.contains('جيوجبرا');
(widget.filePath ?? '').toLowerCase().contains('geogebra');
String get _effectiveSubjectId => widget.subjectId ?? (_isEnglish ? 'english_10' : (_isPhysics ? 'physics_10' : 'math_10')); String get _effectiveSubjectId => widget.subjectId ?? (_isEnglish ? 'english_10' : (_isPhysics ? 'physics_10' : 'math_10'));
String get _effectiveFilePath => widget.filePath ?? widget.title; String get _effectiveFilePath => widget.assetId ?? '';
@override @override
void initState() { void initState() {
@@ -110,16 +113,29 @@ class _CurriculumDocumentViewerScreenState extends State<CurriculumDocumentViewe
filePath: _effectiveFilePath, filePath: _effectiveFilePath,
); );
// 2. Attempt fetching from Live Server if (widget.assetId == null || widget.assetId!.isEmpty) {
if (mounted) {
setState(() {
_loadError = 'المورد المنشور لا يحمل معرّف أصل صالحاً.';
_isLoading = false;
});
}
return;
}
if (widget.mimeType != null && !widget.mimeType!.toLowerCase().startsWith('text/')) {
if (mounted) {
setState(() {
_loadError = 'هذا المورد منشور، لكن عرضه داخل التطبيق غير مدعوم بعد.';
_isLoading = false;
});
}
return;
}
// 2. Fetch only the approved asset UUID from the published bundle.
try { try {
final res = await _api.get( final res = await _api.get('/api/curriculum/assets/${widget.assetId}').timeout(const Duration(seconds: 3));
'/api/curriculum/document',
queryParams: {
'subject': _effectiveSubjectId,
'file': _effectiveFilePath,
'type': widget.documentType,
},
).timeout(const Duration(seconds: 3));
if (res is Map && res['content'] != null && res['content'].toString().trim().isNotEmpty) { if (res is Map && res['content'] != null && res['content'].toString().trim().isNotEmpty) {
_processContent(res['content'].toString()); _processContent(res['content'].toString());
@@ -347,35 +347,14 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
/// Tab 2: Worksheets & Summaries /// Tab 2: Worksheets & Summaries
Widget _buildWorksheetsTab(BuildContext context) { Widget _buildWorksheetsTab(BuildContext context) {
final s = widget.subject.id.toLowerCase(); final worksheets = widget.subject.worksheets;
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 defaultWorksheets = isMath if (worksheets.isEmpty) {
? [ return _buildUnavailableResourcesState(
const ResourceItemModel(title: 'ورقة عمل 1: الأسس والأنظمة والمعادلات الخاصة', filePath: 'grade_10/math_10/semester_1/resources/worksheet_1.md', type: 'worksheet'), icon: CupertinoIcons.doc_text,
const ResourceItemModel(title: 'ملخص شامل: قوانين المعادلات والتحليل إلى العوامل', filePath: 'math_summary.md', type: 'summary'), message: 'لا توجد أوراق عمل منشورة لهذه المادة بعد.',
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;
return ListView.builder( return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20), padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20),
@@ -416,7 +395,15 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
IconButton( IconButton(
icon: const Icon(CupertinoIcons.arrow_down_circle_fill, color: AppColors.saqelCyan, size: 28), icon: const Icon(CupertinoIcons.arrow_down_circle_fill, color: AppColors.saqelCyan, size: 28),
onPressed: () { 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<SubjectHubScreen> with SingleTickerPr
/// Tab 4: Official Ministry Textbooks /// Tab 4: Official Ministry Textbooks
Widget _buildTextbooksTab(BuildContext context) { Widget _buildTextbooksTab(BuildContext context) {
final s = widget.subject.id.toLowerCase(); final textbooks = widget.subject.textbooks;
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 defaultTextbooks = isMath if (textbooks.isEmpty) {
? [ return _buildUnavailableResourcesState(
const ResourceItemModel(title: 'كتاب الطالب المقرر — الرياضيات (أنظمة المعادلات والدائرة)', filePath: 'grade_10/math_10/semester_1/math_student_book.md', type: 'textbook'), icon: CupertinoIcons.book,
const ResourceItemModel(title: 'كتاب التمارين والأنشطة الإضافية — الرياضيات 10', filePath: 'grade_10/math_10/semester_1/math_workbook.md', type: 'textbook'), message: 'لا يوجد كتاب أو ملف مصدر منشور لهذه المادة بعد.',
] );
: (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;
return ListView.builder( return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20), padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20),
@@ -619,7 +588,15 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
IconButton( IconButton(
icon: const Icon(CupertinoIcons.eye_fill, color: AppColors.saqelCyan, size: 24), icon: const Icon(CupertinoIcons.eye_fill, color: AppColors.saqelCyan, size: 24),
onPressed: () { 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<SubjectHubScreen> 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( showModalBottomSheet(
context: context, context: context,
backgroundColor: AppColors.darkSurface, backgroundColor: AppColors.darkSurface,
@@ -716,7 +721,9 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
documentType: title.contains('كتاب') ? 'textbook' : 'worksheet', documentType: title.contains('كتاب') ? 'textbook' : 'worksheet',
subjectTitle: widget.subject.title, subjectTitle: widget.subject.title,
subjectId: widget.subject.id, subjectId: widget.subject.id,
filePath: filePath, assetId: assetId,
assetType: assetType,
mimeType: mimeType,
), ),
), ),
); );
@@ -780,40 +787,9 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text( const Text(
'شروحات هذا الدرس قيد التصوير والمراجعة من قبل نخبة المعلمين المعتمدين وفريق منصة صَقِل.\nيمكنك حالياً دراسة نتاجات وملخص الدرس عبر تبويب الكتب والمذكرات، أو خوض الاختبار التكيفي.', 'لا يوجد شرح منشور لهذا الدرس بعد. ستظهر فقط الموارد التي تُراجع وتُنشر لهذا الدرس أو للمادة.',
style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 13, height: 1.5), 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)),
),
),
],
),
], ],
), ),
), ),
@@ -11,12 +11,12 @@ class MacroTelemetryModel {
final int totalSchools; final int totalSchools;
final int totalStudents; final int totalStudents;
final int totalTeachers; final int totalTeachers;
final double grossMarginPercent; final double? grossMarginPercent;
final double treasuryBalanceJod; final double? treasuryBalanceJod;
final double totalCliqInflowJod; final double? totalCliqInflowJod;
final int pendingPayoutsCount; final int? pendingPayoutsCount;
final double r2BandwidthCostSavingsJod; final double? r2BandwidthCostSavingsJod;
final double uptimePercent; final double? uptimePercent;
const MacroTelemetryModel({ const MacroTelemetryModel({
required this.totalDirectorates, required this.totalDirectorates,
@@ -37,12 +37,12 @@ class MacroTelemetryModel {
totalSchools: (json['total_schools'] as num?)?.toInt() ?? 0, totalSchools: (json['total_schools'] as num?)?.toInt() ?? 0,
totalStudents: (json['total_students'] as num?)?.toInt() ?? 0, totalStudents: (json['total_students'] as num?)?.toInt() ?? 0,
totalTeachers: (json['total_teachers'] as num?)?.toInt() ?? 0, totalTeachers: (json['total_teachers'] as num?)?.toInt() ?? 0,
grossMarginPercent: (json['gross_margin_percent'] as num?)?.toDouble() ?? 0, grossMarginPercent: (json['gross_margin_percent'] as num?)?.toDouble(),
treasuryBalanceJod: (json['treasury_balance_jod'] as num?)?.toDouble() ?? 0, treasuryBalanceJod: (json['treasury_balance_jod'] as num?)?.toDouble(),
totalCliqInflowJod: (json['total_cliq_inflow_jod'] as num?)?.toDouble() ?? 0, totalCliqInflowJod: (json['total_cliq_inflow_jod'] as num?)?.toDouble(),
pendingPayoutsCount: (json['pending_payouts_count'] as num?)?.toInt() ?? 0, pendingPayoutsCount: (json['pending_payouts_count'] as num?)?.toInt(),
r2BandwidthCostSavingsJod: (json['r2_cost_savings_jod'] as num?)?.toDouble() ?? 0, r2BandwidthCostSavingsJod: (json['r2_cost_savings_jod'] as num?)?.toDouble(),
uptimePercent: (json['uptime_percent'] as num?)?.toDouble() ?? 0, uptimePercent: (json['uptime_percent'] as num?)?.toDouble(),
); );
} }
} }
@@ -26,19 +26,10 @@ class SuperAdminCubit extends Cubit<SuperAdminState> {
telemetry: telemetry, telemetry: telemetry,
aiNodes: aiNodes, aiNodes: aiNodes,
alerts: alerts, alerts: alerts,
isEmergencyKillSwitchActive: false,
)); ));
} catch (e) { } catch (e) {
emit(SuperAdminError('فشل تحميل لوحة القيادة السيادية: $e')); emit(SuperAdminError('فشل تحميل لوحة القيادة السيادية: $e'));
} }
} }
void toggleEmergencyKillSwitch() {
if (state is SuperAdminLoaded) {
final cur = state as SuperAdminLoaded;
emit(cur.copyWith(
isEmergencyKillSwitchActive: !cur.isEmergencyKillSwitchActive,
));
}
}
} }
@@ -12,26 +12,22 @@ class SuperAdminLoaded extends SuperAdminState {
final MacroTelemetryModel telemetry; final MacroTelemetryModel telemetry;
final List<AiClusterNodeModel> aiNodes; final List<AiClusterNodeModel> aiNodes;
final List<SecurityIntegrityAlertModel> alerts; final List<SecurityIntegrityAlertModel> alerts;
final bool isEmergencyKillSwitchActive;
const SuperAdminLoaded({ const SuperAdminLoaded({
required this.telemetry, required this.telemetry,
required this.aiNodes, required this.aiNodes,
required this.alerts, required this.alerts,
this.isEmergencyKillSwitchActive = false,
}); });
SuperAdminLoaded copyWith({ SuperAdminLoaded copyWith({
MacroTelemetryModel? telemetry, MacroTelemetryModel? telemetry,
List<AiClusterNodeModel>? aiNodes, List<AiClusterNodeModel>? aiNodes,
List<SecurityIntegrityAlertModel>? alerts, List<SecurityIntegrityAlertModel>? alerts,
bool? isEmergencyKillSwitchActive,
}) { }) {
return SuperAdminLoaded( return SuperAdminLoaded(
telemetry: telemetry ?? this.telemetry, telemetry: telemetry ?? this.telemetry,
aiNodes: aiNodes ?? this.aiNodes, aiNodes: aiNodes ?? this.aiNodes,
alerts: alerts ?? this.alerts, alerts: alerts ?? this.alerts,
isEmergencyKillSwitchActive: isEmergencyKillSwitchActive ?? this.isEmergencyKillSwitchActive,
); );
} }
} }
@@ -12,24 +12,20 @@ class TreasuryLoading extends TreasuryState {}
class TreasuryLoaded extends TreasuryState { class TreasuryLoaded extends TreasuryState {
final List<PayoutQueueItemModel> queue; final List<PayoutQueueItemModel> queue;
final double totalTreasuryBalanceJod; final double? totalTreasuryBalanceJod;
final double totalApprovedTodayJod;
const TreasuryLoaded({ const TreasuryLoaded({
required this.queue, required this.queue,
required this.totalTreasuryBalanceJod, required this.totalTreasuryBalanceJod,
required this.totalApprovedTodayJod,
}); });
TreasuryLoaded copyWith({ TreasuryLoaded copyWith({
List<PayoutQueueItemModel>? queue, List<PayoutQueueItemModel>? queue,
double? totalTreasuryBalanceJod, double? totalTreasuryBalanceJod,
double? totalApprovedTodayJod,
}) { }) {
return TreasuryLoaded( return TreasuryLoaded(
queue: queue ?? this.queue, queue: queue ?? this.queue,
totalTreasuryBalanceJod: totalTreasuryBalanceJod ?? this.totalTreasuryBalanceJod, totalTreasuryBalanceJod: totalTreasuryBalanceJod ?? this.totalTreasuryBalanceJod,
totalApprovedTodayJod: totalApprovedTodayJod ?? this.totalApprovedTodayJod,
); );
} }
} }
@@ -42,69 +38,21 @@ class TreasuryCubit extends Cubit<TreasuryState> {
Future<void> loadTreasury() async { Future<void> loadTreasury() async {
emit(TreasuryLoading()); emit(TreasuryLoading());
try { try {
final queue = await repository.getPayoutQueue(); final results = await Future.wait([repository.getPayoutQueue(), repository.getMacroTelemetry()]);
final queue = results[0] as List<PayoutQueueItemModel>;
final telemetry = results[1] as MacroTelemetryModel;
emit(TreasuryLoaded( emit(TreasuryLoaded(
queue: queue, queue: queue,
totalTreasuryBalanceJod: 54200.0, totalTreasuryBalanceJod: telemetry.treasuryBalanceJod,
totalApprovedTodayJod: 0.0,
));
} catch (_) {
emit(const TreasuryLoaded(
queue: [],
totalTreasuryBalanceJod: 54200.0,
totalApprovedTodayJod: 0.0,
)); ));
} 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) { class TreasuryError extends TreasuryState {
if (item.id == payoutId) { final String message;
approvedAmt = item.amountJod; const TreasuryError(this.message);
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,
));
}
}
} }
@@ -73,28 +73,11 @@ class _SuperAdminShellState extends State<SuperAdminShell> {
letterSpacing: 0.3, 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 SizedBox(height: 3),
const Text( const Text(
'المؤسس والمهندس المعماري: حمزة العائد (Hamza Ayed)', 'البيانات المعروضة تعتمد على مصادر الخادم المتصلة فقط',
style: TextStyle(fontSize: 11.5, color: Colors.white60), style: TextStyle(fontSize: 11.5, color: Colors.white60),
), ),
], ],
@@ -155,10 +138,7 @@ class _SuperAdminShellState extends State<SuperAdminShell> {
MacroRadarTab(telemetry: state.telemetry), MacroRadarTab(telemetry: state.telemetry),
AiClusterTab(nodes: state.aiNodes), AiClusterTab(nodes: state.aiNodes),
const TreasuryCliqTab(), const TreasuryCliqTab(),
SecurityIntegrityTab( SecurityIntegrityTab(alerts: state.alerts),
alerts: state.alerts,
isKillSwitchActive: state.isEmergencyKillSwitchActive,
),
const OrganizationTab(), const OrganizationTab(),
], ],
); );
@@ -1,284 +1,29 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../core/theme/super_admin_theme.dart'; import '../../../core/theme/super_admin_theme.dart';
import '../../../data/models/super_admin_models.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 { class AiClusterTab extends StatelessWidget {
final List<AiClusterNodeModel> nodes; final List<AiClusterNodeModel> nodes;
const AiClusterTab({super.key, required this.nodes}); const AiClusterTab({super.key, required this.nodes});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) => ListView(padding: const EdgeInsets.all(16), children: [
return SingleChildScrollView( const Text('عقد الذكاء الاصطناعي', style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
padding: const EdgeInsets.all(16), const SizedBox(height: 6),
child: Column( const Text('تعرض هذه الشاشة فقط العقد المعرّفة من مصدر تشغيل الخادم. لا تستنتج مكان المعالجة أو زمن الاستجابة أو سياسة البيانات عند غياب telemetry حقيقية.', style: TextStyle(color: Colors.white60, height: 1.4)),
crossAxisAlignment: CrossAxisAlignment.stretch, const SizedBox(height: 16),
children: [ if (nodes.isEmpty) const Padding(padding: EdgeInsets.all(24), child: Center(child: Text('لا توجد بيانات عقد متاحة من الخادم.', style: TextStyle(color: Colors.white60)))) else ...nodes.map(_node),
// 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),
// Nodes List Section Title Widget _node(AiClusterNodeModel node) {
const Row( final usableVram = node.gpuTotalVramGb > 0;
children: [ final ratio = usableVram ? (node.gpuVramUsageGb / node.gpuTotalVramGb).clamp(0.0, 1.0) : 0.0;
Icon(CupertinoIcons.circle_grid_hex_fill, color: SuperAdminTheme.royalGold, size: 18), return Card(color: SuperAdminTheme.surfaceCard, child: Padding(padding: const EdgeInsets.all(16), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(width: 8), Text(node.nodeName, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
Text( const SizedBox(height: 4), Text('${node.modelName} • ${node.role} • ${node.status}', style: const TextStyle(color: Colors.white60)),
'حالة عقد المعالجة الرسومية (GPU Nodes Telemetry)', 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))],
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold, color: Colors.white), if (node.latencyMs > 0) Padding(padding: const EdgeInsets.only(top: 8), child: Text('${node.latencyMs} ms', style: const TextStyle(color: Colors.white60))),
), ])));
],
),
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<Color>(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)),
],
),
),
],
);
} }
} }
@@ -1,228 +1,26 @@
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../core/theme/super_admin_theme.dart'; import '../../../core/theme/super_admin_theme.dart';
import '../../../data/models/super_admin_models.dart'; import '../../../data/models/super_admin_models.dart';
class MacroRadarTab extends StatelessWidget { class MacroRadarTab extends StatelessWidget {
final MacroTelemetryModel telemetry; final MacroTelemetryModel telemetry;
const MacroRadarTab({super.key, required this.telemetry}); const MacroRadarTab({super.key, required this.telemetry});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) => ListView(padding: const EdgeInsets.all(16), children: [
return SingleChildScrollView( const Text('المؤشرات المتاحة من الخادم', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 18)),
padding: const EdgeInsets.all(16), const SizedBox(height: 6),
child: Column( const Text('لا تعرض هذه الشاشة إلا عدادات قاعدة البيانات. القياسات المالية والتشغيلية تبقى غير متاحة حتى ربط مصادرها الموثقة.', style: TextStyle(color: Colors.white60, height: 1.4)),
crossAxisAlignment: CrossAxisAlignment.stretch, const SizedBox(height: 16),
children: [ _grid(),
// Unit Economics & Gross Margin Sovereign Moat Banner const SizedBox(height: 18),
Container( _unavailable('المالية والتسوية', telemetry.treasuryBalanceJod == null ? 'غير متاحة: مزود الدفع ودفتر التسوية غير مربوطين.' : '${telemetry.treasuryBalanceJod} د.أ'),
padding: const EdgeInsets.all(20), _unavailable('مراقبة الاستقرار', telemetry.uptimePercent == null ? 'غير متاحة: لا يوجد مزود مراقبة متصل.' : '${telemetry.uptimePercent}%'),
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),
// Core Metric Tiles (2x2 Grid) 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)]);
Row( 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))])));
children: [ 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))));
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)),
),
],
);
}
} }
@@ -1,257 +1,24 @@
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/theme/super_admin_theme.dart'; import '../../../core/theme/super_admin_theme.dart';
import '../../../data/models/super_admin_models.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 { class SecurityIntegrityTab extends StatelessWidget {
final List<SecurityIntegrityAlertModel> alerts; final List<SecurityIntegrityAlertModel> alerts;
final bool isKillSwitchActive; const SecurityIntegrityTab({super.key, required this.alerts});
const SecurityIntegrityTab({
super.key,
required this.alerts,
required this.isKillSwitchActive,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) => ListView(padding: const EdgeInsets.all(16), children: [
return SingleChildScrollView( const Text('الأمن والنزاهة', style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
padding: const EdgeInsets.all(16), const SizedBox(height: 8),
child: Column( _notice('لا توجد مراقبة أمنية موثقة متصلة حالياً. لذلك لا تعني القائمة الفارغة أن النظام آمن أو أن الإنذارات تساوي صفراً.'),
crossAxisAlignment: CrossAxisAlignment.stretch, const SizedBox(height: 18),
children: [ const Text('الإنذارات المسجلة', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
// Emergency Kill-Switch Card const SizedBox(height: 10),
Container( if (alerts.isEmpty) const Text('مصدر إنذارات النزاهة غير متاح حالياً.', style: TextStyle(color: Colors.white60)) else ...alerts.map(_alert),
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<SuperAdminCubit>().toggleEmergencyKillSwitch();
},
),
],
),
),
const SizedBox(height: 20),
// Sovereign Encryption & Integrity Badges 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)))]));
Row( 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))));
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),
),
],
),
);
}
} }
@@ -1,21 +1,12 @@
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/theme/super_admin_theme.dart'; import '../../../core/theme/super_admin_theme.dart';
import '../../../data/models/super_admin_models.dart'; import '../../../data/models/super_admin_models.dart';
import '../../../logic/cubits/treasury_cubit.dart'; import '../../../logic/cubits/treasury_cubit.dart';
/** /// Read-only until a payment provider and an auditable settlement ledger exist.
* ==============================================================================
* SAQEL SOVEREIGN COMMAND - TREASURY & CLIQ PAYOUT QUEUE TAB
* ==============================================================================
*
* إدارة الخزينة المركزية السيادية والموافقة الفورية على دفعات المعلمين عبر CliQ:
* - رصيد الخزينة الإجمالي (54,200 دينار أردني)
* - موافقة جماعية بنقرة واحدة (1-Click Mass Payout Approval)
* - معالجة فورية عبر نمط Siro-Engine بدون عمولات وسيطة (Zero-Intermediary Fee)
* - سجل تفصيلي لطلبات السحب المعلقة والمكتملة.
*/
class TreasuryCliqTab extends StatelessWidget { class TreasuryCliqTab extends StatelessWidget {
const TreasuryCliqTab({super.key}); const TreasuryCliqTab({super.key});
@@ -23,299 +14,27 @@ class TreasuryCliqTab extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<TreasuryCubit, TreasuryState>( return BlocBuilder<TreasuryCubit, TreasuryState>(
builder: (context, state) { builder: (context, state) {
if (state is TreasuryLoading) { if (state is TreasuryLoading) return const Center(child: CupertinoActivityIndicator(color: SuperAdminTheme.cyberCyan));
return const Center( 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))));
child: CupertinoActivityIndicator(color: SuperAdminTheme.cyberCyan), if (state is! TreasuryLoaded) return const SizedBox.shrink();
); final queued = state.queue.where((item) => item.status == 'queued').toList();
} final queuedAmount = queued.fold<double>(0, (sum, item) => sum + item.amountJod);
return ListView(padding: const EdgeInsets.all(16), children: [
if (state is TreasuryLoaded) { _notice('الخزينة للقراءة فقط', 'لا يوجد مزود دفع أو دفتر تسوية موثق متصل حالياً؛ لا يمكن اعتماد أو إرسال أي سحب من هذه الشاشة.'),
final pendingItems = state.queue.where((item) => item.status == 'queued').toList(); const SizedBox(height: 16),
final double pendingSum = pendingItems.fold(0.0, (acc, item) => acc + item.amountJod); _metric('رصيد قابل للتسوية', state.totalTreasuryBalanceJod == null ? 'غير متاح' : '${state.totalTreasuryBalanceJod!.toStringAsFixed(2)} د.أ'),
const SizedBox(height: 16),
return SingleChildScrollView( Text('طلبات السحب المسجلة (${state.queue.length})', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)),
padding: const EdgeInsets.all(16), const SizedBox(height: 6),
child: Column( Text('قيمة الطلبات بحالة queued: ${queuedAmount.toStringAsFixed(2)} د.أ', style: const TextStyle(color: Colors.white60)),
crossAxisAlignment: CrossAxisAlignment.stretch, const SizedBox(height: 12),
children: [ if (state.queue.isEmpty) const Padding(padding: EdgeInsets.all(24), child: Center(child: Text('لا توجد سجلات سحب متاحة.', style: TextStyle(color: Colors.white60)))) else ...state.queue.map(_payout),
// 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<TreasuryCubit>().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();
}, },
); );
} }
Widget _buildStatBadge(String label, String value, Color color) { 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))]));
return Expanded( 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))]));
child: Container( 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))));
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<TreasuryCubit>().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),
),
),
],
),
],
),
);
}
} }
@@ -387,6 +387,7 @@ class TeacherRepository {
required String gradeLevel, required String gradeLevel,
required String subject, required String subject,
required String curriculumKey, required String curriculumKey,
required String videoVersionId,
String? fileName, String? fileName,
double? fileSizeMb, double? fileSizeMb,
String? filePath, String? filePath,
@@ -410,6 +411,7 @@ class TeacherRepository {
'grade_level': gradeLevel, 'grade_level': gradeLevel,
'subject': subject, 'subject': subject,
'curriculum_key': curriculumKey, 'curriculum_key': curriculumKey,
'video_version_id': videoVersionId,
}); });
AppLogger.request( AppLogger.request(
method: 'POST', method: 'POST',
@@ -420,6 +422,7 @@ class TeacherRepository {
'grade_level': gradeLevel, 'grade_level': gradeLevel,
'subject': subject, 'subject': subject,
'curriculum_key': curriculumKey, 'curriculum_key': curriculumKey,
'video_version_id': videoVersionId,
'file_name': fileName, 'file_name': fileName,
'file_size_mb': fileSizeMb 'file_size_mb': fileSizeMb
}, },
@@ -468,6 +471,42 @@ class TeacherRepository {
} }
} }
Future<Map<String, dynamic>> 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<String, dynamic>.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<Map<String, dynamic>> getCurriculumTree() async { Future<Map<String, dynamic>> getCurriculumTree() async {
final headers = await _authHeaders(); final headers = await _authHeaders();
final response = await _client final response = await _client
@@ -1,4 +1,5 @@
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import '../../data/models/teacher_models.dart'; import '../../data/models/teacher_models.dart';
import '../../data/repositories/teacher_repository.dart'; import '../../data/repositories/teacher_repository.dart';
@@ -267,6 +268,20 @@ class TeacherStudioCubit extends Cubit<TeacherStudioState> {
state.unitKey, state.unitKey,
state.lessonKey state.lessonKey
].where((e) => e.isNotEmpty).join('/'); ].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) => void reportError(String message) =>
emit(state.copyWith(errorMessage: message)); emit(state.copyWith(errorMessage: message));
@@ -300,6 +315,11 @@ class TeacherStudioCubit extends Cubit<TeacherStudioState> {
errorMessage: 'اختر الصف والمبحث والفصل والوحدة والدرس أولاً.')); errorMessage: 'اختر الصف والمبحث والفصل والوحدة والدرس أولاً.'));
return; return;
} }
if (selectedCurriculumLessonId == null) {
emit(state.copyWith(
errorMessage: 'هذا الدرس غير معتمد للنشر بعد، لذلك لا يمكن رفع حصة له.'));
return;
}
await uploadAndPublishLesson(); await uploadAndPublishLesson();
} }
@@ -316,12 +336,31 @@ class TeacherStudioCubit extends Cubit<TeacherStudioState> {
clearError: true, clearError: true,
)); ));
try { 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( final res = await repository.uploadLesson(
title: state.lessonTitle, title: state.lessonTitle,
durationMinutes: state.durationMinutes, durationMinutes: state.durationMinutes,
gradeLevel: state.gradeLevel, gradeLevel: state.gradeLevel,
subject: selectedSubjectName, subject: selectedSubjectName,
curriculumKey: curriculumKey, curriculumKey: curriculumKey,
videoVersionId: videoVersionId,
fileName: state.selectedFileName!, fileName: state.selectedFileName!,
fileSizeMb: state.selectedFileSizeMb, fileSizeMb: state.selectedFileSizeMb,
filePath: state.selectedFilePath, filePath: state.selectedFilePath,
@@ -368,7 +407,7 @@ class TeacherStudioCubit extends Cubit<TeacherStudioState> {
return false; return false;
} }
final msg = res['message']?.toString() ?? 'تم اعتماد الحصة ورفعها بنجاح.'; final msg = res['message']?.toString() ?? 'تم رفع الحصة إلى طابور المراجعة.';
emit(state.copyWith( emit(state.copyWith(
isAuditing: false, isAuditing: false,
isUploading: false, isUploading: false,
@@ -183,6 +183,29 @@ class CurriculumController
public function getTree(Request $request, Response $response): void public function getTree(Request $request, Response $response): void
{ {
$tree = CurriculumService::getCurriculumTree(); $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 { 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"); $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=[]; $byPath=[];
@@ -192,6 +215,52 @@ class CurriculumController
if (isset($byPath[$path])) $lesson=array_merge($lesson,$byPath[$path]); if (isset($byPath[$path])) $lesson=array_merge($lesson,$byPath[$path]);
} }
unset($grade,$subject,$semester,$unit,$lesson); 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()); } } catch (\Throwable $e) { error_log('Published curriculum tree enrichment unavailable: '.$e->getMessage()); }
$response->json(['status'=>'success','data'=>$tree]); $response->json(['status'=>'success','data'=>$tree]);
} }
@@ -61,35 +61,25 @@ class SuperAdminController
} }
public function overview(Request $request, Response $response): void public function overview(Request $request, Response $response): void
{ {
CliqPaymentService::ensureSchema();
$counts = [ $counts = [
'total_directorates' => $this->count('directorates'), 'total_directorates' => $this->count('directorates'),
'total_schools' => $this->count('schools'), 'total_schools' => $this->count('schools'),
'total_students' => $this->count('students'), 'total_students' => $this->count('students'),
'total_teachers' => $this->count('teachers'), '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([ $response->json([
'status' => 'success', 'status' => 'success',
'data' => array_merge($counts, [ 'data' => array_merge($counts, [
'gross_margin_percent' => $inflow > 0 ? round((($inflow - $paidOut) / $inflow) * 100, 2) : 0.0, // Payment settlement and infrastructure monitoring are not
'treasury_balance_jod' => max(0, $inflow - $paidOut), // connected yet. Null is intentional: zero would be a claim.
'total_cliq_inflow_jod' => $inflow, 'gross_margin_percent' => null,
'pending_payouts_count' => (int)($payouts['pending_count'] ?? 0), 'treasury_balance_jod' => null,
'r2_cost_savings_jod' => 0.0, 'total_cliq_inflow_jod' => null,
'uptime_percent' => 0.0, 'pending_payouts_count' => null,
'r2_cost_savings_jod' => null,
'uptime_percent' => null,
'measurement_notes' => [ 'measurement_notes' => [
'financial_metrics' => 'غير متاحة حتى ربط مزود الدفع ودفتر التسوية.',
'r2_cost_savings_jod' => 'يتطلب ربط Cloudflare Billing Analytics', 'r2_cost_savings_jod' => 'يتطلب ربط Cloudflare Billing Analytics',
'uptime_percent' => 'يتطلب ربط مزود مراقبة خارجي', 'uptime_percent' => 'يتطلب ربط مزود مراقبة خارجي',
], ],
@@ -124,7 +114,7 @@ class SuperAdminController
public function securityAlerts(Request $request, Response $response): void public function securityAlerts(Request $request, Response $response): void
{ {
// No synthetic alerts: an audit-event pipeline will populate this endpoint. // 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 private function count(string $table): int
+9 -1
View File
@@ -327,6 +327,9 @@ class VideoController
); );
$auditId = $this->recordUploadAudit($request, $courseId, $_FILES['video'], $preflight); $auditId = $this->recordUploadAudit($request, $courseId, $_FILES['video'], $preflight);
if (($preflight['decision'] ?? '') !== 'approved') { 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([ $response->status(422)->json([
'status' => 'needs_review', 'status' => 'needs_review',
'message' => 'لم يتم حفظ الفيديو في Cloudflare R2 قبل اجتياز تدقيق الجودة.', 'message' => 'لم يتم حفظ الفيديو في Cloudflare R2 قبل اجتياز تدقيق الجودة.',
@@ -335,6 +338,7 @@ class VideoController
return; return;
} }
$reviewQueued = false;
try { try {
// Fast direct upload: save file locally and slice HLS instantly via stream copy (-c copy) // Fast direct upload: save file locally and slice HLS instantly via stream copy (-c copy)
$uploadResult = VideoService::handleDirectUpload($_FILES['video'], $courseId, $title, false); $uploadResult = VideoService::handleDirectUpload($_FILES['video'], $courseId, $title, false);
@@ -364,11 +368,12 @@ class VideoController
$lessonId, $lessonId,
hash_file('sha256', (string)$_FILES['video']['tmp_name']) hash_file('sha256', (string)$_FILES['video']['tmp_name'])
); );
$reviewQueued = true;
// Immediately send HTTP 201 response to Flutter client and close connection // Immediately send HTTP 201 response to Flutter client and close connection
$responsePayload = [ $responsePayload = [
'status' => 'success', 'status' => 'success',
'message' => 'تم رفع الفيديو واعتماده مبدئياً بنجاح. تجري المزامنة السحابية والتحليل السقراطي في الخلفية.', 'message' => 'تم رفع الفيديو إلى طابور المراجعة. لن يظهر للطلاب قبل اكتمال الأدلة وقرار المراجع البشري.',
'data' => array_merge($uploadResult, [ 'data' => array_merge($uploadResult, [
'lesson_id' => $lessonId, 'lesson_id' => $lessonId,
'title' => $title, 'title' => $title,
@@ -406,6 +411,9 @@ class VideoController
exit; exit;
} catch (\Throwable $e) { } catch (\Throwable $e) {
if (!$reviewQueued) {
TeacherSubmissionService::releaseUploadReservation((int)$request->user_id, $videoVersionId);
}
$response->status(500)->json([ $response->status(500)->json([
'status' => 'error', 'status' => 'error',
'message' => $e->getMessage() 'message' => $e->getMessage()
@@ -154,74 +154,6 @@ class CurriculumService
return []; 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; return $tree;
} }
@@ -28,6 +28,7 @@ final class PublishedContentService
JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
WHERE a.uuid = ? WHERE a.uuid = ?
AND a.review_status = 'approved' AND a.review_status = 'approved'
AND a.rights_status = 'cleared'
AND pb.status = 'published' AND pb.status = 'published'
AND cl.source_status = 'approved' AND cl.source_status = 'approved'
ORDER BY pb.published_at DESC, pb.id DESC ORDER BY pb.published_at DESC, pb.id DESC
@@ -41,6 +41,17 @@ final class TeacherSubmissionService {
[$versionUuid, $teacherId] [$versionUuid, $teacherId]
) === 1; ) === 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 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 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);} 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);}
+2 -2
View File
@@ -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-status', [\App\Controllers\CurriculumController::class, 'getUploadStatus'], $curriculumManagerMiddleware);
$router->get('/api/curriculum/upload-log', [\App\Controllers\CurriculumController::class, 'getUploadLog'], $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/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/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/generate-ai-assets', [\App\Controllers\CurriculumController::class, 'generateAiAssets'], $curriculumManagerMiddleware);
$router->post('/api/curriculum/bake-lab', [\App\Controllers\CurriculumController::class, 'bakeInteractiveLab'], $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', [\App\Controllers\CurriculumController::class, 'listSimulations']);
$router->get('/api/curriculum/simulations/{subject}/{simName}', [\App\Controllers\CurriculumController::class, 'getSimulation']); $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/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}/videos', [\App\Controllers\VideoController::class, 'listPublishedLessonVideos'], $studentMiddleware);
$router->get('/api/curriculum/lessons/{lessonId}/english-package', [\App\Controllers\CurriculumController::class, 'getPublishedEnglishPackage'], $studentMiddleware); $router->get('/api/curriculum/lessons/{lessonId}/english-package', [\App\Controllers\CurriculumController::class, 'getPublishedEnglishPackage'], $studentMiddleware);
@@ -0,0 +1,171 @@
<?php
declare(strict_types=1);
/**
* Registers supplied Grade 10 textbook PDFs as review-only source assets.
*
* Default mode is read-only. --apply copies PDFs into curriculum storage and
* creates draft, rights-review-required content_assets records. It never
* approves rights, publishes a bundle, or invents curriculum lessons.
*
* Usage:
* php backend/scripts/import_grade10_book_sources.php
* php backend/scripts/import_grade10_book_sources.php --apply
*/
use App\Core\Database;
$apply = in_array('--apply', $argv, true);
$projectRoot = dirname(__DIR__, 2);
$sourceRoot = $projectRoot . '/books';
$curriculumRoot = $projectRoot . '/backend/storage/curriculum';
$manifestPath = $curriculumRoot . '/manifest.json';
if (!is_dir($sourceRoot) || !is_file($manifestPath)) {
fwrite(STDERR, "Books directory or curriculum manifest is unavailable.\n");
exit(1);
}
$manifest = json_decode((string) file_get_contents($manifestPath), true);
if (!is_array($manifest)) {
fwrite(STDERR, "Curriculum manifest is not valid JSON.\n");
exit(1);
}
$knownSemesters = [];
foreach (($manifest['grade_10']['subjects'] ?? []) as $subjectKey => $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));
}
+7 -2
View File
@@ -1,4 +1,5 @@
#!/bin/bash #!/bin/bash
set -euo pipefail
# ============================================================================== # ==============================================================================
# SAQEL PLATFORM - DEPLOY & GIT SYNCHRONIZATION SCRIPT # SAQEL PLATFORM - DEPLOY & GIT SYNCHRONIZATION SCRIPT
@@ -14,10 +15,14 @@ echo "📦 Staging all files..."
git add . git add .
echo "📝 Committing: $COMMIT_MSG" 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..." echo "🚀 Pushing to origin..."
git push origin --all git push origin main
echo "✅ Done! On the production server run:" echo "✅ Done! On the production server run:"
echo " git pull" echo " git pull"
echo " (and if websocket server changed: php backend/websocket/server.php restart -d)" echo " (and if websocket server changed: php backend/websocket/server.php restart -d)"