Files
saqel/apps/teacher_app/lib/logic/cubits/teacher_studio_cubit.dart
T

455 lines
16 KiB
Dart

import 'package:flutter_bloc/flutter_bloc.dart';
import 'dart:math';
import 'dart:typed_data';
import '../../data/models/teacher_models.dart';
import '../../data/repositories/teacher_repository.dart';
import '../../core/utils/app_logger.dart';
/**
* ==============================================================================
* SAQEL TEACHER STUDIO - STUDIO UPLOAD CUBIT
* ==============================================================================
*
* إدارة حالة استوديو رفع الحصص وفحص الجودة الإدراكي:
* - التحقق من معيار معهد ماساتشوستس (20 - 25 دقيقة)
* - استدعاء التدقيق الآلي للدرس ومخرجات المنهاج الوزاري.
*/
class TeacherStudioState {
final String lessonTitle;
final double durationMinutes;
final String gradeLevel;
final Map<String, dynamic> curriculumTree;
final String gradeKey;
final String subjectKey;
final String semesterKey;
final String unitKey;
final String lessonKey;
final bool isCurriculumLoading;
final String? errorMessage;
final String? selectedFileName;
final double? selectedFileSizeMb;
final String? selectedFilePath;
final Uint8List? selectedFileBytes;
final bool isAuditing;
final TeacherLessonAuditModel? auditResult;
final bool isUploading;
final bool isUploaded;
final String? uploadSuccessMessage;
final double uploadProgress;
final String uploadPhase;
final bool isReplacement;
final int versionNumber;
const TeacherStudioState({
required this.lessonTitle,
required this.durationMinutes,
this.gradeLevel = '',
this.curriculumTree = const {},
this.gradeKey = '',
this.subjectKey = '',
this.semesterKey = '',
this.unitKey = '',
this.lessonKey = '',
this.isCurriculumLoading = false,
this.errorMessage,
this.selectedFileName,
this.selectedFileSizeMb,
this.selectedFilePath,
this.selectedFileBytes,
required this.isAuditing,
this.auditResult,
this.isUploading = false,
this.isUploaded = false,
this.uploadSuccessMessage,
this.uploadProgress = 0.0,
this.uploadPhase = 'idle',
this.isReplacement = false,
this.versionNumber = 1,
});
TeacherStudioState copyWith({
String? lessonTitle,
double? durationMinutes,
String? gradeLevel,
Map<String, dynamic>? curriculumTree,
String? gradeKey,
String? subjectKey,
String? semesterKey,
String? unitKey,
String? lessonKey,
bool? isCurriculumLoading,
String? errorMessage,
bool clearError = false,
String? selectedFileName,
double? selectedFileSizeMb,
String? selectedFilePath,
Uint8List? selectedFileBytes,
bool? isAuditing,
TeacherLessonAuditModel? auditResult,
bool? isUploading,
bool? isUploaded,
String? uploadSuccessMessage,
double? uploadProgress,
String? uploadPhase,
bool? isReplacement,
int? versionNumber,
}) {
return TeacherStudioState(
lessonTitle: lessonTitle ?? this.lessonTitle,
durationMinutes: durationMinutes ?? this.durationMinutes,
gradeLevel: gradeLevel ?? this.gradeLevel,
curriculumTree: curriculumTree ?? this.curriculumTree,
gradeKey: gradeKey ?? this.gradeKey,
subjectKey: subjectKey ?? this.subjectKey,
semesterKey: semesterKey ?? this.semesterKey,
unitKey: unitKey ?? this.unitKey,
lessonKey: lessonKey ?? this.lessonKey,
isCurriculumLoading: isCurriculumLoading ?? this.isCurriculumLoading,
errorMessage: clearError ? null : errorMessage ?? this.errorMessage,
selectedFileName: selectedFileName ?? this.selectedFileName,
selectedFileSizeMb: selectedFileSizeMb ?? this.selectedFileSizeMb,
selectedFilePath: selectedFilePath ?? this.selectedFilePath,
selectedFileBytes: selectedFileBytes ?? this.selectedFileBytes,
isAuditing: isAuditing ?? this.isAuditing,
auditResult: auditResult ?? this.auditResult,
isUploading: isUploading ?? this.isUploading,
isUploaded: isUploaded ?? this.isUploaded,
uploadSuccessMessage: uploadSuccessMessage ?? this.uploadSuccessMessage,
uploadProgress: uploadProgress ?? this.uploadProgress,
uploadPhase: uploadPhase ?? this.uploadPhase,
isReplacement: isReplacement ?? this.isReplacement,
versionNumber: versionNumber ?? this.versionNumber,
);
}
}
class TeacherStudioCubit extends Cubit<TeacherStudioState> {
final TeacherRepository repository;
TeacherStudioCubit({required this.repository})
: super(const TeacherStudioState(
lessonTitle: '',
durationMinutes: 20.0,
isAuditing: false,
auditResult: null,
));
void updateLessonTitle(String title) {
emit(state.copyWith(lessonTitle: title));
}
void updateDuration(double duration) {
emit(state.copyWith(durationMinutes: duration));
}
void updateGradeLevel(String grade) {
emit(state.copyWith(gradeLevel: grade));
}
Map<String, dynamic> _map(dynamic value) =>
value is Map ? Map<String, dynamic>.from(value) : {};
Map<String, dynamic> get _subjects =>
_map(state.curriculumTree[state.gradeKey]?['subjects']);
Map<String, dynamic> get _semesters =>
_map(_subjects[state.subjectKey]?['semesters']);
Map<String, dynamic> get _units =>
_map(_semesters[state.semesterKey]?['units']);
Future<void> loadCurriculum() async {
if (state.curriculumTree.isNotEmpty || state.isCurriculumLoading) return;
emit(state.copyWith(isCurriculumLoading: true, clearError: true));
try {
final results = await Future.wait(
[repository.getCurriculumTree(), repository.getProfileStatus()]);
final rawTree = Map<String, dynamic>.from(results[0] as Map);
final profile = results[1] as TeacherProfileModel;
final allowedGrades = profile.gradesTaught
.map(_normalized)
.where((e) => e.isNotEmpty)
.toList();
final specialization = _normalized(profile.specialization);
final tree = <String, dynamic>{};
for (final gradeEntry in rawTree.entries) {
final gradeNode = _map(gradeEntry.value);
final gradeName =
_normalized(gradeNode['name']?.toString() ?? gradeEntry.key);
if (allowedGrades.isNotEmpty &&
!allowedGrades
.any((g) => gradeName.contains(g) || g.contains(gradeName)))
continue;
final allSubjects = _map(gradeNode['subjects']);
if (specialization.isNotEmpty) {
final matched = Map<String, dynamic>.fromEntries(
allSubjects.entries.where((entry) {
final subjectName =
_normalized('${entry.key} ${_map(entry.value)['name'] ?? ''}');
return subjectName.contains(specialization) ||
specialization.contains(subjectName) ||
specialization.split(' ').any(
(word) => word.length > 3 && subjectName.contains(word));
}));
if (matched.isNotEmpty) gradeNode['subjects'] = matched;
}
tree[gradeEntry.key] = gradeNode;
}
final key = tree.keys.isNotEmpty ? tree.keys.first : '';
emit(state.copyWith(curriculumTree: tree, isCurriculumLoading: false));
selectGrade(key);
} catch (e) {
AppLogger.error('Studio upload failed', error: e, tag: 'TEACHER_STUDIO');
emit(state.copyWith(
isCurriculumLoading: false, errorMessage: e.toString()));
}
}
String _normalized(String value) => value
.toLowerCase()
.replaceAll(RegExp(r'[^\u0600-\u06ffa-z0-9]+'), ' ')
.trim();
void selectGrade(String key) {
final node = state.curriculumTree[key];
final name = node is Map ? node['name']?.toString() ?? key : key;
emit(state.copyWith(
gradeKey: key,
gradeLevel: name,
subjectKey: '',
semesterKey: '',
unitKey: '',
lessonKey: '',
clearError: true));
final values = _map(state.curriculumTree[key]?['subjects']);
selectSubject(values.keys.isNotEmpty ? values.keys.first : '');
}
void selectSubject(String key) {
emit(state.copyWith(
subjectKey: key,
semesterKey: '',
unitKey: '',
lessonKey: '',
clearError: true));
final values = _map(_subjects[key]?['semesters']);
selectSemester(values.keys.isNotEmpty ? values.keys.first : '');
}
void selectSemester(String key) {
emit(state.copyWith(
semesterKey: key, unitKey: '', lessonKey: '', clearError: true));
final values = _map(_semesters[key]?['units']);
selectUnit(values.keys.isNotEmpty ? values.keys.first : '');
}
void selectUnit(String key) {
emit(state.copyWith(unitKey: key, lessonKey: '', clearError: true));
final lessons = _units[key]?['lessons'];
final id = lessons is List && lessons.isNotEmpty && lessons.first is Map
? (lessons.first as Map)['id']?.toString() ?? ''
: '';
selectLesson(id);
}
void selectLesson(String key) {
String title = '';
final lessons = _units[state.unitKey]?['lessons'];
if (lessons is List)
for (final item in lessons) {
if (item is Map && item['id']?.toString() == key)
title = item['title']?.toString() ?? '';
}
emit(state.copyWith(lessonKey: key, lessonTitle: title, clearError: true));
}
String get selectedSubjectName {
final node = _subjects[state.subjectKey];
final name = node is Map
? node['name']?.toString() ?? state.subjectKey
: state.subjectKey;
return name.replaceFirst(RegExp(r'\s*\([^)]*\)\s*$'), '').trim();
}
String get curriculumKey => [
state.gradeKey,
state.subjectKey,
state.semesterKey,
state.unitKey,
state.lessonKey
].where((e) => e.isNotEmpty).join('/');
String? get selectedCurriculumLessonId {
final lessons = _units[state.unitKey]?['lessons'];
if (lessons is! List) return null;
for (final item in lessons) {
if (item is Map && item['id']?.toString() == state.lessonKey) {
final id = item['curriculum_lesson_id']?.toString();
return id != null && id.isNotEmpty ? id : null;
}
}
return null;
}
String _newIdempotencyKey() => 'teacher_${DateTime.now().microsecondsSinceEpoch}_${Random.secure().nextInt(1 << 32)}';
void reportError(String message) =>
emit(state.copyWith(errorMessage: message));
void selectVideoFile(
String fileName,
double sizeMb,
double durationMinutes, {
String? path,
Uint8List? bytes,
}) {
emit(state.copyWith(
selectedFileName: fileName,
selectedFileSizeMb: sizeMb,
selectedFilePath: path,
selectedFileBytes: bytes,
durationMinutes: durationMinutes,
isUploaded: false,
uploadSuccessMessage: null,
));
}
Future<void> runQualityGate() async {
if (state.selectedFileName == null ||
(state.selectedFilePath == null && state.selectedFileBytes == null)) {
emit(state.copyWith(
errorMessage: 'اختر ملف فيديو فعلياً قبل بدء فحص الجودة.'));
return;
}
if (curriculumKey.isEmpty || state.lessonTitle.trim().isEmpty) {
emit(state.copyWith(
errorMessage: 'اختر الصف والمبحث والفصل والوحدة والدرس أولاً.'));
return;
}
if (selectedCurriculumLessonId == null) {
emit(state.copyWith(
errorMessage: 'هذا الدرس غير معتمد للنشر بعد، لذلك لا يمكن رفع حصة له.'));
return;
}
await uploadAndPublishLesson();
}
Future<bool> uploadAndPublishLesson() async {
if (state.selectedFileName == null ||
(state.selectedFilePath == null && state.selectedFileBytes == null)) {
return false;
}
emit(state.copyWith(
isUploading: true,
isAuditing: true,
uploadProgress: 0.0,
uploadPhase: 'uploading',
clearError: true,
));
try {
var preflight = await repository.preflightSubmission(
curriculumLessonId: selectedCurriculumLessonId!,
idempotencyKey: _newIdempotencyKey(),
);
if (preflight['status']?.toString() == 'existing_submission' && preflight['submission_id'] != null) {
// إذا كانت الحصة مسجلة مسبقاً، نطلب تلقائياً إنشاء نسخة مرشحة للاستبدال
preflight = await repository.preflightSubmission(
curriculumLessonId: selectedCurriculumLessonId!,
idempotencyKey: _newIdempotencyKey(),
replacement: true,
submissionId: preflight['submission_id'].toString(),
);
}
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 isReplacement = preflight['replacement'] == true;
final versionNum = (preflight['version_number'] as num?)?.toInt() ?? 1;
emit(state.copyWith(
isReplacement: isReplacement,
versionNumber: versionNum,
));
final videoVersionId = preflight['video_version_id']?.toString();
if (videoVersionId == null || videoVersionId.isEmpty) {
throw StateError('لم ينشئ الخادم نسخة فيديو صالحة للرفع.');
}
final res = await repository.uploadLesson(
title: state.lessonTitle,
durationMinutes: state.durationMinutes,
gradeLevel: state.gradeLevel,
subject: selectedSubjectName,
curriculumKey: curriculumKey,
videoVersionId: videoVersionId,
fileName: state.selectedFileName!,
fileSizeMb: state.selectedFileSizeMb,
filePath: state.selectedFilePath,
fileBytes: state.selectedFileBytes,
onProgress: (sent, total) {
if (total > 0) {
final p = (sent / total).clamp(0.0, 1.0);
emit(state.copyWith(
uploadProgress: p,
uploadPhase: p >= 1.0 ? 'auditing' : 'uploading',
));
}
},
);
final status = res['status']?.toString();
final data = res['data'] is Map
? Map<String, dynamic>.from(res['data'] as Map)
: <String, dynamic>{};
final report = data['preflight_report'] is Map
? Map<String, dynamic>.from(data['preflight_report'] as Map)
: <String, dynamic>{};
final aiAnalysis = data['ai_analysis'] is Map
? Map<String, dynamic>.from(data['ai_analysis'] as Map)
: <String, dynamic>{};
final result = TeacherLessonAuditModel.fromJson({
...report,
'ai_analysis': aiAnalysis,
'lesson_title': state.lessonTitle,
'subject': selectedSubjectName
});
if (status == 'needs_review' || report['decision'] == 'needs_manual_review' || report['decision'] == 'rejected') {
final reason = report['reason']?.toString() ?? res['message']?.toString() ?? 'لم يجتز الفيديو تدقيق الجودة.';
emit(state.copyWith(
isAuditing: false,
isUploading: false,
isUploaded: false,
uploadProgress: 0.0,
uploadPhase: 'idle',
auditResult: result,
errorMessage: reason,
));
return false;
}
final msg = res['message']?.toString() ?? 'تم رفع الحصة إلى طابور المراجعة.';
emit(state.copyWith(
isAuditing: false,
isUploading: false,
isUploaded: true,
uploadProgress: 1.0,
uploadPhase: 'completed',
auditResult: result,
uploadSuccessMessage: msg,
));
return true;
} catch (e) {
emit(state.copyWith(
isAuditing: false,
isUploading: false,
uploadProgress: 0.0,
uploadPhase: 'idle',
errorMessage: e.toString().replaceFirst('Bad state: ', '')));
return false;
}
}
}