364 lines
12 KiB
Dart
364 lines
12 KiB
Dart
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
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;
|
|
|
|
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,
|
|
});
|
|
|
|
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,
|
|
}) {
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
|
|
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('/');
|
|
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;
|
|
}
|
|
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, clearError: true));
|
|
try {
|
|
final res = await repository.uploadLesson(
|
|
title: state.lessonTitle,
|
|
durationMinutes: state.durationMinutes,
|
|
gradeLevel: state.gradeLevel,
|
|
subject: selectedSubjectName,
|
|
curriculumKey: curriculumKey,
|
|
fileName: state.selectedFileName!,
|
|
fileSizeMb: state.selectedFileSizeMb,
|
|
filePath: state.selectedFilePath,
|
|
fileBytes: state.selectedFileBytes,
|
|
);
|
|
|
|
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,
|
|
auditResult: result,
|
|
errorMessage: reason,
|
|
));
|
|
return false;
|
|
}
|
|
|
|
final msg = res['message']?.toString() ?? 'تم اعتماد الحصة ورفعها بنجاح.';
|
|
emit(state.copyWith(
|
|
isAuditing: false,
|
|
isUploading: false,
|
|
isUploaded: true,
|
|
auditResult: result,
|
|
uploadSuccessMessage: msg,
|
|
));
|
|
return true;
|
|
} catch (e) {
|
|
emit(state.copyWith(
|
|
isAuditing: false,
|
|
isUploading: false,
|
|
errorMessage: e.toString().replaceFirst('Bad state: ', '')));
|
|
return false;
|
|
}
|
|
}
|
|
}
|