Update Saqel Platform: 2026-09-08 13:43:36
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user