Update Saqel Platform: 2026-09-08 13:43:36
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
|
||||
/// Ultra-Resilient Storage Service for Admin App
|
||||
@@ -22,13 +21,6 @@ class StorageService {
|
||||
static const String _keyUser = 'saqel_admin_user_data';
|
||||
|
||||
Future<void> _writeSafe(String key, String value) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(key, value);
|
||||
} catch (e) {
|
||||
AppLogger.log('Prefs write notice ($key): $e', tag: 'STORAGE');
|
||||
}
|
||||
|
||||
try {
|
||||
await _secureStorage.write(key: key, value: value);
|
||||
} on PlatformException catch (e) {
|
||||
@@ -42,23 +34,13 @@ class StorageService {
|
||||
if (val != null && val.isNotEmpty) return val;
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(key);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> saveToken(String token) async => await _writeSafe(_keyToken, token);
|
||||
Future<String?> getToken() async => await _readSafe(_keyToken);
|
||||
|
||||
Future<void> clearSession() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_keyToken);
|
||||
await prefs.remove(_keyUser);
|
||||
} catch (_) {}
|
||||
try {
|
||||
await _secureStorage.delete(key: _keyToken);
|
||||
await _secureStorage.delete(key: _keyUser);
|
||||
|
||||
@@ -28,17 +28,17 @@ class DirectorateOverview {
|
||||
|
||||
factory DirectorateOverview.fromJson(Map<String, dynamic> json) {
|
||||
return DirectorateOverview(
|
||||
code: json['code'] ?? 'MC-JOR',
|
||||
name: json['name'] ?? 'مديرية التربية والتعليم والثقافة العسكرية',
|
||||
commanderName: json['commander_name'] ?? 'مدير التعليم والثقافة العسكرية',
|
||||
totalSchools: json['total_schools'] ?? 43,
|
||||
totalStudents: json['total_students'] ?? 19350,
|
||||
totalTeachers: json['total_teachers'] ?? 812,
|
||||
annualContractValue: (json['annual_contract_value'] as num?)?.toDouble() ?? 20000.0,
|
||||
recordedLessonsMonth: json['recorded_lessons_month'] ?? 1420,
|
||||
complianceRate: (json['compliance_rate'] as num?)?.toDouble() ?? 94.5,
|
||||
aiAverageScore: (json['ai_average_score'] as num?)?.toDouble() ?? 91.8,
|
||||
activeUnifiedExams: json['active_unified_exams'] ?? 2,
|
||||
code: json['code']?.toString() ?? '',
|
||||
name: json['name']?.toString() ?? '',
|
||||
commanderName: json['commander_name']?.toString() ?? '',
|
||||
totalSchools: (json['total_schools'] as num?)?.toInt() ?? 0,
|
||||
totalStudents: (json['total_students'] as num?)?.toInt() ?? 0,
|
||||
totalTeachers: (json['total_teachers'] as num?)?.toInt() ?? 0,
|
||||
annualContractValue: (json['annual_contract_value'] as num?)?.toDouble() ?? 0,
|
||||
recordedLessonsMonth: (json['recorded_lessons_month'] as num?)?.toInt() ?? 0,
|
||||
complianceRate: (json['compliance_rate'] as num?)?.toDouble() ?? 0,
|
||||
aiAverageScore: (json['ai_average_score'] as num?)?.toDouble() ?? 0,
|
||||
activeUnifiedExams: (json['active_unified_exams'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -86,17 +86,79 @@ class SchoolItem {
|
||||
id: json['id'] ?? 0,
|
||||
code: json['code'] ?? '',
|
||||
name: json['name'] ?? '',
|
||||
governorate: json['governorate'] ?? 'العاصمة',
|
||||
weightTier: json['weight_tier'] ?? 'tier_b_medium',
|
||||
studentCount: json['student_count'] ?? 450,
|
||||
teacherCount: json['teacher_count'] ?? 24,
|
||||
compliance: (json['compliance'] as num?)?.toDouble() ?? 90.0,
|
||||
status: json['status'] ?? 'good',
|
||||
annualFee: (json['annual_fee'] as num?)?.toDouble() ?? 450.0,
|
||||
governorate: json['governorate']?.toString() ?? '',
|
||||
weightTier: json['weight_tier']?.toString() ?? '',
|
||||
studentCount: (json['student_count'] as num?)?.toInt() ?? 0,
|
||||
teacherCount: (json['teacher_count'] as num?)?.toInt() ?? 0,
|
||||
compliance: (json['compliance'] as num?)?.toDouble() ?? 0,
|
||||
status: json['status']?.toString() ?? 'unknown',
|
||||
annualFee: (json['annual_fee'] as num?)?.toDouble() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Detailed school data used by the principal and supervisor dashboards.
|
||||
class SchoolDetail {
|
||||
final int id;
|
||||
final String name;
|
||||
final String governorate;
|
||||
final int studentCount;
|
||||
final int teacherCount;
|
||||
final String weightTier;
|
||||
final String directorName;
|
||||
final String biweeklyCycle;
|
||||
final String cycleProgress;
|
||||
|
||||
const SchoolDetail({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.governorate,
|
||||
required this.studentCount,
|
||||
required this.teacherCount,
|
||||
required this.weightTier,
|
||||
required this.directorName,
|
||||
required this.biweeklyCycle,
|
||||
required this.cycleProgress,
|
||||
});
|
||||
|
||||
factory SchoolDetail.fromJson(Map<String, dynamic> json) => SchoolDetail(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
name: json['name']?.toString() ?? '',
|
||||
governorate: json['governorate']?.toString() ?? '',
|
||||
studentCount: (json['student_count'] as num?)?.toInt() ?? 0,
|
||||
teacherCount: (json['teacher_count'] as num?)?.toInt() ?? 0,
|
||||
weightTier: json['weight_tier']?.toString() ?? '',
|
||||
directorName: json['director_name']?.toString() ?? '',
|
||||
biweeklyCycle: json['biweekly_cycle']?.toString() ?? '',
|
||||
cycleProgress: json['cycle_progress']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
/// A real computer-lab resource returned by the school dashboard API.
|
||||
class ComputerLabItem {
|
||||
final int id;
|
||||
final String name;
|
||||
final int deviceCount;
|
||||
final int onlineDevices;
|
||||
final String status;
|
||||
|
||||
const ComputerLabItem({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.deviceCount,
|
||||
required this.onlineDevices,
|
||||
required this.status,
|
||||
});
|
||||
|
||||
factory ComputerLabItem.fromJson(Map<String, dynamic> json) => ComputerLabItem(
|
||||
id: (json['id'] as num?)?.toInt() ?? 0,
|
||||
name: json['name']?.toString() ?? '',
|
||||
deviceCount: (json['device_count'] as num?)?.toInt() ?? 0,
|
||||
onlineDevices: (json['online_devices'] as num?)?.toInt() ?? 0,
|
||||
status: json['status']?.toString() ?? 'unknown',
|
||||
);
|
||||
}
|
||||
|
||||
/// Teacher Item Model (المعلم ومؤشر دورية الأسبوعين)
|
||||
class TeacherItem {
|
||||
final int id;
|
||||
@@ -129,8 +191,8 @@ class TeacherItem {
|
||||
grade: json['grade'] ?? '',
|
||||
quotaStatus: json['quota_status'] ?? 'pending',
|
||||
lastLesson: json['last_lesson'] ?? '',
|
||||
aiScore: (json['ai_score'] as num?)?.toDouble() ?? 90.0,
|
||||
recordedAt: json['recorded_at'] ?? 'مؤخراً',
|
||||
aiScore: (json['ai_score'] as num?)?.toDouble() ?? 0,
|
||||
recordedAt: json['recorded_at']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -181,12 +243,12 @@ class RecordedLessonResult {
|
||||
teacherName: json['teacher_name'] ?? '',
|
||||
subject: json['subject'] ?? '',
|
||||
lessonTitle: json['lesson_title'] ?? '',
|
||||
fileSizeMb: (json['file_size_mb'] as num?)?.toDouble() ?? 350.0,
|
||||
durationMinutes: json['duration_minutes'] ?? 45,
|
||||
aiAlignmentScore: (json['ai_alignment_score'] as num?)?.toDouble() ?? 92.0,
|
||||
teacherTalkRatio: json['teacher_talk_ratio'] ?? '60%',
|
||||
status: json['status'] ?? 'approved_official',
|
||||
reportSummary: json['report_summary'] ?? 'تم الاعتماد بنجاح',
|
||||
fileSizeMb: (json['file_size_mb'] as num?)?.toDouble() ?? 0,
|
||||
durationMinutes: (json['duration_minutes'] as num?)?.toInt() ?? 0,
|
||||
aiAlignmentScore: (json['ai_alignment_score'] as num?)?.toDouble() ?? 0,
|
||||
teacherTalkRatio: json['teacher_talk_ratio']?.toString() ?? '',
|
||||
status: json['status']?.toString() ?? 'unknown',
|
||||
reportSummary: json['report_summary']?.toString() ?? '',
|
||||
socraticCheckpoints: checkpoints,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,20 +21,28 @@ class DirectorateRepository {
|
||||
/// Record field lesson observation
|
||||
Future<Map<String, dynamic>> recordLesson({
|
||||
required int schoolId,
|
||||
required String teacherName,
|
||||
required int teacherId,
|
||||
required String subject,
|
||||
required String gradeLevel,
|
||||
required String topic,
|
||||
required double score,
|
||||
required String notes,
|
||||
required String filePath,
|
||||
required String fileName,
|
||||
}) async {
|
||||
return await DirectorateApiService.recordFieldObservation(
|
||||
final result = await DirectorateApiService.recordLesson(
|
||||
schoolId: schoolId,
|
||||
teacherName: teacherName,
|
||||
teacherId: teacherId,
|
||||
subject: subject,
|
||||
topic: topic,
|
||||
score: score,
|
||||
notes: notes,
|
||||
gradeLevel: gradeLevel,
|
||||
lessonTitle: topic,
|
||||
durationMinutes: 0,
|
||||
filePath: filePath,
|
||||
fileName: fileName,
|
||||
);
|
||||
return {
|
||||
'status': 'success',
|
||||
'school_id': schoolId,
|
||||
'data': result,
|
||||
};
|
||||
}
|
||||
|
||||
/// Push unified exam to computer lab
|
||||
@@ -44,12 +52,15 @@ class DirectorateRepository {
|
||||
required String labName,
|
||||
required int deviceCount,
|
||||
}) async {
|
||||
return await DirectorateApiService.pushExamToLab(
|
||||
schoolId: schoolId,
|
||||
examTitle: examTitle,
|
||||
labName: labName,
|
||||
deviceCount: deviceCount,
|
||||
);
|
||||
final examId = int.tryParse(examTitle);
|
||||
if (examId == null) throw StateError('معرف الامتحان يجب أن يكون رقمياً.');
|
||||
final pushed = await DirectorateApiService.pushExamToLab(examId: examId, schoolId: schoolId);
|
||||
return {
|
||||
'status': pushed ? 'success' : 'error',
|
||||
'school_id': schoolId,
|
||||
'lab_name': labName,
|
||||
'device_count': deviceCount,
|
||||
};
|
||||
}
|
||||
|
||||
/// Evaluate session integrity
|
||||
@@ -57,10 +68,12 @@ class DirectorateRepository {
|
||||
required int sessionId,
|
||||
required String schoolCode,
|
||||
}) async {
|
||||
return await DirectorateApiService.evaluateSessionIntegrity(
|
||||
sessionId: sessionId,
|
||||
schoolCode: schoolCode,
|
||||
);
|
||||
final result = await DirectorateApiService.evaluateExamSessionIntegrity();
|
||||
return {
|
||||
...result,
|
||||
'session_id': sessionId,
|
||||
'school_code': schoolCode,
|
||||
};
|
||||
}
|
||||
|
||||
/// Dispatch reports to parents via WhatsApp
|
||||
@@ -68,9 +81,7 @@ class DirectorateRepository {
|
||||
required int schoolId,
|
||||
required String grade,
|
||||
}) async {
|
||||
return await DirectorateApiService.dispatchParentReports(
|
||||
schoolId: schoolId,
|
||||
grade: grade,
|
||||
);
|
||||
final result = await DirectorateApiService.dispatchParentReports(schoolId: schoolId);
|
||||
return {...result, 'grade': grade};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'logic/cubits/directorate_cubit.dart';
|
||||
import 'presentation/screens/directorate_command_screen.dart';
|
||||
import 'presentation/screens/school_principal_screen.dart';
|
||||
import 'presentation/screens/admin_auth_screen.dart';
|
||||
import 'services/directorate_api_service.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const SaqelAdminApp());
|
||||
@@ -42,10 +44,43 @@ class SaqelAdminApp extends StatelessWidget {
|
||||
child: child ?? const SizedBox(),
|
||||
);
|
||||
},
|
||||
home: BlocProvider(
|
||||
create: (context) => DirectorateCubit(),
|
||||
child: const UnifiedSupervisorShell(),
|
||||
),
|
||||
home: const AdminAuthGate(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AdminAuthGate extends StatefulWidget {
|
||||
const AdminAuthGate({super.key});
|
||||
|
||||
@override
|
||||
State<AdminAuthGate> createState() => _AdminAuthGateState();
|
||||
}
|
||||
|
||||
class _AdminAuthGateState extends State<AdminAuthGate> {
|
||||
late Future<bool> _session;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_session = DirectorateApiService.hasValidSession();
|
||||
}
|
||||
|
||||
void _authenticated() => setState(() => _session = Future.value(true));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<bool>(
|
||||
future: _session,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
if (snapshot.data != true) return AdminAuthScreen(onAuthenticated: _authenticated);
|
||||
return BlocProvider(
|
||||
create: (_) => DirectorateCubit(),
|
||||
child: const UnifiedSupervisorShell(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../services/directorate_api_service.dart';
|
||||
|
||||
class AdminAuthScreen extends StatefulWidget {
|
||||
final VoidCallback onAuthenticated;
|
||||
|
||||
const AdminAuthScreen({super.key, required this.onAuthenticated});
|
||||
|
||||
@override
|
||||
State<AdminAuthScreen> createState() => _AdminAuthScreenState();
|
||||
}
|
||||
|
||||
class _AdminAuthScreenState extends State<AdminAuthScreen> {
|
||||
final _phone = TextEditingController();
|
||||
final _otp = TextEditingController();
|
||||
String _role = 'school_admin';
|
||||
bool _otpSent = false;
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phone.dispose();
|
||||
_otp.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
if (!_otpSent) {
|
||||
await DirectorateApiService.requestOtp(phoneNumber: _phone.text.trim(), role: _role);
|
||||
if (mounted) setState(() => _otpSent = true);
|
||||
} else {
|
||||
await DirectorateApiService.verifyOtp(
|
||||
phoneNumber: _phone.text.trim(),
|
||||
otp: _otp.text.trim(),
|
||||
role: _role,
|
||||
);
|
||||
widget.onAuthenticated();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _error = e.toString().replaceFirst('Bad state: ', ''));
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 430),
|
||||
child: Card(
|
||||
margin: const EdgeInsets.all(24),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(28),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Text('دخول الإدارة', style: TextStyle(fontSize: 28, fontWeight: FontWeight.w800)),
|
||||
const SizedBox(height: 8),
|
||||
const Text('الدخول متاح فقط للحسابات المخوّلة مسبقاً على خادم صَقِل.'),
|
||||
const SizedBox(height: 22),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _role,
|
||||
decoration: const InputDecoration(labelText: 'الصلاحية'),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'school_admin', child: Text('مدير مدرسة')),
|
||||
DropdownMenuItem(value: 'supervisor', child: Text('مشرف')),
|
||||
DropdownMenuItem(value: 'directorate_admin', child: Text('مدير مديرية')),
|
||||
],
|
||||
onChanged: _otpSent ? null : (value) => setState(() => _role = value!),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: _phone,
|
||||
enabled: !_otpSent,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: const InputDecoration(labelText: 'رقم الهاتف', hintText: '079XXXXXXX'),
|
||||
),
|
||||
if (_otpSent) ...[
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: _otp,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: 'رمز التحقق'),
|
||||
),
|
||||
],
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 14),
|
||||
Text(_error!, style: const TextStyle(color: Colors.redAccent)),
|
||||
],
|
||||
const SizedBox(height: 22),
|
||||
FilledButton(
|
||||
onPressed: _busy ? null : _submit,
|
||||
child: Text(_busy ? 'جارٍ التحقق…' : (_otpSent ? 'دخول' : 'إرسال الرمز')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import '../../data/models/directorate_models.dart';
|
||||
import '../../services/directorate_api_service.dart';
|
||||
|
||||
@@ -22,10 +24,11 @@ class _SchoolPrincipalScreenState extends State<SchoolPrincipalScreen>
|
||||
int _recordDurationSeconds = 0;
|
||||
Timer? _recordTimer;
|
||||
double _recordedSizeMb = 0.0;
|
||||
String _selectedTeacher = 'أحمد المجالي';
|
||||
String _selectedSubject = 'الفيزياء';
|
||||
int? _selectedTeacherId;
|
||||
String? _selectedTeacher;
|
||||
String _selectedSubject = '';
|
||||
final TextEditingController _lessonTitleController =
|
||||
TextEditingController(text: 'قوانين نيوتن في الحركة والمصاعد');
|
||||
TextEditingController();
|
||||
RecordedLessonResult? _lastResult;
|
||||
|
||||
// Exam state
|
||||
@@ -52,33 +55,36 @@ class _SchoolPrincipalScreenState extends State<SchoolPrincipalScreen>
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_dashboardData = data;
|
||||
final teachers = data['teachers'] as List? ?? const [];
|
||||
if (teachers.isNotEmpty) {
|
||||
final first = Map<String, dynamic>.from(teachers.first as Map);
|
||||
_selectedTeacherId = (first['id'] as num?)?.toInt();
|
||||
_selectedTeacher = first['name']?.toString();
|
||||
_selectedSubject = first['subject']?.toString() ?? '';
|
||||
}
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _startRecording() {
|
||||
Future<void> _selectAndUploadRecording() async {
|
||||
if (_selectedTeacherId == null || _lessonTitleController.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('اختر معلماً وأدخل عنوان الحصة أولاً.')));
|
||||
return;
|
||||
}
|
||||
final selection = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: const ['mp4', 'mov', 'webm'],
|
||||
withData: true,
|
||||
);
|
||||
if (selection == null || selection.files.isEmpty) return;
|
||||
final file = selection.files.single;
|
||||
setState(() {
|
||||
_isRecording = true;
|
||||
_recordDurationSeconds = 0;
|
||||
_recordedSizeMb = 0.0;
|
||||
_recordedSizeMb = file.size / 1048576;
|
||||
_lastResult = null;
|
||||
});
|
||||
|
||||
_recordTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_recordDurationSeconds++;
|
||||
// Calculate simulated file size at 720p ~ 1.1 Mbps (approx 0.14 MB per second)
|
||||
_recordedSizeMb = (_recordDurationSeconds * 0.14).clamp(0.0, 395.0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _stopAndAuditRecording() async {
|
||||
_recordTimer?.cancel();
|
||||
setState(() => _isRecording = false);
|
||||
|
||||
// Show quick processing dialog
|
||||
showCupertinoDialog(
|
||||
context: context,
|
||||
@@ -88,19 +94,26 @@ class _SchoolPrincipalScreenState extends State<SchoolPrincipalScreen>
|
||||
),
|
||||
);
|
||||
|
||||
final result = await DirectorateApiService.recordLesson(
|
||||
teacherName: _selectedTeacher,
|
||||
subject: _selectedSubject,
|
||||
lessonTitle: _lessonTitleController.text.trim(),
|
||||
fileSizeMb: _recordedSizeMb > 0 ? _recordedSizeMb : 340.0,
|
||||
durationMinutes: (_recordDurationSeconds / 60).ceil().clamp(15, 45),
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop(); // dismiss loading
|
||||
setState(() {
|
||||
_lastResult = result;
|
||||
});
|
||||
try {
|
||||
final result = await DirectorateApiService.recordLesson(
|
||||
schoolId: (_dashboardData?['school']?['id'] as num?)?.toInt() ?? 0,
|
||||
teacherId: _selectedTeacherId!,
|
||||
subject: _selectedSubject,
|
||||
gradeLevel: 'grade_10',
|
||||
lessonTitle: _lessonTitleController.text.trim(),
|
||||
durationMinutes: 0,
|
||||
filePath: file.path,
|
||||
fileBytes: file.bytes,
|
||||
fileName: file.name,
|
||||
);
|
||||
if (mounted) setState(() => _lastResult = result);
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(e.toString())));
|
||||
} finally {
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
setState(() => _isRecording = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,16 +294,54 @@ class _SchoolPrincipalScreenState extends State<SchoolPrincipalScreen>
|
||||
}
|
||||
|
||||
void _importSchoolRoster() async {
|
||||
final picked = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: const ['csv', 'json'],
|
||||
withData: true,
|
||||
);
|
||||
if (picked == null || picked.files.single.bytes == null) return;
|
||||
|
||||
setState(() => _isImportingRoster = true);
|
||||
final res = await DirectorateApiService.importSchoolRoster();
|
||||
if (mounted) {
|
||||
setState(() => _isImportingRoster = false);
|
||||
try {
|
||||
final file = picked.files.single;
|
||||
final text = utf8.decode(file.bytes!);
|
||||
final List<Map<String, dynamic>> records;
|
||||
if (file.extension?.toLowerCase() == 'json') {
|
||||
final decoded = json.decode(text);
|
||||
if (decoded is! List) throw const FormatException('ملف JSON يجب أن يحتوي قائمة طلبة.');
|
||||
records = decoded.map((row) => Map<String, dynamic>.from(row as Map)).toList();
|
||||
} else {
|
||||
final lines = const LineSplitter()
|
||||
.convert(text)
|
||||
.where((line) => line.trim().isNotEmpty)
|
||||
.toList();
|
||||
if (lines.length < 2) throw const FormatException('ملف CSV فارغ.');
|
||||
final headers = lines.first.split(',').map((h) => h.trim()).toList();
|
||||
records = lines.skip(1).map((line) {
|
||||
final values = line.split(',');
|
||||
return <String, dynamic>{
|
||||
for (var i = 0; i < headers.length; i++)
|
||||
headers[i]: i < values.length ? values[i].trim() : '',
|
||||
};
|
||||
}).toList();
|
||||
}
|
||||
|
||||
final res = await DirectorateApiService.importSchoolRoster(records: records);
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('تم استيراد وتشفير ${res['imported_count']} طالباً بنجاح عبر AES-256-GCM السيادي 🔒'),
|
||||
content: Text('تم استيراد ${res['imported_count']} طالباً والتحقق من بياناتهم بنجاح.'),
|
||||
backgroundColor: const Color(0xFF0284C7),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('تعذر استيراد الكشف: $e'), backgroundColor: const Color(0xFFDC2626)),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isImportingRoster = false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,9 +623,7 @@ class _SchoolPrincipalScreenState extends State<SchoolPrincipalScreen>
|
||||
left: 14,
|
||||
right: 14,
|
||||
child: ElevatedButton(
|
||||
onPressed: _isRecording
|
||||
? _stopAndAuditRecording
|
||||
: _startRecording,
|
||||
onPressed: _isRecording ? null : _selectAndUploadRecording,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _isRecording
|
||||
? const Color(0xFFDC2626)
|
||||
@@ -593,9 +642,7 @@ class _SchoolPrincipalScreenState extends State<SchoolPrincipalScreen>
|
||||
size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_isRecording
|
||||
? 'إنهاء الحصة وإرسالها للتدقيق بالذكاء الاصطناعي 🚀'
|
||||
: 'بدء تصوير الحصة الصفية 🎥',
|
||||
_isRecording ? 'جارٍ رفع الحصة إلى R2…' : 'اختيار فيديو حصة حقيقي ورفعه 🎥',
|
||||
style: const TextStyle(
|
||||
fontSize: 15, fontWeight: FontWeight.w700),
|
||||
),
|
||||
@@ -631,7 +678,7 @@ class _SchoolPrincipalScreenState extends State<SchoolPrincipalScreen>
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _selectedTeacher,
|
||||
initialValue: _selectedTeacher,
|
||||
dropdownColor: const Color(0xFF1E293B),
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
@@ -647,14 +694,21 @@ class _SchoolPrincipalScreenState extends State<SchoolPrincipalScreen>
|
||||
value: t.name, child: Text(t.name)))
|
||||
.toList(),
|
||||
onChanged: (val) {
|
||||
if (val != null) setState(() => _selectedTeacher = val);
|
||||
if (val != null) {
|
||||
final teacher = teachers.firstWhere((t) => t.name == val);
|
||||
setState(() {
|
||||
_selectedTeacher = val;
|
||||
_selectedTeacherId = teacher.id;
|
||||
_selectedSubject = teacher.subject;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _selectedSubject,
|
||||
initialValue: _selectedSubject.isEmpty ? null : _selectedSubject,
|
||||
dropdownColor: const Color(0xFF1E293B),
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13),
|
||||
decoration: InputDecoration(
|
||||
@@ -665,14 +719,8 @@ class _SchoolPrincipalScreenState extends State<SchoolPrincipalScreen>
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'الفيزياء', child: Text('الفيزياء')),
|
||||
DropdownMenuItem(value: 'الرياضيات', child: Text('الرياضيات')),
|
||||
DropdownMenuItem(value: 'الكيمياء', child: Text('الكيمياء')),
|
||||
DropdownMenuItem(
|
||||
value: 'اللغة الإنجليزية',
|
||||
child: Text('اللغة الإنجليزية')),
|
||||
],
|
||||
items: teachers.map((t) => t.subject).where((s) => s.isNotEmpty).toSet()
|
||||
.map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
|
||||
onChanged: (val) {
|
||||
if (val != null) setState(() => _selectedSubject = val);
|
||||
},
|
||||
@@ -1008,8 +1056,21 @@ class _SchoolPrincipalScreenState extends State<SchoolPrincipalScreen>
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
final exams = _dashboardData?['active_exams'] as List? ?? const [];
|
||||
final examId = exams.isEmpty
|
||||
? 0
|
||||
: (Map<String, dynamic>.from(exams.first as Map)['id'] as num?)?.toInt() ?? 0;
|
||||
final schoolId = (_dashboardData?['school']?['id'] as num?)?.toInt() ?? 0;
|
||||
if (examId == 0 || schoolId == 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('لا يوجد امتحان حقيقي مجدول لهذه المدرسة.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final ok = await DirectorateApiService.pushExamToLab(
|
||||
'EX-MC-2026-01');
|
||||
examId: examId,
|
||||
schoolId: schoolId,
|
||||
);
|
||||
if (ok && mounted) {
|
||||
setState(() => _examPushedToLab = true);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -1133,13 +1194,33 @@ class _SchoolPrincipalScreenState extends State<SchoolPrincipalScreen>
|
||||
|
||||
// Toggle Panoramic Button
|
||||
ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
setState(() => _isPanoramicActive = !_isPanoramicActive);
|
||||
if (_isPanoramicActive) {
|
||||
DirectorateApiService.uploadPanoramicSample(
|
||||
fileSizeMb: _panoramicSizeMb,
|
||||
durationSeconds: 120,
|
||||
onPressed: () async {
|
||||
final exams = _dashboardData?['active_exams'] as List? ?? const [];
|
||||
final sessionId = exams.isEmpty
|
||||
? 0
|
||||
: (Map<String, dynamic>.from(exams.first as Map)['session_id'] as num?)?.toInt() ?? 0;
|
||||
if (sessionId == 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('لا توجد جلسة امتحان حقيقية لربط العينة بها.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final selected = await FilePicker.platform.pickFiles(type: FileType.video, withData: true);
|
||||
if (selected == null || selected.files.isEmpty) return;
|
||||
final file = selected.files.single;
|
||||
setState(() {
|
||||
_isPanoramicActive = true;
|
||||
_panoramicSizeMb = file.size / 1048576;
|
||||
});
|
||||
try {
|
||||
await DirectorateApiService.uploadPanoramicSample(
|
||||
sessionId: sessionId,
|
||||
filePath: file.path,
|
||||
fileBytes: file.bytes,
|
||||
fileName: file.name,
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isPanoramicActive = false);
|
||||
}
|
||||
},
|
||||
icon: Icon(
|
||||
|
||||
@@ -1,459 +1,226 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../models/directorate_models.dart';
|
||||
|
||||
import '../core/services/storage_service.dart';
|
||||
import '../data/models/directorate_models.dart';
|
||||
|
||||
/// Authenticated API client for principals, supervisors, and directorates.
|
||||
/// Operational data always comes from the server; connection errors stay errors.
|
||||
class DirectorateApiService {
|
||||
static const String baseUrl = 'http://127.0.0.1:8000';
|
||||
static const String baseUrl = String.fromEnvironment(
|
||||
'SAQEL_API_BASE_URL',
|
||||
defaultValue: 'https://saqel.intaleqapp.com',
|
||||
);
|
||||
|
||||
/// Fetch Macro Directorate Dashboard (لوحة القائد العام لمدارس الثقافة العسكرية)
|
||||
static Future<Map<String, dynamic>> fetchDirectorateDashboard() async {
|
||||
try {
|
||||
final response = await http
|
||||
.get(Uri.parse('$baseUrl/api/directorate/dashboard'))
|
||||
.timeout(const Duration(seconds: 4));
|
||||
static final StorageService _storage = StorageService();
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(utf8.decode(response.bodyBytes));
|
||||
return data;
|
||||
}
|
||||
} catch (_) {
|
||||
// Fallback for offline / demo mode
|
||||
static Future<Map<String, String>> _headers() async {
|
||||
final token = await _storage.getToken();
|
||||
if (token == null || token.isEmpty) {
|
||||
throw StateError('جلسة الإدارة غير متوفرة. يرجى تسجيل الدخول.');
|
||||
}
|
||||
|
||||
// High-fidelity fallback
|
||||
return {
|
||||
'status': 'success',
|
||||
'directorate': {
|
||||
'code': 'MC-JOR',
|
||||
'name': 'مديرية التربية والتعليم والثقافة العسكرية',
|
||||
'type': 'military_culture',
|
||||
'commander_name': 'مدير التعليم والثقافة العسكرية',
|
||||
'total_schools': 43,
|
||||
'total_students': 19350,
|
||||
'total_teachers': 812,
|
||||
'annual_contract_value': 20000.00,
|
||||
'recorded_lessons_month': 1420,
|
||||
'compliance_rate': 94.5,
|
||||
'ai_average_score': 91.8,
|
||||
'active_unified_exams': 2,
|
||||
},
|
||||
'schools': [
|
||||
{
|
||||
'id': 1,
|
||||
'code': 'SCH-MC-01',
|
||||
'name': 'مدرسة الشهيد فيصل الثاني الثانوية العسكرية',
|
||||
'governorate': 'العاصمة',
|
||||
'weight_tier': 'tier_c_large',
|
||||
'student_count': 980,
|
||||
'teacher_count': 48,
|
||||
'compliance': 98.0,
|
||||
'status': 'excellent',
|
||||
'annual_fee': 900.00,
|
||||
},
|
||||
{
|
||||
'id': 2,
|
||||
'code': 'SCH-MC-02',
|
||||
'name': 'مدرسة الملك حسين الثانوية العسكرية',
|
||||
'governorate': 'الزرقاء',
|
||||
'weight_tier': 'tier_c_large',
|
||||
'student_count': 890,
|
||||
'teacher_count': 42,
|
||||
'compliance': 95.5,
|
||||
'status': 'excellent',
|
||||
'annual_fee': 900.00,
|
||||
},
|
||||
{
|
||||
'id': 3,
|
||||
'code': 'SCH-MC-03',
|
||||
'name': 'مدرسة صرح الشهيد العسكرية',
|
||||
'governorate': 'إربد',
|
||||
'weight_tier': 'tier_b_medium',
|
||||
'student_count': 460,
|
||||
'teacher_count': 24,
|
||||
'compliance': 92.0,
|
||||
'status': 'good',
|
||||
'annual_fee': 450.00,
|
||||
},
|
||||
{
|
||||
'id': 4,
|
||||
'code': 'SCH-MC-04',
|
||||
'name': 'مدرسة البادية الشمالية الثانوية العسكرية',
|
||||
'governorate': 'المفرق',
|
||||
'weight_tier': 'tier_a_small',
|
||||
'student_count': 150,
|
||||
'teacher_count': 12,
|
||||
'compliance': 88.0,
|
||||
'status': 'warning_under_quota',
|
||||
'annual_fee': 250.00,
|
||||
},
|
||||
{
|
||||
'id': 5,
|
||||
'code': 'SCH-MC-05',
|
||||
'name': 'مدرسة القويرة الثانوية العسكرية',
|
||||
'governorate': 'العقبة',
|
||||
'weight_tier': 'tier_a_small',
|
||||
'student_count': 135,
|
||||
'teacher_count': 11,
|
||||
'compliance': 91.0,
|
||||
'status': 'good',
|
||||
'annual_fee': 250.00,
|
||||
},
|
||||
{
|
||||
'id': 6,
|
||||
'code': 'SCH-MC-06',
|
||||
'name': 'مدرسة الكرك الثانوية العسكرية',
|
||||
'governorate': 'الكرك',
|
||||
'weight_tier': 'tier_b_medium',
|
||||
'student_count': 420,
|
||||
'teacher_count': 22,
|
||||
'compliance': 94.0,
|
||||
'status': 'good',
|
||||
'annual_fee': 450.00,
|
||||
},
|
||||
],
|
||||
'anomalies': [
|
||||
{
|
||||
'id': 'ANM-01',
|
||||
'type': 'speed_impossible',
|
||||
'school_name': 'مدرسة البادية الشمالية العسكرية',
|
||||
'subject': 'الرياضيات العلمي',
|
||||
'description': 'حل 18 طالباً لمسألة تفاضل في 14 ثانية (تحت التدقيق الإشرافي)',
|
||||
'severity': 'medium',
|
||||
'timestamp': 'منذ ساعتين',
|
||||
}
|
||||
],
|
||||
'radar': {
|
||||
'top_teachers': [
|
||||
{'name': 'أحمد المجالي', 'school': 'الشهيد فيصل الثاني', 'subject': 'الفيزياء', 'score': 97.4, 'certified_badge': true},
|
||||
{'name': 'خلدون بني هاني', 'school': 'صرح الشهيد إربد', 'subject': 'الرياضيات', 'score': 96.8, 'certified_badge': true},
|
||||
{'name': 'طارق الحنيطي', 'school': 'الملك حسين الزرقاء', 'subject': 'الكيمياء', 'score': 95.9, 'certified_badge': true},
|
||||
],
|
||||
'needing_support': [
|
||||
{'name': 'معلم لغة عربية', 'school': 'مدرسة القويرة', 'issue': 'ضعف الأسئلة السقراطية وتجاوز زمن الشرح 35 دقيقة', 'score': 74.2},
|
||||
]
|
||||
}
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer $token',
|
||||
'X-Device-Fingerprint': 'saqel_admin_flutter',
|
||||
};
|
||||
}
|
||||
|
||||
/// Fetch School Principal Dashboard (لوحة مدير المدرسة والمشرف الميداني)
|
||||
static Future<Map<String, dynamic>> fetchSchoolDashboard({int schoolId = 1}) async {
|
||||
try {
|
||||
final response = await http
|
||||
.get(Uri.parse('$baseUrl/api/supervisor/school-dashboard?school_id=$schoolId'))
|
||||
.timeout(const Duration(seconds: 4));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(utf8.decode(response.bodyBytes));
|
||||
return data;
|
||||
}
|
||||
} catch (_) {
|
||||
// Fallback
|
||||
static Map<String, dynamic> _decode(http.Response response) {
|
||||
Map<String, dynamic> data = {};
|
||||
if (response.bodyBytes.isNotEmpty) {
|
||||
final decoded = json.decode(utf8.decode(response.bodyBytes));
|
||||
if (decoded is Map) data = Map<String, dynamic>.from(decoded);
|
||||
}
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'school': {
|
||||
'id': schoolId,
|
||||
'name': 'مدرسة الشهيد فيصل الثاني الثانوية العسكرية',
|
||||
'governorate': 'العاصمة - عمان',
|
||||
'student_count': 980,
|
||||
'teacher_count': 48,
|
||||
'weight_tier': 'tier_c_large',
|
||||
'director_name': 'المقدم الركن / مدير المدرسة',
|
||||
'biweekly_cycle': 'الدورة الرابعة (الفصل الأول)',
|
||||
'cycle_progress': '42 من 48 حصة مسجلة (87.5%)',
|
||||
},
|
||||
'teachers': [
|
||||
{
|
||||
'id': 101,
|
||||
'name': 'أحمد المجالي',
|
||||
'subject': 'الفيزياء',
|
||||
'grade': 'العاشر + الأول ثانوي',
|
||||
'quota_status': 'completed',
|
||||
'last_lesson': 'تطبيقات قوانين نيوتن الثالث والمصاعد',
|
||||
'ai_score': 97.4,
|
||||
'recorded_at': 'منذ 3 أيام',
|
||||
},
|
||||
{
|
||||
'id': 102,
|
||||
'name': 'عمر الحباشنة',
|
||||
'subject': 'الرياضيات العلمي',
|
||||
'grade': 'التوجيهي 2008',
|
||||
'quota_status': 'pending',
|
||||
'last_lesson': 'تطبيقات القيم القصوى والمعدلات المرتبطة',
|
||||
'ai_score': 94.0,
|
||||
'recorded_at': 'منذ 12 يوماً',
|
||||
},
|
||||
{
|
||||
'id': 103,
|
||||
'name': 'سليمان الطراونة',
|
||||
'subject': 'الكيمياء',
|
||||
'grade': 'الأول ثانوي',
|
||||
'quota_status': 'completed',
|
||||
'last_lesson': 'سرعة التفاعلات ونظرية التصادم',
|
||||
'ai_score': 92.5,
|
||||
'recorded_at': 'أمس',
|
||||
},
|
||||
{
|
||||
'id': 104,
|
||||
'name': 'محمد الغويري',
|
||||
'subject': 'اللغة الإنجليزية',
|
||||
'grade': 'العاشر',
|
||||
'quota_status': 'completed',
|
||||
'last_lesson': 'Conditional Sentences (Type 2 & 3)',
|
||||
'ai_score': 96.0,
|
||||
'recorded_at': 'منذ 5 أيام',
|
||||
},
|
||||
],
|
||||
'active_exams': [
|
||||
{
|
||||
'id': 'EX-MC-2026-01',
|
||||
'title': 'الامتحان الموحد التجريبي - الرياضيات العلمي',
|
||||
'subject': 'الرياضيات العلمي',
|
||||
'grade_level': 'التوجيهي 2008',
|
||||
'scheduled_time': 'اليوم - 09:00 صباحاً (موعد موحد لكافة المدارس)',
|
||||
'duration_minutes': 60,
|
||||
'pushed_to_lab': true,
|
||||
'status': 'in_progress',
|
||||
'forms': ['نموذج أ', 'نموذج ب'],
|
||||
'anti_cheating': {
|
||||
'kiosk_mode_locked': true,
|
||||
'dynamic_numbers': true,
|
||||
'smart_sampling': 'ثانيتان كل دقيقة (حجم الإجمالي 16.5 ميجابايت)',
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw StateError(
|
||||
data['message']?.toString() ?? 'فشل طلب الخادم (${response.statusCode}).',
|
||||
);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/// Submit a Video Lesson for AI Pedagogical Audit (تسجيل حصة مرئية)
|
||||
static Future<RecordedLessonResult> recordLesson({
|
||||
required String teacherName,
|
||||
required String subject,
|
||||
required String lessonTitle,
|
||||
required double fileSizeMb,
|
||||
required int durationMinutes,
|
||||
static Future<void> requestOtp({
|
||||
required String phoneNumber,
|
||||
required String role,
|
||||
}) async {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/auth/otp/request'),
|
||||
headers: const {'Accept': 'application/json', 'Content-Type': 'application/json'},
|
||||
body: json.encode({'phone_number': phoneNumber, 'role': role}),
|
||||
).timeout(const Duration(seconds: 30));
|
||||
_decode(response);
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> verifyOtp({
|
||||
required String phoneNumber,
|
||||
required String otp,
|
||||
required String role,
|
||||
}) async {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/auth/otp/verify'),
|
||||
headers: const {'Accept': 'application/json', 'Content-Type': 'application/json'},
|
||||
body: json.encode({
|
||||
'phone_number': phoneNumber,
|
||||
'otp': otp,
|
||||
'role': role,
|
||||
'device_fingerprint': 'saqel_admin_flutter',
|
||||
}),
|
||||
).timeout(const Duration(seconds: 30));
|
||||
final data = _decode(response);
|
||||
final payload = Map<String, dynamic>.from(data['data'] as Map? ?? const {});
|
||||
final token = payload['token']?.toString() ?? '';
|
||||
if (token.isEmpty) throw StateError('لم يُرجع الخادم جلسة إدارية صالحة.');
|
||||
await _storage.saveToken(token);
|
||||
return payload;
|
||||
}
|
||||
|
||||
static Future<bool> hasValidSession() async {
|
||||
try {
|
||||
final response = await http
|
||||
.post(
|
||||
Uri.parse('$baseUrl/api/supervisor/record-lesson'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: json.encode({
|
||||
'teacher_name': teacherName,
|
||||
'subject': subject,
|
||||
'lesson_title': lessonTitle,
|
||||
'file_size_mb': fileSizeMb,
|
||||
'duration_minutes': durationMinutes,
|
||||
}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 4));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(utf8.decode(response.bodyBytes));
|
||||
return RecordedLessonResult.fromJson(data['data']);
|
||||
}
|
||||
final response = await http.get(
|
||||
Uri.parse('$baseUrl/api/auth/me'),
|
||||
headers: await _headers(),
|
||||
).timeout(const Duration(seconds: 15));
|
||||
_decode(response);
|
||||
return true;
|
||||
} catch (_) {
|
||||
// Fallback
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback simulated result
|
||||
return RecordedLessonResult(
|
||||
lessonUuid: 'LREC-${DateTime.now().millisecondsSinceEpoch}',
|
||||
teacherName: teacherName,
|
||||
subject: subject,
|
||||
lessonTitle: lessonTitle,
|
||||
fileSizeMb: fileSizeMb,
|
||||
durationMinutes: durationMinutes,
|
||||
aiAlignmentScore: 95.5,
|
||||
teacherTalkRatio: '62%',
|
||||
status: 'approved_official',
|
||||
reportSummary:
|
||||
'التزام تام بالمنهاج الوزاري، تفاعل ممتاز من الطلاب، وطرح سليم للأسئلة السقراطية.',
|
||||
socraticCheckpoints: [
|
||||
{
|
||||
'timestamp': '14:20',
|
||||
'question': 'ما الفرق بين القصور الذاتي والكتلة التثاقلية؟',
|
||||
'objective': 'الربط بمنهاج الفيزياء - نتاجات الوحدة الثانية'
|
||||
},
|
||||
{
|
||||
'timestamp': '29:10',
|
||||
'question': 'إذا انقطع حبل المصعد، ما القوة العمودية المؤثرة على الشخص؟',
|
||||
'objective': 'تفكير استنتاجي سقراطي'
|
||||
}
|
||||
],
|
||||
static Future<Map<String, dynamic>> fetchDirectorateDashboard() async {
|
||||
final response = await http.get(
|
||||
Uri.parse('$baseUrl/api/directorate/dashboard'),
|
||||
headers: await _headers(),
|
||||
).timeout(const Duration(seconds: 15));
|
||||
return _decode(response);
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> fetchSchoolDashboard({int schoolId = 1}) async {
|
||||
final uri = Uri.parse('$baseUrl/api/supervisor/school-dashboard').replace(
|
||||
queryParameters: {'school_id': '$schoolId'},
|
||||
);
|
||||
final response = await http.get(uri, headers: await _headers())
|
||||
.timeout(const Duration(seconds: 15));
|
||||
return _decode(response);
|
||||
}
|
||||
|
||||
static Future<RecordedLessonResult> recordLesson({
|
||||
required int schoolId,
|
||||
required int teacherId,
|
||||
required String subject,
|
||||
required String gradeLevel,
|
||||
required String lessonTitle,
|
||||
required int durationMinutes,
|
||||
String? filePath,
|
||||
List<int>? fileBytes,
|
||||
required String fileName,
|
||||
}) async {
|
||||
final request = http.MultipartRequest('POST', Uri.parse('$baseUrl/api/supervisor/record-lesson'));
|
||||
final headers = await _headers();
|
||||
headers.remove('Content-Type');
|
||||
request.headers.addAll(headers);
|
||||
request.fields.addAll({
|
||||
'school_id': '$schoolId',
|
||||
'teacher_id': '$teacherId',
|
||||
'subject': subject,
|
||||
'grade_level': gradeLevel,
|
||||
'lesson_title': lessonTitle,
|
||||
'duration_minutes': '$durationMinutes',
|
||||
});
|
||||
if (fileBytes != null) {
|
||||
request.files.add(http.MultipartFile.fromBytes('video', fileBytes, filename: fileName));
|
||||
} else if (filePath != null) {
|
||||
request.files.add(await http.MultipartFile.fromPath('video', filePath, filename: fileName));
|
||||
} else {
|
||||
throw StateError('لم يتم اختيار ملف فيديو حقيقي.');
|
||||
}
|
||||
final streamed = await request.send().timeout(const Duration(minutes: 10));
|
||||
final response = await http.Response.fromStream(streamed);
|
||||
final data = _decode(response);
|
||||
return RecordedLessonResult.fromJson(
|
||||
Map<String, dynamic>.from(data['data'] as Map? ?? const {}),
|
||||
);
|
||||
}
|
||||
|
||||
/// Push Exam to School Computer Labs (إرسال الامتحان لمختبرات الحاسوب)
|
||||
static Future<bool> pushExamToLab(String examId) async {
|
||||
try {
|
||||
final response = await http
|
||||
.post(
|
||||
Uri.parse('$baseUrl/api/supervisor/exam/push-to-lab'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: json.encode({'exam_id': examId}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 3));
|
||||
|
||||
return response.statusCode == 200;
|
||||
} catch (_) {
|
||||
return true; // Simulate success in offline mode
|
||||
}
|
||||
static Future<bool> pushExamToLab({required int examId, required int schoolId}) async {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/supervisor/exam/push-to-lab'),
|
||||
headers: await _headers(),
|
||||
body: json.encode({'exam_id': examId, 'school_id': schoolId}),
|
||||
).timeout(const Duration(seconds: 30));
|
||||
return _decode(response)['status'] == 'success';
|
||||
}
|
||||
|
||||
/// Upload Smart Sampled Panoramic Surveillance (رفع العينة البانورامية المقتضبة)
|
||||
static Future<bool> uploadPanoramicSample({
|
||||
required double fileSizeMb,
|
||||
required int durationSeconds,
|
||||
required int sessionId,
|
||||
String? filePath,
|
||||
List<int>? fileBytes,
|
||||
required String fileName,
|
||||
}) async {
|
||||
try {
|
||||
final response = await http
|
||||
.post(
|
||||
Uri.parse('$baseUrl/api/supervisor/exam/upload-panoramic'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: json.encode({
|
||||
'file_size_mb': fileSizeMb,
|
||||
'duration_seconds': durationSeconds,
|
||||
}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 3));
|
||||
|
||||
return response.statusCode == 200;
|
||||
} catch (_) {
|
||||
return true;
|
||||
final request = http.MultipartRequest('POST', Uri.parse('$baseUrl/api/supervisor/exam/upload-panoramic'));
|
||||
final headers = await _headers();
|
||||
headers.remove('Content-Type');
|
||||
request.headers.addAll(headers);
|
||||
request.fields['session_id'] = '$sessionId';
|
||||
if (fileBytes != null) {
|
||||
request.files.add(http.MultipartFile.fromBytes('video', fileBytes, filename: fileName));
|
||||
} else if (filePath != null) {
|
||||
request.files.add(await http.MultipartFile.fromPath('video', filePath, filename: fileName));
|
||||
} else {
|
||||
throw StateError('لم يتم اختيار عينة فيديو حقيقية.');
|
||||
}
|
||||
final response = await http.Response.fromStream(
|
||||
await request.send().timeout(const Duration(minutes: 10)),
|
||||
);
|
||||
return _decode(response)['status'] == 'success';
|
||||
}
|
||||
|
||||
/// Fetch Dual-Form Standardized Exam (نموذج أ ونموذج ب)
|
||||
static Future<Map<String, dynamic>> fetchDualForms({
|
||||
String subject = 'الفيزياء',
|
||||
String gradeLevel = 'الأول ثانوي',
|
||||
String gradeLevel = 'الصف العاشر الأساسي',
|
||||
}) async {
|
||||
try {
|
||||
final uri = Uri.parse('$baseUrl/api/unified-exams/dual-forms').replace(
|
||||
queryParameters: {'subject': subject, 'grade_level': gradeLevel},
|
||||
);
|
||||
final response = await http.get(uri).timeout(const Duration(seconds: 3));
|
||||
if (response.statusCode == 200) {
|
||||
final decoded = json.decode(utf8.decode(response.bodyBytes));
|
||||
return decoded['data'] ?? {};
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return {
|
||||
'exam_uuid': 'sim-exam-dual-01',
|
||||
'subject': subject,
|
||||
'grade_level': gradeLevel,
|
||||
'forms': {
|
||||
'form_a': {
|
||||
'form_code': 'FORM_A_ALPHA',
|
||||
'barcode': 'SAQEL-EXAM-A-SIM01',
|
||||
'total_score': 100,
|
||||
'objective_score': 70,
|
||||
'written_steps_score': 30,
|
||||
},
|
||||
'form_b': {
|
||||
'form_code': 'FORM_B_BETA',
|
||||
'barcode': 'SAQEL-EXAM-B-SIM01',
|
||||
'total_score': 100,
|
||||
'objective_score': 70,
|
||||
'written_steps_score': 30,
|
||||
},
|
||||
}
|
||||
};
|
||||
final uri = Uri.parse('$baseUrl/api/unified-exams/dual-forms').replace(
|
||||
queryParameters: {'subject': subject, 'grade_level': gradeLevel},
|
||||
);
|
||||
final response = await http.get(uri, headers: await _headers())
|
||||
.timeout(const Duration(seconds: 30));
|
||||
final data = _decode(response);
|
||||
return Map<String, dynamic>.from(data['data'] as Map? ?? const {});
|
||||
}
|
||||
|
||||
/// Evaluate Exam Session Integrity & Detect Statistical Anomalies
|
||||
static Future<Map<String, dynamic>> evaluateExamSessionIntegrity() async {
|
||||
try {
|
||||
final response = await http
|
||||
.post(
|
||||
Uri.parse('$baseUrl/api/unified-exams/evaluate-integrity'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: json.encode({}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 3));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final decoded = json.decode(utf8.decode(response.bodyBytes));
|
||||
return decoded['data'] ?? {};
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'anomalies_detected': 3,
|
||||
'integrity_score': 75,
|
||||
'anomalies': [
|
||||
{
|
||||
'type': 'impossible_speed',
|
||||
'severity': 'critical',
|
||||
'title': 'مؤشر السرعة المستحيلة (Impossible Speed)',
|
||||
'student_name': 'سيف الدين خالد الرواشدة',
|
||||
'seat_number': 'قاعة 1 — مقعد 04',
|
||||
'details': 'أنهى الطالب الامتحان في 195 ثانية فقط بمعدل 12 ثانية لكل مسألة تفاضل وحصل على 95%.',
|
||||
'recommended_action': 'استعراض التسجيل البانورامي للقاعة في الدقيقة 02:40 والتحقق من جهاز الطالب.'
|
||||
},
|
||||
{
|
||||
'type': 'error_clustering',
|
||||
'severity': 'critical',
|
||||
'title': 'تكتل الأخطاء المتطابقة (Identical Error Clustering)',
|
||||
'student_name': 'عمر أحمد الحباشنة و فيصل محمود الخريشا',
|
||||
'seat_number': 'مقعد 05 و مقعد 06',
|
||||
'details': 'تطابق غريب في اختيار نفس الخيار الخاطئ النادر في 3 مسائل حسابية معقدة بين مقاعد متجاورة.',
|
||||
'recommended_action': 'الرجوع فوراً للقطات الكاميرا البانورامية للمقاعد المذكورة.'
|
||||
}
|
||||
]
|
||||
};
|
||||
static Future<Map<String, dynamic>> evaluateExamSessionIntegrity({
|
||||
List<Map<String, dynamic>> submissions = const [],
|
||||
}) async {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/unified-exams/evaluate-integrity'),
|
||||
headers: await _headers(),
|
||||
body: json.encode({'submissions': submissions}),
|
||||
).timeout(const Duration(seconds: 30));
|
||||
final data = _decode(response);
|
||||
return Map<String, dynamic>.from(data['data'] as Map? ?? const {});
|
||||
}
|
||||
|
||||
/// Dispatch Monthly Parent Reports via WhatsApp / Nabeh Gateway
|
||||
static Future<Map<String, dynamic>> dispatchParentReports({int schoolId = 1}) async {
|
||||
try {
|
||||
final response = await http
|
||||
.post(
|
||||
Uri.parse('$baseUrl/api/parent-reports/dispatch'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: json.encode({'school_id': schoolId}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 4));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return json.decode(utf8.decode(response.bodyBytes));
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'message': 'تمت جدولة وبث 450 تقرير شهري لأولياء أمور طلبة المدرسة بنجاح عبر بوابة نبيه 📲',
|
||||
'total_dispatched': 450,
|
||||
'delivery_rate': '100%',
|
||||
};
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/parent-reports/dispatch'),
|
||||
headers: await _headers(),
|
||||
body: json.encode({'school_id': schoolId}),
|
||||
).timeout(const Duration(minutes: 2));
|
||||
return _decode(response);
|
||||
}
|
||||
|
||||
/// Import School Roster with 10-digit National ID and AES-256-GCM Encryption
|
||||
static Future<Map<String, dynamic>> importSchoolRoster({int schoolId = 1}) async {
|
||||
try {
|
||||
final response = await http
|
||||
.post(
|
||||
Uri.parse('$baseUrl/api/school-roster/import'),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: json.encode({'school_id': schoolId}),
|
||||
)
|
||||
.timeout(const Duration(seconds: 4));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return json.decode(utf8.decode(response.bodyBytes));
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'total_received': 5,
|
||||
'imported_count': 5,
|
||||
'failed_count': 0,
|
||||
'encryption_info': 'تم تشفير جميع الأرقام الوطنية بنجاح عبر خوارزمية AES-256-GCM السيادية ومؤشر HMAC الأعمى.',
|
||||
};
|
||||
static Future<Map<String, dynamic>> importSchoolRoster({
|
||||
int schoolId = 1,
|
||||
required List<Map<String, dynamic>> records,
|
||||
}) async {
|
||||
final response = await http.post(
|
||||
Uri.parse('$baseUrl/api/school-roster/import'),
|
||||
headers: await _headers(),
|
||||
body: json.encode({'school_id': schoolId, 'records': records}),
|
||||
).timeout(const Duration(minutes: 2));
|
||||
return _decode(response);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user