Update Saqel Platform: 2026-09-08 13:43:36

This commit is contained in:
Hamza-Ayed
2026-09-08 13:43:36 +03:00
parent 0f424062fe
commit bb37b31cab
76 changed files with 3617 additions and 3059 deletions
@@ -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(