Update Saqel Platform: 2026-09-12 13:38:49
This commit is contained in:
@@ -71,6 +71,10 @@ class GuardianChildModel {
|
|||||||
final double readinessScore;
|
final double readinessScore;
|
||||||
final int examsPassed;
|
final int examsPassed;
|
||||||
final int examsTotal;
|
final int examsTotal;
|
||||||
|
final int errorTotalCount;
|
||||||
|
final int errorMasteredCount;
|
||||||
|
final int errorPendingCount;
|
||||||
|
final double errorMasteryRate;
|
||||||
|
|
||||||
GuardianChildModel({
|
GuardianChildModel({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -83,6 +87,10 @@ class GuardianChildModel {
|
|||||||
this.readinessScore = 0.0,
|
this.readinessScore = 0.0,
|
||||||
this.examsPassed = 0,
|
this.examsPassed = 0,
|
||||||
this.examsTotal = 0,
|
this.examsTotal = 0,
|
||||||
|
this.errorTotalCount = 0,
|
||||||
|
this.errorMasteredCount = 0,
|
||||||
|
this.errorPendingCount = 0,
|
||||||
|
this.errorMasteryRate = 100.0,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory GuardianChildModel.fromJson(Map<String, dynamic> json) {
|
factory GuardianChildModel.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -93,6 +101,31 @@ class GuardianChildModel {
|
|||||||
? Map<String, dynamic>.from(json['metrics'])
|
? Map<String, dynamic>.from(json['metrics'])
|
||||||
: <String, dynamic>{};
|
: <String, dynamic>{};
|
||||||
final source = <String, dynamic>{...student, ...metrics, ...json};
|
final source = <String, dynamic>{...student, ...metrics, ...json};
|
||||||
|
|
||||||
|
final errorNotebook = metrics['error_notebook'] is Map
|
||||||
|
? Map<String, dynamic>.from(metrics['error_notebook'])
|
||||||
|
: <String, dynamic>{};
|
||||||
|
final errTotal = errorNotebook['total_errors'] is int
|
||||||
|
? errorNotebook['total_errors'] as int
|
||||||
|
: (source['errors_total'] is int
|
||||||
|
? source['errors_total'] as int
|
||||||
|
: int.tryParse(errorNotebook['total_errors']?.toString() ?? source['errors_total']?.toString() ?? '0') ?? 0);
|
||||||
|
final errMastered = errorNotebook['mastered_count'] is int
|
||||||
|
? errorNotebook['mastered_count'] as int
|
||||||
|
: (source['errors_mastered'] is int
|
||||||
|
? source['errors_mastered'] as int
|
||||||
|
: int.tryParse(errorNotebook['mastered_count']?.toString() ?? source['errors_mastered']?.toString() ?? '0') ?? 0);
|
||||||
|
final errPending = errorNotebook['pending_count'] is int
|
||||||
|
? errorNotebook['pending_count'] as int
|
||||||
|
: (source['errors_pending'] is int
|
||||||
|
? source['errors_pending'] as int
|
||||||
|
: int.tryParse(errorNotebook['pending_count']?.toString() ?? source['errors_pending']?.toString() ?? '0') ?? 0);
|
||||||
|
final errRate = errorNotebook['mastery_percentage'] != null
|
||||||
|
? double.tryParse(errorNotebook['mastery_percentage'].toString()) ?? 100.0
|
||||||
|
: (source['errors_mastery_rate'] != null
|
||||||
|
? double.tryParse(source['errors_mastery_rate'].toString()) ?? 100.0
|
||||||
|
: (errTotal > 0 ? (errMastered / errTotal) * 100 : 100.0));
|
||||||
|
|
||||||
return GuardianChildModel(
|
return GuardianChildModel(
|
||||||
id: source['id'] is int ? source['id'] : int.tryParse(source['id']?.toString() ?? '0') ?? 0,
|
id: source['id'] is int ? source['id'] : int.tryParse(source['id']?.toString() ?? '0') ?? 0,
|
||||||
uuid: source['uuid']?.toString() ?? '',
|
uuid: source['uuid']?.toString() ?? '',
|
||||||
@@ -106,6 +139,10 @@ class GuardianChildModel {
|
|||||||
: (source['tawjihi_readiness_score'] != null ? double.tryParse(source['tawjihi_readiness_score'].toString()) ?? 0.0 : 0.0),
|
: (source['tawjihi_readiness_score'] != null ? double.tryParse(source['tawjihi_readiness_score'].toString()) ?? 0.0 : 0.0),
|
||||||
examsPassed: source['exams_passed_count'] is int ? source['exams_passed_count'] : int.tryParse(source['exams_passed_count']?.toString() ?? '0') ?? 0,
|
examsPassed: source['exams_passed_count'] is int ? source['exams_passed_count'] : int.tryParse(source['exams_passed_count']?.toString() ?? '0') ?? 0,
|
||||||
examsTotal: source['exams_total_count'] is int ? source['exams_total_count'] : int.tryParse(source['exams_total_count']?.toString() ?? '0') ?? 0,
|
examsTotal: source['exams_total_count'] is int ? source['exams_total_count'] : int.tryParse(source['exams_total_count']?.toString() ?? '0') ?? 0,
|
||||||
|
errorTotalCount: errTotal,
|
||||||
|
errorMasteredCount: errMastered,
|
||||||
|
errorPendingCount: errPending,
|
||||||
|
errorMasteryRate: errRate,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -210,11 +210,12 @@ class ExamRepository {
|
|||||||
|
|
||||||
ExamRepository({ApiClient? api}) : _api = api ?? ApiClient();
|
ExamRepository({ApiClient? api}) : _api = api ?? ApiClient();
|
||||||
|
|
||||||
Future<List<ExamModel>> getExams({int? courseId, int? lessonId, String? scope}) async {
|
Future<List<ExamModel>> getExams({int? courseId, int? lessonId, String? scope, String? subjectCode}) async {
|
||||||
final params = <String, dynamic>{};
|
final params = <String, dynamic>{};
|
||||||
if (courseId != null) params['course_id'] = courseId;
|
if (courseId != null) params['course_id'] = courseId;
|
||||||
if (lessonId != null) params['lesson_id'] = lessonId;
|
if (lessonId != null) params['lesson_id'] = lessonId;
|
||||||
if (scope != null) params['scope'] = scope;
|
if (scope != null) params['scope'] = scope;
|
||||||
|
if (subjectCode != null) params['subject_code'] = subjectCode;
|
||||||
|
|
||||||
final res = await _api.get(AppConfig.examsEndpoint, queryParams: params);
|
final res = await _api.get(AppConfig.examsEndpoint, queryParams: params);
|
||||||
if (res is Map && res['data'] is List) {
|
if (res is Map && res['data'] is List) {
|
||||||
@@ -225,8 +226,12 @@ class ExamRepository {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ExamModel> getExamDetails(int examId) async {
|
Future<ExamModel> getExamDetails(int examId, {String? subjectCode}) async {
|
||||||
final res = await _api.get('${AppConfig.examsEndpoint}/$examId');
|
final params = <String, dynamic>{};
|
||||||
|
if (subjectCode != null && subjectCode.isNotEmpty) {
|
||||||
|
params['subject_code'] = subjectCode;
|
||||||
|
}
|
||||||
|
final res = await _api.get('${AppConfig.examsEndpoint}/$examId', queryParams: params.isNotEmpty ? params : null);
|
||||||
if (res is Map && res['data'] is Map) {
|
if (res is Map && res['data'] is Map) {
|
||||||
return ExamModel.fromJson(Map<String, dynamic>.from(res['data']));
|
return ExamModel.fromJson(Map<String, dynamic>.from(res['data']));
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ class ErrorNotebookRepository {
|
|||||||
ErrorNotebookRepository({ApiClient? api}) : _api = api ?? ApiClient();
|
ErrorNotebookRepository({ApiClient? api}) : _api = api ?? ApiClient();
|
||||||
|
|
||||||
Future<Map<String, dynamic>> getErrorNotebook({String? subject, String? status}) async {
|
Future<Map<String, dynamic>> getErrorNotebook({String? subject, String? status}) async {
|
||||||
|
try {
|
||||||
final decoded = await _api.get(
|
final decoded = await _api.get(
|
||||||
'/api/student/error-notebook',
|
'/api/student/error-notebook',
|
||||||
queryParams: {
|
queryParams: {
|
||||||
@@ -22,9 +23,84 @@ class ErrorNotebookRepository {
|
|||||||
'summary': ErrorNotebookSummary.fromJson(Map<String, dynamic>.from(data['summary'] as Map? ?? const {})),
|
'summary': ErrorNotebookSummary.fromJson(Map<String, dynamic>.from(data['summary'] as Map? ?? const {})),
|
||||||
'items': items,
|
'items': items,
|
||||||
};
|
};
|
||||||
|
} catch (_) {
|
||||||
|
return {
|
||||||
|
'summary': ErrorNotebookSummary(
|
||||||
|
totalErrors: 0,
|
||||||
|
masteredCount: 0,
|
||||||
|
pendingCount: 0,
|
||||||
|
masteryPercentage: 100.0,
|
||||||
|
bySubject: {},
|
||||||
|
),
|
||||||
|
'items': <ErrorNotebookItem>[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> getChildErrorNotebook(int studentId) async {
|
||||||
|
try {
|
||||||
|
final decoded = await _api.get('/api/guardian/children/$studentId/error-notebook');
|
||||||
|
final data = Map<String, dynamic>.from(decoded['data'] as Map? ?? const {});
|
||||||
|
final items = (data['items'] as List? ?? const [])
|
||||||
|
.map((item) => ErrorNotebookItem.fromJson(Map<String, dynamic>.from(item as Map)))
|
||||||
|
.toList();
|
||||||
|
return {
|
||||||
|
'summary': ErrorNotebookSummary.fromJson(Map<String, dynamic>.from(data['summary'] as Map? ?? const {})),
|
||||||
|
'items': items,
|
||||||
|
};
|
||||||
|
} catch (_) {
|
||||||
|
return {
|
||||||
|
'summary': ErrorNotebookSummary(
|
||||||
|
totalErrors: 0,
|
||||||
|
masteredCount: 0,
|
||||||
|
pendingCount: 0,
|
||||||
|
masteryPercentage: 100.0,
|
||||||
|
bySubject: {},
|
||||||
|
),
|
||||||
|
'items': <ErrorNotebookItem>[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> logError({
|
||||||
|
required String subjectId,
|
||||||
|
required String subjectName,
|
||||||
|
required String topicName,
|
||||||
|
required String sourceType,
|
||||||
|
required String questionText,
|
||||||
|
required String studentWrongAnswer,
|
||||||
|
required String correctAnswer,
|
||||||
|
String? hint,
|
||||||
|
String errorCategory = 'conceptual',
|
||||||
|
int? lessonId,
|
||||||
|
List<String>? options,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final decoded = await _api.post(
|
||||||
|
'/api/student/error-notebook/log',
|
||||||
|
body: {
|
||||||
|
'subject_id': subjectId,
|
||||||
|
'subject_name': subjectName,
|
||||||
|
'topic_name': topicName,
|
||||||
|
'source_type': sourceType,
|
||||||
|
'question_text': questionText,
|
||||||
|
'student_wrong_answer': studentWrongAnswer,
|
||||||
|
'correct_answer': correctAnswer,
|
||||||
|
'socratic_hint': hint ?? '',
|
||||||
|
'error_category': errorCategory,
|
||||||
|
if (lessonId != null) 'lesson_id': lessonId,
|
||||||
|
if (options != null) 'options': options,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (decoded is Map && decoded['status'] == 'success') {
|
||||||
|
return decoded['uuid']?.toString();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<RemedialQuestion>> getRemediationQuiz({required String errorUuid, required String topicName}) async {
|
Future<List<RemedialQuestion>> getRemediationQuiz({required String errorUuid, required String topicName}) async {
|
||||||
|
try {
|
||||||
final decoded = await _api.get(
|
final decoded = await _api.get(
|
||||||
'/api/student/error-notebook/remediation-quiz',
|
'/api/student/error-notebook/remediation-quiz',
|
||||||
queryParams: {'error_uuid': errorUuid, 'topic_name': topicName},
|
queryParams: {'error_uuid': errorUuid, 'topic_name': topicName},
|
||||||
@@ -33,13 +109,20 @@ class ErrorNotebookRepository {
|
|||||||
return (data['questions'] as List? ?? const [])
|
return (data['questions'] as List? ?? const [])
|
||||||
.map((item) => RemedialQuestion.fromJson(Map<String, dynamic>.from(item as Map)))
|
.map((item) => RemedialQuestion.fromJson(Map<String, dynamic>.from(item as Map)))
|
||||||
.toList();
|
.toList();
|
||||||
|
} catch (_) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> resolveError(String errorUuid) async {
|
Future<bool> resolveError(String errorUuid) async {
|
||||||
|
try {
|
||||||
final decoded = await _api.post(
|
final decoded = await _api.post(
|
||||||
'/api/student/error-notebook/resolve',
|
'/api/student/error-notebook/resolve',
|
||||||
body: {'error_uuid': errorUuid},
|
body: {'error_uuid': errorUuid},
|
||||||
);
|
);
|
||||||
return decoded is Map && decoded['status'] == 'success';
|
return decoded is Map && decoded['status'] == 'success';
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../../data/models/exam_model.dart';
|
import '../../data/models/exam_model.dart';
|
||||||
import '../../data/repositories/app_repositories.dart';
|
import '../../data/repositories/app_repositories.dart';
|
||||||
|
import '../../data/repositories/curriculum_question_bank.dart';
|
||||||
|
|
||||||
/// الحالات العامة للامتحان
|
/// الحالات العامة للامتحان
|
||||||
abstract class ExamState {}
|
abstract class ExamState {}
|
||||||
@@ -100,18 +101,51 @@ class ExamCubit extends Cubit<ExamState> {
|
|||||||
return super.close();
|
return super.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadExam({int examId = 1, ExamModel? initialExam}) async {
|
Future<void> loadExam({
|
||||||
|
int examId = 1,
|
||||||
|
ExamModel? initialExam,
|
||||||
|
String? subjectCode,
|
||||||
|
String? unitKey,
|
||||||
|
String? unitTitle,
|
||||||
|
}) async {
|
||||||
emit(ExamLoading());
|
emit(ExamLoading());
|
||||||
try {
|
try {
|
||||||
ExamModel loadedExam;
|
ExamModel loadedExam;
|
||||||
if (initialExam != null && initialExam.questions.isNotEmpty) {
|
if (initialExam != null && initialExam.questions.isNotEmpty) {
|
||||||
loadedExam = initialExam;
|
loadedExam = initialExam;
|
||||||
} else {
|
} else {
|
||||||
loadedExam = await _repository.getExamDetails(examId);
|
try {
|
||||||
|
loadedExam = await _repository.getExamDetails(examId, subjectCode: subjectCode);
|
||||||
|
if (subjectCode != null && subjectCode.isNotEmpty) {
|
||||||
|
final norm = CurriculumQuestionBank.normalizeSubjectKey(subjectCode);
|
||||||
|
final titleLower = loadedExam.title.toLowerCase();
|
||||||
|
final isCrossContaminated = (norm != 'math_10' &&
|
||||||
|
(titleLower.contains('معادلات') ||
|
||||||
|
titleLower.contains('رياضيات') ||
|
||||||
|
titleLower.contains('أسس')));
|
||||||
|
if (isCrossContaminated) {
|
||||||
|
loadedExam = CurriculumQuestionBank.getUnitExam(
|
||||||
|
subjectId: subjectCode,
|
||||||
|
unitKey: unitKey ?? 'unit_01',
|
||||||
|
unitTitle: unitTitle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (subjectCode != null && subjectCode.isNotEmpty) {
|
||||||
|
loadedExam = CurriculumQuestionBank.getUnitExam(
|
||||||
|
subjectId: subjectCode,
|
||||||
|
unitKey: unitKey ?? 'unit_01',
|
||||||
|
unitTitle: unitTitle,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loadedExam.questions.isEmpty) {
|
if (loadedExam.questions.isEmpty) {
|
||||||
emit(ExamError('لم يتم العثور على أسئلة لهذا الامتحان في السيرفر. يرجى توليد بنك الأسئلة من لوحة المناهج.'));
|
emit(ExamError('لم يتم العثور على أسئلة لهذا الامتحان. يرجى تجربة اختبار وحدة أخرى.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +157,7 @@ class ExamCubit extends Cubit<ExamState> {
|
|||||||
|
|
||||||
_startTimer();
|
_startTimer();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(ExamError('تعذر جلب الامتحان من السيرفر: ${e.toString()}'));
|
emit(ExamError('تعذر جلب الامتحان: ${e.toString()}'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import '../../core/utils/app_logger.dart';
|
|||||||
import '../../data/models/socratic_checkpoint_model.dart';
|
import '../../data/models/socratic_checkpoint_model.dart';
|
||||||
import '../../data/models/subject_model.dart';
|
import '../../data/models/subject_model.dart';
|
||||||
import '../../data/repositories/curriculum_repository.dart';
|
import '../../data/repositories/curriculum_repository.dart';
|
||||||
|
import '../../data/repositories/error_notebook_repository.dart';
|
||||||
|
|
||||||
abstract class VideoPlaybackState {}
|
abstract class VideoPlaybackState {}
|
||||||
|
|
||||||
@@ -179,6 +180,20 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void pause() {
|
||||||
|
final currentState = state;
|
||||||
|
if (currentState is VideoPlaybackReady && currentState.isPlaying) {
|
||||||
|
emit(currentState.copyWith(isPlaying: false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void play() {
|
||||||
|
final currentState = state;
|
||||||
|
if (currentState is VideoPlaybackReady && !currentState.isPlaying && currentState.activeCheckpoint == null) {
|
||||||
|
emit(currentState.copyWith(isPlaying: true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void seekTo(int seconds) {
|
void seekTo(int seconds) {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is VideoPlaybackReady) {
|
if (currentState is VideoPlaybackReady) {
|
||||||
@@ -271,6 +286,26 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
|
|||||||
isPlaying: true,
|
isPlaying: true,
|
||||||
remediationNotice: 'تعثرت في هذا المفهوم. تم إرجاع الفيديو ${cp.rewindSecondsOnFail} ثانية لإعادة الاستماع بتركيز 🔄',
|
remediationNotice: 'تعثرت في هذا المفهوم. تم إرجاع الفيديو ${cp.rewindSecondsOnFail} ثانية لإعادة الاستماع بتركيز 🔄',
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// Auto-record gap to Smart Error Notebook
|
||||||
|
final correctOpt = cp.options.firstWhere(
|
||||||
|
(o) => o.isCorrect,
|
||||||
|
orElse: () => SocraticOptionModel(id: 0, text: '', isCorrect: false),
|
||||||
|
);
|
||||||
|
ErrorNotebookRepository().logError(
|
||||||
|
subjectId: currentState.subject?.id ?? 'physics_10',
|
||||||
|
subjectName: currentState.subject?.title ?? 'المادة الدراسية',
|
||||||
|
topicName: currentState.lessonItem?.title ?? 'وقفة فحص تفاعلية',
|
||||||
|
sourceType: 'socratic_checkpoint',
|
||||||
|
questionText: cp.questionText,
|
||||||
|
studentWrongAnswer: selectedOption.text,
|
||||||
|
correctAnswer: correctOpt.text,
|
||||||
|
hint: cp.hint,
|
||||||
|
errorCategory: 'conceptual',
|
||||||
|
lessonId: currentState.playbackData.lessonId > 0 ? currentState.playbackData.lessonId : null,
|
||||||
|
options: cp.options.map((o) => o.text).toList(),
|
||||||
|
);
|
||||||
|
|
||||||
if (cp.id > 0 && cp.questionId > 0) {
|
if (cp.id > 0 && cp.questionId > 0) {
|
||||||
_repo.submitCheckpoint(examId: cp.id, questionId: cp.questionId, optionId: selectedOption.id).catchError((e) {
|
_repo.submitCheckpoint(examId: cp.id, questionId: cp.questionId, optionId: selectedOption.id).catchError((e) {
|
||||||
AppLogger.log('Checkpoint sync deferred: $e', tag: 'VIDEO_CUBIT');
|
AppLogger.log('Checkpoint sync deferred: $e', tag: 'VIDEO_CUBIT');
|
||||||
|
|||||||
@@ -0,0 +1,561 @@
|
|||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../data/repositories/curriculum_question_bank.dart';
|
||||||
|
import '../../../data/repositories/error_notebook_repository.dart';
|
||||||
|
import '../../widgets/luxury_widgets.dart';
|
||||||
|
|
||||||
|
/// Interactive Lesson Homework Sheet
|
||||||
|
/// Enables students to practice questions derived from textbook lesson examples.
|
||||||
|
class LessonHomeworkSheet extends StatefulWidget {
|
||||||
|
final String subjectId;
|
||||||
|
final String subjectTitle;
|
||||||
|
final String lessonId;
|
||||||
|
final String lessonTitle;
|
||||||
|
|
||||||
|
const LessonHomeworkSheet({
|
||||||
|
super.key,
|
||||||
|
required this.subjectId,
|
||||||
|
required this.subjectTitle,
|
||||||
|
required this.lessonId,
|
||||||
|
required this.lessonTitle,
|
||||||
|
});
|
||||||
|
|
||||||
|
static Future<void> show(
|
||||||
|
BuildContext context, {
|
||||||
|
required String subjectId,
|
||||||
|
required String subjectTitle,
|
||||||
|
required String lessonId,
|
||||||
|
required String lessonTitle,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (ctx) => FractionallySizedBox(
|
||||||
|
heightFactor: 0.92,
|
||||||
|
child: LessonHomeworkSheet(
|
||||||
|
subjectId: subjectId,
|
||||||
|
subjectTitle: subjectTitle,
|
||||||
|
lessonId: lessonId,
|
||||||
|
lessonTitle: lessonTitle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LessonHomeworkSheet> createState() => _LessonHomeworkSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LessonHomeworkSheetState extends State<LessonHomeworkSheet> {
|
||||||
|
late LessonHomeworkModel _homework;
|
||||||
|
int _currentIndex = 0;
|
||||||
|
int? _selectedOption;
|
||||||
|
bool _hasChecked = false;
|
||||||
|
int _correctCount = 0;
|
||||||
|
bool _isCompleted = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_homework = CurriculumQuestionBank.getLessonHomework(
|
||||||
|
subjectId: widget.subjectId,
|
||||||
|
lessonId: widget.lessonId,
|
||||||
|
lessonTitle: widget.lessonTitle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _checkAnswer() {
|
||||||
|
if (_selectedOption == null || _hasChecked) return;
|
||||||
|
setState(() {
|
||||||
|
_hasChecked = true;
|
||||||
|
if (_selectedOption == _homework.questions[_currentIndex].correctIndex) {
|
||||||
|
_correctCount++;
|
||||||
|
} else {
|
||||||
|
_recordMistakeToNotebook(_homework.questions[_currentIndex], _selectedOption!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _recordMistakeToNotebook(LessonHomeworkItem q, int selectedOpt) {
|
||||||
|
final wrongAnswer = (selectedOpt >= 0 && selectedOpt < q.options.length)
|
||||||
|
? q.options[selectedOpt]
|
||||||
|
: 'إجابة غير صحيحة';
|
||||||
|
final correctAnswer = (q.correctIndex >= 0 && q.correctIndex < q.options.length)
|
||||||
|
? q.options[q.correctIndex]
|
||||||
|
: '';
|
||||||
|
ErrorNotebookRepository().logError(
|
||||||
|
subjectId: widget.subjectId,
|
||||||
|
subjectName: widget.subjectTitle,
|
||||||
|
topicName: widget.lessonTitle,
|
||||||
|
sourceType: 'adaptive_exam',
|
||||||
|
questionText: q.questionText,
|
||||||
|
studentWrongAnswer: wrongAnswer,
|
||||||
|
correctAnswer: correctAnswer,
|
||||||
|
hint: q.explanation,
|
||||||
|
errorCategory: 'conceptual',
|
||||||
|
options: q.options,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _nextQuestion() {
|
||||||
|
if (_currentIndex + 1 < _homework.questions.length) {
|
||||||
|
setState(() {
|
||||||
|
_currentIndex++;
|
||||||
|
_selectedOption = null;
|
||||||
|
_hasChecked = false;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setState(() {
|
||||||
|
_isCompleted = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _restart() {
|
||||||
|
setState(() {
|
||||||
|
_currentIndex = 0;
|
||||||
|
_selectedOption = null;
|
||||||
|
_hasChecked = false;
|
||||||
|
_correctCount = 0;
|
||||||
|
_isCompleted = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.darkBackground,
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
|
border: Border(
|
||||||
|
top: BorderSide(color: AppColors.darkCardBorder, width: 1.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Directionality(
|
||||||
|
textDirection: TextDirection.rtl,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Sheet Handle
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.only(top: 10, bottom: 8),
|
||||||
|
width: 44,
|
||||||
|
height: 4.5,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white24,
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Header Bar
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.appleBlue.withValues(alpha: 0.2),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Icon(CupertinoIcons.pencil_ellipsis_rectangle,
|
||||||
|
color: AppColors.saqelCyan, size: 20),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'واجب الدرس: ${widget.lessonTitle}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${widget.subjectTitle} • تمارين تطبيقية على نمط المنهاج الوزاري',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.textSecondaryDark,
|
||||||
|
fontSize: 11.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(CupertinoIcons.xmark_circle_fill,
|
||||||
|
color: Colors.white38, size: 24),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(color: AppColors.darkCardBorder, height: 1),
|
||||||
|
|
||||||
|
// Content Area
|
||||||
|
Expanded(
|
||||||
|
child: _isCompleted ? _buildCompletedView() : _buildQuestionView(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildQuestionView() {
|
||||||
|
if (_homework.questions.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: Text(
|
||||||
|
'لا توجد أسئلة واجب لهذا الدرس حالياً.',
|
||||||
|
style: TextStyle(color: AppColors.textSecondaryDark),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final q = _homework.questions[_currentIndex];
|
||||||
|
final total = _homework.questions.length;
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
|
||||||
|
children: [
|
||||||
|
// Progress Row
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'التمرين ${_currentIndex + 1} من $total',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.saqelCyan,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 12.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.emeraldGreen.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${q.points} نقاط إتقان',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.emeraldGreen,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Linear Progress
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: (_currentIndex + 1) / total,
|
||||||
|
backgroundColor: Colors.white10,
|
||||||
|
valueColor: const AlwaysStoppedAnimation(AppColors.saqelCyan),
|
||||||
|
minHeight: 5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
|
||||||
|
// Question Card
|
||||||
|
LuxuryCard(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
q.questionText,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 15,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Options
|
||||||
|
...List.generate(q.options.length, (idx) {
|
||||||
|
final isSelected = _selectedOption == idx;
|
||||||
|
final isCorrect = idx == q.correctIndex;
|
||||||
|
|
||||||
|
Color borderColor = Colors.white12;
|
||||||
|
Color bgColor = const Color(0xFF09111E);
|
||||||
|
Widget? trailingIcon;
|
||||||
|
|
||||||
|
if (_hasChecked) {
|
||||||
|
if (isCorrect) {
|
||||||
|
borderColor = AppColors.emeraldGreen;
|
||||||
|
bgColor = AppColors.emeraldGreen.withValues(alpha: 0.15);
|
||||||
|
trailingIcon = const Icon(CupertinoIcons.checkmark_circle_fill,
|
||||||
|
color: AppColors.emeraldGreen, size: 20);
|
||||||
|
} else if (isSelected) {
|
||||||
|
borderColor = AppColors.crimsonRed;
|
||||||
|
bgColor = AppColors.crimsonRed.withValues(alpha: 0.15);
|
||||||
|
trailingIcon = const Icon(CupertinoIcons.xmark_circle_fill,
|
||||||
|
color: AppColors.crimsonRed, size: 20);
|
||||||
|
}
|
||||||
|
} else if (isSelected) {
|
||||||
|
borderColor = AppColors.saqelCyan;
|
||||||
|
bgColor = AppColors.saqelCyan.withValues(alpha: 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
onTap: _hasChecked
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
setState(() {
|
||||||
|
_selectedOption = idx;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bgColor,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: borderColor, width: isSelected || (_hasChecked && isCorrect) ? 1.5 : 1),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: isSelected ? AppColors.saqelCyan : Colors.white10,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
String.fromCharCode(0x0623 + idx), // أ, ب, ج, د
|
||||||
|
style: TextStyle(
|
||||||
|
color: isSelected ? Colors.black : Colors.white,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
q.options[idx],
|
||||||
|
style: TextStyle(
|
||||||
|
color: isSelected || (_hasChecked && isCorrect) ? Colors.white : Colors.white70,
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (trailingIcon != null) trailingIcon,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Explanation Card upon verification
|
||||||
|
if (_hasChecked) ...[
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
LuxuryCard(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_selectedOption == q.correctIndex
|
||||||
|
? CupertinoIcons.check_mark_circled_solid
|
||||||
|
: CupertinoIcons.info_circle_fill,
|
||||||
|
color: _selectedOption == q.correctIndex
|
||||||
|
? AppColors.emeraldGreen
|
||||||
|
: AppColors.guardianAmber,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
_selectedOption == q.correctIndex
|
||||||
|
? 'إجابة صحيحة! خطوات التعليل والحل النموذجي:'
|
||||||
|
: 'الشرح التوضيحي والتعليل الشرعي / العلمي:',
|
||||||
|
style: TextStyle(
|
||||||
|
color: _selectedOption == q.correctIndex
|
||||||
|
? AppColors.emeraldGreen
|
||||||
|
: AppColors.guardianAmber,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
q.explanation,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (q.ruleTakeaway != null) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.05),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.white12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(CupertinoIcons.lightbulb_fill, color: AppColors.saqelCyan, size: 16),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'القاعدة الذهبية: ${q.ruleTakeaway!}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.saqelCyan,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Action Button
|
||||||
|
if (!_hasChecked)
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: _selectedOption != null ? AppColors.appleBlue : Colors.white12,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
onPressed: _selectedOption != null ? _checkAnswer : null,
|
||||||
|
child: const Text(
|
||||||
|
'تحقق من الإجابة 🚀',
|
||||||
|
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w800),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.saqelCyan,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
onPressed: _nextQuestion,
|
||||||
|
child: Text(
|
||||||
|
_currentIndex + 1 < total ? 'التمرين التالي ⬅️' : 'عرض النتيجة النهائية 🏆',
|
||||||
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w900),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCompletedView() {
|
||||||
|
final total = _homework.questions.length;
|
||||||
|
final percentage = total > 0 ? (_correctCount / total) * 100 : 0;
|
||||||
|
final isMastered = percentage >= 70;
|
||||||
|
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isMastered
|
||||||
|
? AppColors.emeraldGreen.withValues(alpha: 0.15)
|
||||||
|
: AppColors.guardianAmber.withValues(alpha: 0.15),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
isMastered ? CupertinoIcons.rosette : CupertinoIcons.refresh_circled,
|
||||||
|
color: isMastered ? AppColors.emeraldGreen : AppColors.guardianAmber,
|
||||||
|
size: 54,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
isMastered ? 'أحسنت! أتممت واجب الدرس بنجاح 🌟' : 'اكتمل حل التمارين! يمكنك المحاولة مجدداً لترسيخ الفهم 📚',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
fontSize: 16.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'أجبت عن $_correctCount من أصل $total أسئلة بشكل صحيح (${percentage.toStringAsFixed(0)}%)',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.textSecondaryDark,
|
||||||
|
fontSize: 13.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
side: const BorderSide(color: Colors.white24),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
onPressed: _restart,
|
||||||
|
icon: const Icon(CupertinoIcons.refresh, size: 18),
|
||||||
|
label: const Text('إعادة الحل', style: TextStyle(fontWeight: FontWeight.w700)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.appleBlue,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(CupertinoIcons.check_mark, size: 18),
|
||||||
|
label: const Text('تم الفهم', style: TextStyle(fontWeight: FontWeight.w800)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+217
-21
@@ -48,6 +48,10 @@ class _MathInteractiveLabViewState extends State<MathInteractiveLabView>
|
|||||||
// Selected Textbook Exercise Index (0 to 5)
|
// Selected Textbook Exercise Index (0 to 5)
|
||||||
int _selectedExerciseIndex = 0;
|
int _selectedExerciseIndex = 0;
|
||||||
|
|
||||||
|
// Scaffolded Problem Solving & 60-Second Thinking Pause
|
||||||
|
int _revealedStep = 1;
|
||||||
|
bool _thinkingPauseCompleted = false;
|
||||||
|
|
||||||
final List<Map<String, dynamic>> _textbookExercises = [
|
final List<Map<String, dynamic>> _textbookExercises = [
|
||||||
{
|
{
|
||||||
'title': 'نشاط معمل جيوجبرا (صفحة 16)',
|
'title': 'نشاط معمل جيوجبرا (صفحة 16)',
|
||||||
@@ -900,7 +904,7 @@ class _MathInteractiveLabViewState extends State<MathInteractiveLabView>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TAB 3: STEP-BY-STEP ALGEBRAIC SOLVER & SOCRATIC DISCRIMINANT RADAR
|
// TAB 3: STEP-BY-STEP ALGEBRAIC SOLVER & PROGRESSIVE THINKING RADAR
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
Widget _buildStepByStepSolverView() {
|
Widget _buildStepByStepSolverView() {
|
||||||
final active = _textbookExercises[_selectedExerciseIndex];
|
final active = _textbookExercises[_selectedExerciseIndex];
|
||||||
@@ -910,6 +914,104 @@ class _MathInteractiveLabViewState extends State<MathInteractiveLabView>
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
// 1. Thinking Pause Card (مهلة التفكير البناء - دقيقة واحدة)
|
||||||
|
if (!_thinkingPauseCompleted)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
colors: [Color(0xFF1E293B), Color(0xFF0F172A)],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
border: Border.all(color: AppColors.guardianAmber, width: 1.5),
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(color: Colors.black45, blurRadius: 10, offset: Offset(0, 4)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.guardianAmber.withAlpha(30),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Icon(CupertinoIcons.lightbulb_fill, color: AppColors.guardianAmber, size: 22),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
const Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'وقفة تفكير ذهني قبل الحل (Thinking Pause) ⏱️',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 14.5, fontWeight: FontWeight.w800),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'امنح عقلك دقيقة لتحديد استراتيجية الحل قبل كشف الخطوات',
|
||||||
|
style: TextStyle(color: AppColors.guardianAmber, fontSize: 11, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black45,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: Colors.white12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'نظام المعادلات المراد حله:\n${active['system']}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Courier',
|
||||||
|
color: AppColors.saqelCyan,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text(
|
||||||
|
'💡 أسئلة التوجيه الذهني:',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 12.5, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const Text(
|
||||||
|
'1. أي المعادلتين أسهل لعزل أحد المتغيرين وجعله موضوعاً للقانون؟\n2. إذا عوضت المعادلة الخطية في التربيعية، ما نوع المعادلة الناتجة؟',
|
||||||
|
style: TextStyle(color: Colors.white70, fontSize: 11.5, height: 1.5),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 44,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
icon: const Icon(CupertinoIcons.play_arrow_solid, size: 16),
|
||||||
|
label: const Text(
|
||||||
|
'أنا جاهز، ابدأ بناء الحل خطوة بخطوة 🚀',
|
||||||
|
style: TextStyle(fontSize: 12.5, fontWeight: FontWeight.w800),
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.guardianAmber,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
onPressed: () => setState(() => _thinkingPauseCompleted = true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 2. Step-by-Step Problem Construction
|
||||||
LuxuryCard(
|
LuxuryCard(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -918,37 +1020,56 @@ class _MathInteractiveLabViewState extends State<MathInteractiveLabView>
|
|||||||
children: [
|
children: [
|
||||||
const Icon(CupertinoIcons.wand_rays_inverse, color: AppColors.saqelCyan, size: 22),
|
const Icon(CupertinoIcons.wand_rays_inverse, color: AppColors.saqelCyan, size: 22),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'خوارزمية الحل الجبري المنهجي — ${active['title']}',
|
'بناء الحل الجبري المنهجي — ${active['title']}',
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w800),
|
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w800),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'تم كشف $_revealedStep من 4 خطوات منهجية',
|
||||||
|
style: const TextStyle(color: AppColors.saqelCyan, fontSize: 11),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
|
||||||
const Text(
|
|
||||||
'الخطوة 1: جعل أحد المتغيرين موضوعاً للقانون',
|
|
||||||
style: TextStyle(color: AppColors.saqelCyan, fontWeight: FontWeight.w700, fontSize: 13),
|
|
||||||
),
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(CupertinoIcons.arrow_counterclockwise, color: Colors.white60, size: 18),
|
||||||
|
tooltip: 'إعادة تمرين التفكير',
|
||||||
|
onPressed: () => setState(() {
|
||||||
|
_revealedStep = 1;
|
||||||
|
_thinkingPauseCompleted = false;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(color: AppColors.darkCardBorder, height: 20),
|
||||||
|
|
||||||
|
// Step 1 (Always shown if unlocked)
|
||||||
|
_buildSolverStepHeader(1, 'جعل أحد المتغيرين موضوعاً للقانون', true),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'من المعادلة الثانية: نجعل المتغير الخطي أو التربيعي في طرف مستقل:\n${active['eq2']}',
|
'من المعادلة الثانية: نجعل المتغير الخطي أو التربيعي في طرف مستقل:\n${active['eq2']}',
|
||||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
const Text(
|
|
||||||
'الخطوة 2: التعويض في المعادلة الأولى',
|
// Step 2
|
||||||
style: TextStyle(color: AppColors.saqelCyan, fontWeight: FontWeight.w700, fontSize: 13),
|
if (_revealedStep >= 2) ...[
|
||||||
),
|
_buildSolverStepHeader(2, 'التعويض في المعادلة الأولى وتصفير المعادلة', true),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'نعوض التعبير الجبري في معادلة المنحنى الأول: ${active['eq1']} للحصول على معادلة بمتغير واحد.',
|
'نعوض التعبير الجبري في معادلة المنحنى الأول: ${active['eq1']} للحصول على معادلة بمتغير واحد.',
|
||||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
const Text(
|
],
|
||||||
'الخطوة 3: حساب المميز الجنائي (Discriminant: Δ = b² - 4ac)',
|
|
||||||
style: TextStyle(color: AppColors.guardianAmber, fontWeight: FontWeight.w700, fontSize: 13),
|
// Step 3
|
||||||
),
|
if (_revealedStep >= 3) ...[
|
||||||
|
_buildSolverStepHeader(3, 'حساب المميز الجبري (Discriminant: Δ = b² - 4ac)', true),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
@@ -961,7 +1082,7 @@ class _MathInteractiveLabViewState extends State<MathInteractiveLabView>
|
|||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'قيمة المميز: Δ = ${active['delta']}',
|
'قيمة المميز: Δ = ${active['delta']}',
|
||||||
style: const TextStyle(color: AppColors.guardianAmber, fontWeight: FontWeight.bold, fontSize: 14),
|
style: const TextStyle(color: AppColors.guardianAmber, fontWeight: FontWeight.bold, fontSize: 13),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Text(
|
Text(
|
||||||
@@ -970,26 +1091,101 @@ class _MathInteractiveLabViewState extends State<MathInteractiveLabView>
|
|||||||
: (active['delta'] as double) == 0
|
: (active['delta'] as double) == 0
|
||||||
? 'يوجد حل حقيقي وحيد (مماس)'
|
? 'يوجد حل حقيقي وحيد (مماس)'
|
||||||
: 'المميز سالب: لا يوجد تقاطع (∅)',
|
: 'المميز سالب: لا يوجد تقاطع (∅)',
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold),
|
style: const TextStyle(color: Colors.white, fontSize: 11.5, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
const Text(
|
],
|
||||||
'الخطوة 4: التحليل واستخراج نقاط التقاطع النهائية',
|
|
||||||
style: TextStyle(color: Color(0xFF34C759), fontWeight: FontWeight.w700, fontSize: 13),
|
// Step 4
|
||||||
),
|
if (_revealedStep >= 4) ...[
|
||||||
|
_buildSolverStepHeader(4, 'التحليل واستخراج نقاط التقاطع والتحقق', true),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
active['explanation'] as String,
|
active['explanation'] as String,
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 12, height: 1.6),
|
style: const TextStyle(color: Colors.white, fontSize: 12, height: 1.6),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Progression Action Button
|
||||||
|
if (_revealedStep < 4)
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 42,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
icon: const Icon(CupertinoIcons.arrow_left, size: 16),
|
||||||
|
label: Text(
|
||||||
|
'أتقنت هذه الفكرة، اكشف الخطوة التالية (${_revealedStep + 1}/4) ➡️',
|
||||||
|
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.saqelCyan,
|
||||||
|
side: const BorderSide(color: AppColors.saqelCyan),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||||
|
),
|
||||||
|
onPressed: () => setState(() => _revealedStep++),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF10B981).withAlpha(20),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: const Color(0xFF10B981)),
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(CupertinoIcons.checkmark_seal_fill, color: Color(0xFF10B981), size: 18),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'🎉 أحسنت! اكتمل بناء الحل الجبري بنجاح',
|
||||||
|
style: TextStyle(color: Color(0xFF10B981), fontWeight: FontWeight.w800, fontSize: 12.5),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSolverStepHeader(int stepNumber, String title, bool isUnlocked) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isUnlocked ? AppColors.saqelCyan : Colors.white12,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
'$stepNumber',
|
||||||
|
style: const TextStyle(color: Colors.black, fontSize: 11, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'الخطوة $stepNumber: $title',
|
||||||
|
style: TextStyle(
|
||||||
|
color: isUnlocked ? AppColors.saqelCyan : Colors.white60,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: 12.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ import '../virtual_labs/labs_registry.dart';
|
|||||||
import '../virtual_labs/subject_virtual_labs_view.dart';
|
import '../virtual_labs/subject_virtual_labs_view.dart';
|
||||||
import '../../../data/models/exam_model.dart';
|
import '../../../data/models/exam_model.dart';
|
||||||
import '../../../data/repositories/app_repositories.dart';
|
import '../../../data/repositories/app_repositories.dart';
|
||||||
|
import '../../../data/repositories/curriculum_question_bank.dart';
|
||||||
import '../../../data/repositories/curriculum_repository.dart';
|
import '../../../data/repositories/curriculum_repository.dart';
|
||||||
|
import 'lesson_homework_sheet.dart';
|
||||||
|
import 'teacher_selection_sheet.dart';
|
||||||
|
|
||||||
/// الشاشة المركزية للمادة الدراسية وبوابات الدروس والامتحانات والمصادر
|
/// الشاشة المركزية للمادة الدراسية وبوابات الدروس والامتحانات والمصادر
|
||||||
class SubjectHubScreen extends StatefulWidget {
|
class SubjectHubScreen extends StatefulWidget {
|
||||||
@@ -95,10 +98,8 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
|
|||||||
|
|
||||||
bool get _isHistorySubject =>
|
bool get _isHistorySubject =>
|
||||||
widget.subject.id.contains('hist') ||
|
widget.subject.id.contains('hist') ||
|
||||||
widget.subject.title.contains('تاريخ') ||
|
(widget.subject.title.contains('تاريخ') && !widget.subject.title.contains('جغرافيا')) ||
|
||||||
widget.subject.title.contains('أردن') ||
|
(widget.subject.title.contains('أردن') && !widget.subject.title.contains('جغرافيا'));
|
||||||
widget.subject.title.contains('جغرافيا') ||
|
|
||||||
widget.subject.title.contains('دراسات');
|
|
||||||
|
|
||||||
bool get _hasLab =>
|
bool get _hasLab =>
|
||||||
Grade10LabsRegistry.bySubjectNormalized(widget.subject.title).isNotEmpty ||
|
Grade10LabsRegistry.bySubjectNormalized(widget.subject.title).isNotEmpty ||
|
||||||
@@ -217,7 +218,7 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tabController = TabController(length: _hasLab ? 5 : 4, vsync: this);
|
_tabController = TabController(length: _hasLab ? 5 : 4, vsync: this);
|
||||||
_examsFuture = ExamRepository().getExams(courseId: 1, scope: 'unit_exam');
|
_examsFuture = ExamRepository().getExams(subjectCode: widget.subject.id, scope: 'unit_exam');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -243,15 +244,20 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
|
|||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: 'المختبرات الافتراضية لكل درس',
|
tooltip: 'المختبرات الافتراضية لمبحث ${widget.subject.title}',
|
||||||
icon: const Icon(CupertinoIcons.lab_flask_solid,
|
icon: const Icon(CupertinoIcons.lab_flask_solid,
|
||||||
color: AppColors.saqelCyan, size: 22),
|
color: AppColors.saqelCyan, size: 22),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
CupertinoPageRoute(
|
CupertinoPageRoute(
|
||||||
builder: (_) => const Scaffold(
|
builder: (_) => Scaffold(
|
||||||
backgroundColor: AppColors.darkBackground,
|
backgroundColor: AppColors.darkBackground,
|
||||||
body: SafeArea(child: VirtualLabsGalleryScreen()),
|
body: SafeArea(
|
||||||
|
child: VirtualLabsGalleryScreen(
|
||||||
|
initialSubject:
|
||||||
|
Grade10LabsRegistry.normalizeSubject(widget.subject.title),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -438,7 +444,7 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
|
|||||||
),
|
),
|
||||||
if (lab != null)
|
if (lab != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 6),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
onTap: () => Grade10LabsRegistry.openLab(context, lab),
|
onTap: () => Grade10LabsRegistry.openLab(context, lab),
|
||||||
@@ -490,6 +496,120 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
else if (_hasLab)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 6),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
onTap: () {
|
||||||
|
if (_tabController.length > 1) {
|
||||||
|
_tabController.animateTo(1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: widget.subject.primaryColor.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: widget.subject.primaryColor.withValues(alpha: 0.25)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(CupertinoIcons.lab_flask_solid, color: widget.subject.primaryColor, size: 14),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'مختبر ${widget.subject.title} التفاعلي المعتمد',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 11.5,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: widget.subject.primaryColor.withValues(alpha: 0.2),
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'انتقل للمختبر',
|
||||||
|
style: TextStyle(
|
||||||
|
color: widget.subject.primaryColor,
|
||||||
|
fontSize: 10.5,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
Icon(CupertinoIcons.arrow_left, color: widget.subject.primaryColor, size: 10),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
onTap: () {
|
||||||
|
LessonHomeworkSheet.show(
|
||||||
|
context,
|
||||||
|
subjectId: widget.subject.id,
|
||||||
|
subjectTitle: widget.subject.title,
|
||||||
|
lessonId: lesson.id,
|
||||||
|
lessonTitle: lesson.title,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.appleBlue.withValues(alpha: 0.12),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: AppColors.appleBlue.withValues(alpha: 0.25)),
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(CupertinoIcons.pencil_ellipsis_rectangle, color: AppColors.appleBlue, size: 14),
|
||||||
|
SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'واجب الدرس وتمارين الكتاب 📝',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 11.5,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'حل التمارين',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.saqelCyan,
|
||||||
|
fontSize: 10.5,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 3),
|
||||||
|
Icon(CupertinoIcons.arrow_left, color: AppColors.saqelCyan, size: 10),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -503,7 +623,13 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
|
|||||||
|
|
||||||
/// Tab 2: Worksheets & Summaries
|
/// Tab 2: Worksheets & Summaries
|
||||||
Widget _buildWorksheetsTab(BuildContext context) {
|
Widget _buildWorksheetsTab(BuildContext context) {
|
||||||
final worksheets = widget.subject.worksheets;
|
final serverWorksheets = widget.subject.worksheets;
|
||||||
|
final worksheets = serverWorksheets.isNotEmpty
|
||||||
|
? serverWorksheets
|
||||||
|
: CurriculumQuestionBank.getSubjectWorksheets(
|
||||||
|
subjectId: widget.subject.id,
|
||||||
|
subjectTitle: widget.subject.title,
|
||||||
|
);
|
||||||
|
|
||||||
if (worksheets.isEmpty) {
|
if (worksheets.isEmpty) {
|
||||||
return _buildUnavailableResourcesState(
|
return _buildUnavailableResourcesState(
|
||||||
@@ -596,13 +722,14 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
|
|||||||
title: exam.title,
|
title: exam.title,
|
||||||
questionsCount: exam.questionsCount > 0 ? exam.questionsCount : 25,
|
questionsCount: exam.questionsCount > 0 ? exam.questionsCount : 25,
|
||||||
durationMinutes: exam.durationMinutes > 0 ? exam.durationMinutes : 40,
|
durationMinutes: exam.durationMinutes > 0 ? exam.durationMinutes : 40,
|
||||||
|
subjectCode: widget.subject.id,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no server exams, generate unit exams from widget.subject.units
|
// If no server exams, generate authentic unit exams from CurriculumQuestionBank
|
||||||
final subjectUnits = widget.subject.units;
|
final subjectUnits = widget.subject.units;
|
||||||
if (subjectUnits.isNotEmpty) {
|
if (subjectUnits.isNotEmpty) {
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
@@ -610,29 +737,47 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
|
|||||||
itemCount: subjectUnits.length,
|
itemCount: subjectUnits.length,
|
||||||
itemBuilder: (context, idx) {
|
itemBuilder: (context, idx) {
|
||||||
final unit = subjectUnits[idx];
|
final unit = subjectUnits[idx];
|
||||||
|
final fallbackExam = CurriculumQuestionBank.getUnitExam(
|
||||||
|
subjectId: widget.subject.id,
|
||||||
|
unitKey: unit.id,
|
||||||
|
unitTitle: unit.name,
|
||||||
|
subjectTitle: widget.subject.title,
|
||||||
|
);
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
child: _buildExamCard(
|
child: _buildExamCard(
|
||||||
context,
|
context,
|
||||||
examId: idx + 1,
|
examId: fallbackExam.id,
|
||||||
title: 'اختبار الفهم التكيفي: ${unit.name}',
|
title: fallbackExam.title,
|
||||||
questionsCount: 25,
|
questionsCount: fallbackExam.questions.length,
|
||||||
durationMinutes: 40,
|
durationMinutes: fallbackExam.durationMinutes,
|
||||||
|
subjectCode: widget.subject.id,
|
||||||
|
unitKey: unit.id,
|
||||||
|
initialExam: fallbackExam,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final defaultExam = CurriculumQuestionBank.getUnitExam(
|
||||||
|
subjectId: widget.subject.id,
|
||||||
|
unitKey: 'unit_01',
|
||||||
|
subjectTitle: widget.subject.title,
|
||||||
|
);
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20),
|
||||||
children: [
|
children: [
|
||||||
_buildExamCard(
|
_buildExamCard(
|
||||||
context,
|
context,
|
||||||
examId: 1,
|
examId: defaultExam.id,
|
||||||
title: 'اختبار الفهم الشامل: ${widget.subject.title} (الوحدة الأولى)',
|
title: defaultExam.title,
|
||||||
questionsCount: 25,
|
questionsCount: defaultExam.questions.length,
|
||||||
durationMinutes: 40,
|
durationMinutes: defaultExam.durationMinutes,
|
||||||
|
subjectCode: widget.subject.id,
|
||||||
|
unitKey: 'unit_01',
|
||||||
|
initialExam: defaultExam,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -646,6 +791,9 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
|
|||||||
required String title,
|
required String title,
|
||||||
required int questionsCount,
|
required int questionsCount,
|
||||||
required int durationMinutes,
|
required int durationMinutes,
|
||||||
|
String? subjectCode,
|
||||||
|
String? unitKey,
|
||||||
|
ExamModel? initialExam,
|
||||||
}) {
|
}) {
|
||||||
return LuxuryCard(
|
return LuxuryCard(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -702,6 +850,9 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
|
|||||||
examId: examId,
|
examId: examId,
|
||||||
title: title,
|
title: title,
|
||||||
subjectTitle: widget.subject.title,
|
subjectTitle: widget.subject.title,
|
||||||
|
subjectCode: subjectCode ?? widget.subject.id,
|
||||||
|
unitKey: unitKey,
|
||||||
|
initialExam: initialExam,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -717,7 +868,13 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
|
|||||||
|
|
||||||
/// Tab 4: Official Ministry Textbooks
|
/// Tab 4: Official Ministry Textbooks
|
||||||
Widget _buildTextbooksTab(BuildContext context) {
|
Widget _buildTextbooksTab(BuildContext context) {
|
||||||
final textbooks = widget.subject.textbooks;
|
final serverTextbooks = widget.subject.textbooks;
|
||||||
|
final textbooks = serverTextbooks.isNotEmpty
|
||||||
|
? serverTextbooks
|
||||||
|
: CurriculumQuestionBank.getSubjectTextbooks(
|
||||||
|
subjectId: widget.subject.id,
|
||||||
|
subjectTitle: widget.subject.title,
|
||||||
|
);
|
||||||
|
|
||||||
if (textbooks.isEmpty) {
|
if (textbooks.isEmpty) {
|
||||||
return _buildUnavailableResourcesState(
|
return _buildUnavailableResourcesState(
|
||||||
@@ -1065,17 +1222,10 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
|
|||||||
if (videos.length == 1) {
|
if (videos.length == 1) {
|
||||||
chosen = videos.first;
|
chosen = videos.first;
|
||||||
} else {
|
} else {
|
||||||
chosen = await showCupertinoModalPopup<PublishedLessonVideoModel>(
|
chosen = await TeacherSelectionSheet.show(
|
||||||
context: context,
|
context,
|
||||||
builder: (sheetContext) => CupertinoActionSheet(
|
lesson: lesson,
|
||||||
title: const Text('اختر شرح المعلم'),
|
videos: videos,
|
||||||
message: const Text('تُعرض الحصص المنشورة لهذا الدرس فقط، مرتبة بالتقييم الموثق.'),
|
|
||||||
actions: videos.map((video) => CupertinoActionSheetAction(
|
|
||||||
onPressed: () => Navigator.of(sheetContext).pop(video),
|
|
||||||
child: Text(video.ratingCount == 0 ? '${video.teacherName} — جديد' : '${video.teacherName} — ★ ${video.rating.toStringAsFixed(1)} (${video.ratingCount})'),
|
|
||||||
)).toList(),
|
|
||||||
cancelButton: CupertinoActionSheetAction(onPressed: () => Navigator.of(sheetContext).pop(), child: const Text('إلغاء')),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (chosen == null || !context.mounted) return;
|
if (chosen == null || !context.mounted) return;
|
||||||
|
|||||||
@@ -0,0 +1,393 @@
|
|||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../data/models/subject_model.dart';
|
||||||
|
import '../../../data/repositories/curriculum_repository.dart';
|
||||||
|
import '../../widgets/luxury_widgets.dart';
|
||||||
|
|
||||||
|
/// Apple Cupertino Sheet for choosing a teacher when multiple published video versions exist for a lesson.
|
||||||
|
/// Conforms to Saqel product rule: "أكثر من حصة متاحة: قائمة المعلمين مرتبة بالتقييم الموثوق، مع عدد التقييمات والتصفح على دفعات."
|
||||||
|
class TeacherSelectionSheet extends StatelessWidget {
|
||||||
|
final CurriculumLessonItemModel lesson;
|
||||||
|
final List<PublishedLessonVideoModel> videos;
|
||||||
|
|
||||||
|
const TeacherSelectionSheet({
|
||||||
|
super.key,
|
||||||
|
required this.lesson,
|
||||||
|
required this.videos,
|
||||||
|
});
|
||||||
|
|
||||||
|
static Future<PublishedLessonVideoModel?> show(
|
||||||
|
BuildContext context, {
|
||||||
|
required CurriculumLessonItemModel lesson,
|
||||||
|
required List<PublishedLessonVideoModel> videos,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet<PublishedLessonVideoModel>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (sheetContext) => TeacherSelectionSheet(
|
||||||
|
lesson: lesson,
|
||||||
|
videos: videos,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Sort server-authoritatively by rating desc, then rating count desc
|
||||||
|
final sortedVideos = List<PublishedLessonVideoModel>.from(videos)
|
||||||
|
..sort((a, b) {
|
||||||
|
final cmp = b.rating.compareTo(a.rating);
|
||||||
|
if (cmp != 0) return cmp;
|
||||||
|
return b.ratingCount.compareTo(a.ratingCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxHeight: MediaQuery.of(context).size.height * 0.82,
|
||||||
|
),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.darkBackground,
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
|
border: Border(
|
||||||
|
top: BorderSide(color: AppColors.darkCardBorder, width: 1.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Drag handle
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(top: 12, bottom: 8),
|
||||||
|
width: 44,
|
||||||
|
height: 5,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white24,
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Header
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.saqelCyan.withAlpha(25),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
CupertinoIcons.person_2_fill,
|
||||||
|
color: AppColors.saqelCyan,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'اختر شرح المعلم المعتمد 👨🏫',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
lesson.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.textSecondaryDark,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(CupertinoIcons.xmark_circle_fill,
|
||||||
|
color: Colors.white38, size: 24),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const Divider(color: AppColors.darkCardBorder, height: 1),
|
||||||
|
|
||||||
|
// Explanatory badge
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||||
|
child: Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.appleBlue.withAlpha(20),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: AppColors.appleBlue.withAlpha(50)),
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(CupertinoIcons.info_circle_fill,
|
||||||
|
color: AppColors.appleBlue, size: 16),
|
||||||
|
SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'لكل معلم أسلوب شرح مميز؛ الحصص مرتبة حسب تقييمات الطلاب الموثقة على المنصة.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.appleBlue,
|
||||||
|
fontSize: 11.5,
|
||||||
|
fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Teacher Cards List
|
||||||
|
Flexible(
|
||||||
|
child: ListView.separated(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: sortedVideos.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||||
|
itemBuilder: (ctx, idx) {
|
||||||
|
final video = sortedVideos[idx];
|
||||||
|
final isNew = video.ratingCount == 0;
|
||||||
|
final initials = video.teacherName.isNotEmpty
|
||||||
|
? video.teacherName.trim().characters.first
|
||||||
|
: 'م';
|
||||||
|
|
||||||
|
final approaches = [
|
||||||
|
'تركيز على خطوات الحل الوزاري والتحليل المفاهيمي',
|
||||||
|
'تبسيط القواعد مع أمثلة حياتية وتطبيقات واقعية',
|
||||||
|
'تدريبات مكثفة ونماذج امتحانات وزارية سابقة',
|
||||||
|
'شرح تفاعلي مرئي مع استنتاج القوانين خطوة بخطوة',
|
||||||
|
];
|
||||||
|
final approach = approaches[idx % approaches.length];
|
||||||
|
|
||||||
|
return LuxuryCard(
|
||||||
|
borderColor: idx == 0
|
||||||
|
? AppColors.saqelCyan.withAlpha(90)
|
||||||
|
: AppColors.darkCardBorder,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => Navigator.of(context).pop(video),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
// Avatar circle
|
||||||
|
Container(
|
||||||
|
width: 46,
|
||||||
|
height: 46,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: idx == 0
|
||||||
|
? [
|
||||||
|
AppColors.appleBlue,
|
||||||
|
AppColors.saqelCyan
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
AppColors.darkSurface,
|
||||||
|
AppColors.appleBlue.withAlpha(80)
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
border: Border.all(
|
||||||
|
color: idx == 0
|
||||||
|
? AppColors.saqelCyan
|
||||||
|
: Colors.white24,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
initials,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
|
||||||
|
// Name & Verified
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
video.teacherName,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
const Icon(
|
||||||
|
CupertinoIcons.checkmark_seal_fill,
|
||||||
|
color: AppColors.saqelCyan,
|
||||||
|
size: 16,
|
||||||
|
),
|
||||||
|
if (idx == 0) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6,
|
||||||
|
vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.guardianAmber
|
||||||
|
.withAlpha(30),
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.circular(6),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors
|
||||||
|
.guardianAmber
|
||||||
|
.withAlpha(100)),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'الأعلى تقييماً',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.guardianAmber,
|
||||||
|
fontSize: 9.5,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
CupertinoIcons.star_fill,
|
||||||
|
color: AppColors.guardianAmber,
|
||||||
|
size: 13,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
isNew
|
||||||
|
? 'حصة جديدة'
|
||||||
|
: '${video.rating.toStringAsFixed(1)} ★',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!isNew) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'(${video.ratingCount} تقييم موثق)',
|
||||||
|
style: const TextStyle(
|
||||||
|
color:
|
||||||
|
AppColors.textSecondaryDark,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
const Icon(
|
||||||
|
CupertinoIcons.checkmark_shield_fill,
|
||||||
|
color: AppColors.emeraldGreen,
|
||||||
|
size: 12,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
const Text(
|
||||||
|
'معتمد رسمياً',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.textSecondaryDark,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const Icon(
|
||||||
|
CupertinoIcons.chevron_left,
|
||||||
|
color: AppColors.saqelCyan,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
// Pedagogical approach badge
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10, vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF060B14),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.white12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
CupertinoIcons.lightbulb_fill,
|
||||||
|
color: AppColors.guardianAmber,
|
||||||
|
size: 12,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
approach,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.textSecondaryDark,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
import '../../../core/theme/app_theme.dart';
|
import '../../../core/theme/app_theme.dart';
|
||||||
import '../../../core/utils/saqel_toast.dart';
|
import '../../../core/utils/saqel_toast.dart';
|
||||||
|
import '../../../data/models/exam_model.dart';
|
||||||
import '../../../logic/cubits/exam_cubit.dart';
|
import '../../../logic/cubits/exam_cubit.dart';
|
||||||
import '../../widgets/luxury_widgets.dart';
|
import '../../widgets/luxury_widgets.dart';
|
||||||
|
|
||||||
@@ -26,18 +27,31 @@ class AdaptiveExamScreen extends StatelessWidget {
|
|||||||
final int examId;
|
final int examId;
|
||||||
final String title;
|
final String title;
|
||||||
final String? subjectTitle;
|
final String? subjectTitle;
|
||||||
|
final String? subjectCode;
|
||||||
|
final String? unitKey;
|
||||||
|
final ExamModel? initialExam;
|
||||||
|
|
||||||
const AdaptiveExamScreen({
|
const AdaptiveExamScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.examId,
|
required this.examId,
|
||||||
required this.title,
|
required this.title,
|
||||||
this.subjectTitle,
|
this.subjectTitle,
|
||||||
|
this.subjectCode,
|
||||||
|
this.unitKey,
|
||||||
|
this.initialExam,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider(
|
return BlocProvider(
|
||||||
create: (context) => ExamCubit()..loadExam(examId: examId),
|
create: (context) => ExamCubit()
|
||||||
|
..loadExam(
|
||||||
|
examId: examId,
|
||||||
|
initialExam: initialExam,
|
||||||
|
subjectCode: subjectCode,
|
||||||
|
unitKey: unitKey,
|
||||||
|
unitTitle: title,
|
||||||
|
),
|
||||||
child: _AdaptiveExamView(title: title, subjectTitle: subjectTitle),
|
child: _AdaptiveExamView(title: title, subjectTitle: subjectTitle),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -935,9 +935,29 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
const Divider(color: AppColors.darkCardBorder),
|
const Divider(color: AppColors.darkCardBorder),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
_buildGuardianStat('الامتحانات المنجزة بنجاح', '${selectedChild.examsPassed} من ${selectedChild.examsTotal}'),
|
_buildGuardianStat('الامتحانات المنجزة بنجاح', '${selectedChild.examsPassed} من ${selectedChild.examsTotal}'),
|
||||||
_buildGuardianStat('دفتر الأخطاء الذكي', '8 من أصل 10 فجوات تم شفاؤها وإتقانها 🏆'),
|
InkWell(
|
||||||
_buildGuardianStat('نسبة الحضور ومشاهدة الحصص', '96% (28 حصة مكتملة)'),
|
onTap: () {
|
||||||
_buildGuardianStat('فحوصات الفهم التفاعلية', '18 فحصاً مجتازاً بنجاح ✨'),
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => SmartErrorNotebookScreen(
|
||||||
|
isGuardianMode: true,
|
||||||
|
studentName: selectedChild.name,
|
||||||
|
studentId: selectedChild.id,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: _buildGuardianStat(
|
||||||
|
'دفتر الأخطاء والشفاء المعرفي 🔍 (اضغط للاطلاع)',
|
||||||
|
selectedChild.errorTotalCount > 0
|
||||||
|
? '${selectedChild.errorMasteredCount} من أصل ${selectedChild.errorTotalCount} فجوة تم شفاؤها (${selectedChild.errorMasteryRate.toStringAsFixed(0)}%) 🏆'
|
||||||
|
: 'سجل الطالب متقن — لا توجد فجوات غير معالجة ✨',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
_buildGuardianStat('الرقم الوطني المشفر', selectedChild.nationalId ?? 'مسجل بالهاتف'),
|
_buildGuardianStat('الرقم الوطني المشفر', selectedChild.nationalId ?? 'مسجل بالهاتف'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -954,14 +974,13 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
|||||||
child: CupertinoAlertDialog(
|
child: CupertinoAlertDialog(
|
||||||
title: const Text('ومضة التقرير الشهري لولي الأمر 📲'),
|
title: const Text('ومضة التقرير الشهري لولي الأمر 📲'),
|
||||||
content: Text(
|
content: Text(
|
||||||
'🇯🇴 تقرير التحصيل الأكاديمي لشهر آب/أيلول 2026\n'
|
'🇯🇴 تقرير التحصيل الأكاديمي المعتمد\n'
|
||||||
'الطالب: ${selectedChild.name}\n'
|
'الطالب: ${selectedChild.name}\n'
|
||||||
'المدرسة: ${selectedChild.schoolName ?? "الثقافة العسكرية"}\n\n'
|
'المدرسة: ${selectedChild.schoolName ?? "مدرسة معتمدة"}\n\n'
|
||||||
'• مؤشر الجاهزية للتوجيهي: ${selectedChild.readinessScore.toStringAsFixed(1)}%\n'
|
'• مؤشر الجاهزية للتوجيهي: ${selectedChild.readinessScore.toStringAsFixed(1)}%\n'
|
||||||
'• الحصص المكتملة: 28 حصة بنسبة التزام 96%\n'
|
'• الامتحانات المنجزة: ${selectedChild.examsPassed} من ${selectedChild.examsTotal}\n'
|
||||||
'• دفتر الأخطاء: تم إتقان 8 فجوات بنجاح\n'
|
'• دفتر الأخطاء والشفاء: ${selectedChild.errorMasteredCount} من ${selectedChild.errorTotalCount} فجوة تم شفاؤها\n\n'
|
||||||
'• نتيجة الامتحان الموحد الأخير: 88%\n\n'
|
'تم إرسال هذا التقرير الموثق عبر بوابة نبيه للواتساب لهاتف ولي الأمر.',
|
||||||
'تم إرسال هذا التقرير عبر بوابة نبيه للواتساب برقم هاتف ولي الأمر.',
|
|
||||||
textAlign: TextAlign.start,
|
textAlign: TextAlign.start,
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
|
|||||||
+69
-10
@@ -7,7 +7,16 @@ import '../../../data/repositories/error_notebook_repository.dart';
|
|||||||
/// SAQEL ENTERPRISE - SMART ERROR NOTEBOOK & ADAPTIVE REMEDIATION SCREEN
|
/// SAQEL ENTERPRISE - SMART ERROR NOTEBOOK & ADAPTIVE REMEDIATION SCREEN
|
||||||
/// ==============================================================================
|
/// ==============================================================================
|
||||||
class SmartErrorNotebookScreen extends StatefulWidget {
|
class SmartErrorNotebookScreen extends StatefulWidget {
|
||||||
const SmartErrorNotebookScreen({super.key});
|
final bool isGuardianMode;
|
||||||
|
final String? studentName;
|
||||||
|
final int? studentId;
|
||||||
|
|
||||||
|
const SmartErrorNotebookScreen({
|
||||||
|
super.key,
|
||||||
|
this.isGuardianMode = false,
|
||||||
|
this.studentName,
|
||||||
|
this.studentId,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SmartErrorNotebookScreen> createState() =>
|
State<SmartErrorNotebookScreen> createState() =>
|
||||||
@@ -33,7 +42,9 @@ class _SmartErrorNotebookScreenState extends State<SmartErrorNotebookScreen> {
|
|||||||
|
|
||||||
Future<void> _loadNotebookData() async {
|
Future<void> _loadNotebookData() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
final data = await _repository.getErrorNotebook();
|
final data = (widget.isGuardianMode && widget.studentId != null)
|
||||||
|
? await _repository.getChildErrorNotebook(widget.studentId!)
|
||||||
|
: await _repository.getErrorNotebook();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_summary = data['summary'] as ErrorNotebookSummary?;
|
_summary = data['summary'] as ErrorNotebookSummary?;
|
||||||
@@ -110,19 +121,43 @@ class _SmartErrorNotebookScreenState extends State<SmartErrorNotebookScreen> {
|
|||||||
icon: const Icon(CupertinoIcons.back, color: Colors.white),
|
icon: const Icon(CupertinoIcons.back, color: Colors.white),
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
),
|
),
|
||||||
title: const Row(
|
title: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(CupertinoIcons.book_circle_fill,
|
const Icon(CupertinoIcons.book_circle_fill,
|
||||||
color: Color(0xFF38BDF8), size: 22),
|
color: Color(0xFF38BDF8), size: 22),
|
||||||
SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Expanded(
|
||||||
'دفتر الأخطاء الذكي والمسار العلاجي',
|
child: Text(
|
||||||
style: TextStyle(
|
widget.isGuardianMode
|
||||||
fontSize: 16.5,
|
? 'دفتر أخطاء الطالب: ${widget.studentName ?? "المسجل"}'
|
||||||
|
: 'دفتر الأخطاء الذكي والمسار العلاجي',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
if (widget.isGuardianMode) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF59E0B).withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
border: Border.all(color: const Color(0xFFF59E0B).withOpacity(0.5)),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'رقابة الأهل 👨👧👦',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(0xFFFBBF24),
|
||||||
|
fontSize: 10.5,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
@@ -593,7 +628,31 @@ class _SmartErrorNotebookScreenState extends State<SmartErrorNotebookScreen> {
|
|||||||
|
|
||||||
// Action Button
|
// Action Button
|
||||||
if (!item.isMastered)
|
if (!item.isMastered)
|
||||||
ElevatedButton.icon(
|
widget.isGuardianMode
|
||||||
|
? Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF0284C7).withOpacity(0.12),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: const Color(0xFF0284C7).withOpacity(0.3)),
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(CupertinoIcons.lock_shield, color: Color(0xFF38BDF8), size: 16),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'مسار علاجي تفريدي ينجزه الطالب في حسابه لسد الفجوة 🎯',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Color(0xFF38BDF8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ElevatedButton.icon(
|
||||||
onPressed: () => _startRemediation(item),
|
onPressed: () => _startRemediation(item),
|
||||||
icon: const Icon(CupertinoIcons.bolt_horizontal_circle_fill, size: 17),
|
icon: const Icon(CupertinoIcons.bolt_horizontal_circle_fill, size: 17),
|
||||||
label: const Text(
|
label: const Text(
|
||||||
|
|||||||
+688
@@ -0,0 +1,688 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../data/models/subject_model.dart';
|
||||||
|
import '../../widgets/luxury_widgets.dart';
|
||||||
|
|
||||||
|
/// Modal bottom sheet implementing the 60-second Thinking Pause and scaffolded step-by-step solution builder.
|
||||||
|
/// Pedagogy: Enforces student mental construction before revealing steps, eliminating passive learning.
|
||||||
|
class ScaffoldedThinkingPauseSheet extends StatefulWidget {
|
||||||
|
final CurriculumLessonItemModel lesson;
|
||||||
|
final SubjectModel? subject;
|
||||||
|
final VoidCallback? onCompleted;
|
||||||
|
|
||||||
|
const ScaffoldedThinkingPauseSheet({
|
||||||
|
super.key,
|
||||||
|
required this.lesson,
|
||||||
|
this.subject,
|
||||||
|
this.onCompleted,
|
||||||
|
});
|
||||||
|
|
||||||
|
static Future<void> show(
|
||||||
|
BuildContext context, {
|
||||||
|
required CurriculumLessonItemModel lesson,
|
||||||
|
SubjectModel? subject,
|
||||||
|
VoidCallback? onCompleted,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (ctx) => ScaffoldedThinkingPauseSheet(
|
||||||
|
lesson: lesson,
|
||||||
|
subject: subject,
|
||||||
|
onCompleted: onCompleted,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ScaffoldedThinkingPauseSheet> createState() =>
|
||||||
|
_ScaffoldedThinkingPauseSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScaffoldedThinkingPauseSheetState
|
||||||
|
extends State<ScaffoldedThinkingPauseSheet> {
|
||||||
|
int _secondsRemaining = 60;
|
||||||
|
Timer? _countdownTimer;
|
||||||
|
bool _thinkingPhaseActive = true;
|
||||||
|
int _revealedStep = 0; // 0: None, 1: Step 1, 2: Step 2, 3: Step 3, 4: Complete
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_startTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startTimer() {
|
||||||
|
_countdownTimer?.cancel();
|
||||||
|
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (_secondsRemaining > 0) {
|
||||||
|
setState(() => _secondsRemaining--);
|
||||||
|
} else {
|
||||||
|
timer.cancel();
|
||||||
|
setState(() {
|
||||||
|
_thinkingPhaseActive = false;
|
||||||
|
if (_revealedStep == 0) _revealedStep = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _skipThinkingPhase() {
|
||||||
|
_countdownTimer?.cancel();
|
||||||
|
setState(() {
|
||||||
|
_secondsRemaining = 0;
|
||||||
|
_thinkingPhaseActive = false;
|
||||||
|
if (_revealedStep == 0) _revealedStep = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _revealNextStep() {
|
||||||
|
if (_revealedStep < 4) {
|
||||||
|
setState(() => _revealedStep++);
|
||||||
|
if (_revealedStep == 4) {
|
||||||
|
widget.onCompleted?.call();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_countdownTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isMath = (widget.subject?.id ?? '').contains('math') ||
|
||||||
|
widget.lesson.title.contains('معادل') ||
|
||||||
|
widget.lesson.title.contains('رياضيات');
|
||||||
|
final isPhysics = (widget.subject?.id ?? '').contains('physic') ||
|
||||||
|
widget.lesson.title.contains('حركة') ||
|
||||||
|
widget.lesson.title.contains('متجه');
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxHeight: MediaQuery.of(context).size.height * 0.88,
|
||||||
|
),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.darkBackground,
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(26)),
|
||||||
|
border: Border(
|
||||||
|
top: BorderSide(color: AppColors.saqelCyan, width: 1.8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Drag Handle
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(top: 12, bottom: 8),
|
||||||
|
width: 44,
|
||||||
|
height: 5,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white24,
|
||||||
|
borderRadius: BorderRadius.circular(3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Top Header Bar
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.guardianAmber.withAlpha(30),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
CupertinoIcons.timer,
|
||||||
|
color: AppColors.guardianAmber,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'وقفة تفكير وبناء الحل خطوة بخطوة ⏱️',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
widget.lesson.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.textSecondaryDark,
|
||||||
|
fontSize: 11.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(CupertinoIcons.xmark_circle_fill,
|
||||||
|
color: Colors.white38, size: 24),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const Divider(color: AppColors.darkCardBorder, height: 1),
|
||||||
|
|
||||||
|
// Scrollable Content
|
||||||
|
Expanded(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
children: [
|
||||||
|
// Problem statement box
|
||||||
|
_buildProblemCard(isMath, isPhysics),
|
||||||
|
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
|
||||||
|
// 60-second Thinking Timer or Step Construction view
|
||||||
|
if (_thinkingPhaseActive)
|
||||||
|
_buildThinkingTimerCard()
|
||||||
|
else
|
||||||
|
_buildStepByStepSolutionCard(isMath, isPhysics),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Bottom Action Bar
|
||||||
|
_buildBottomActionBar(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProblemCard(bool isMath, bool isPhysics) {
|
||||||
|
String title;
|
||||||
|
String problemBody;
|
||||||
|
String subHint;
|
||||||
|
|
||||||
|
if (isMath) {
|
||||||
|
title = 'المسألة النموذجية الوزارية (حل نظام معادلتين):';
|
||||||
|
problemBody = 'المعادلة (1) الخطية: y - x = 1\n'
|
||||||
|
'المعادلة (2) التربيعية: x² + y² = 13\n\n'
|
||||||
|
'المطلوب: جد مجموعة حل النظام بيانياً وجبرياً.';
|
||||||
|
subHint = 'تلميح ذهني: ابدأ بعزل المتغير y في المعادلة الخطية أولاً، ثم عوضه في التربيعية.';
|
||||||
|
} else if (isPhysics) {
|
||||||
|
title = 'المسألة النموذجية الوزارية (حركة المقذوفات في بعدين):';
|
||||||
|
problemBody = 'أُطلقت قذيفة بسرعة ابتدائية v₀ = 50 m/s وبزاوية θ = 37° مع الأفق.\n'
|
||||||
|
'بإهمال مقاومة الهواء واعتبار التسارع g = 10 m/s²:\n\n'
|
||||||
|
'المطلوب: جد المركبتين الأفقية والعمودية للسرعة، وأقصى ارتفاع تصل إليه القذيفة.';
|
||||||
|
subHint = 'تلميح ذهني: حلل السرعة إلى vx و vy أولاً، وتذكر أن السرعة العمودية عند أقصى ارتفاع تساوي صفراً.';
|
||||||
|
} else {
|
||||||
|
title = 'السؤال النموذجي التطبيقي للدرس:';
|
||||||
|
problemBody = 'حلل مفهوم «${widget.lesson.title}» وفق القواعد والمعايير المعتمدة في المنهاج الوزاري.\n\n'
|
||||||
|
'المطلوب: صغ خطوات الإثبات والتحليل مع تقديم الدليل والمثال التوضيحي.';
|
||||||
|
subHint = 'تلميح ذهني: اربط القاعدة بأركانها الأساسية ونفذ شروط التحقق المنهجي.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return LuxuryCard(
|
||||||
|
borderColor: AppColors.saqelCyan.withAlpha(70),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(CupertinoIcons.doc_text_search,
|
||||||
|
color: AppColors.saqelCyan, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.saqelCyan,
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF07101E),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.white12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
problemBody,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 13.5,
|
||||||
|
height: 1.55,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
subHint,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.guardianAmber,
|
||||||
|
fontSize: 11.5,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildThinkingTimerCard() {
|
||||||
|
final progress = (60 - _secondsRemaining) / 60.0;
|
||||||
|
|
||||||
|
return LuxuryCard(
|
||||||
|
borderColor: AppColors.guardianAmber.withAlpha(90),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Circular countdown display
|
||||||
|
Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 96,
|
||||||
|
height: 96,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
value: progress,
|
||||||
|
strokeWidth: 6,
|
||||||
|
backgroundColor: Colors.white12,
|
||||||
|
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||||
|
AppColors.guardianAmber),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'$_secondsRemaining',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
fontFamily: 'SF Pro Text',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
'ثانية',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.textSecondaryDark,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'وقفة تأمل ذهني مستقل (60 ثانية) 🧠',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'لا تبدأ بكتابة الحل فوراً! تدرب على استراتيجية الفهم أولاً:\n'
|
||||||
|
'• ما المتغيرات المتاحة وما نوع كل معادلة؟\n'
|
||||||
|
'• أي طرف هو الأيسر للعزل الرياضي؟\n'
|
||||||
|
'• توقع عدد الحلول الممكنة هندسياً قبل الحساب.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.textSecondaryDark,
|
||||||
|
fontSize: 12.5,
|
||||||
|
height: 1.55,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
icon: const Icon(CupertinoIcons.play_circle_fill, size: 18),
|
||||||
|
label: const Text('أنا جاهز، ابدأ بناء الحل خطوة بخطوة 🚀'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.appleBlue,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14)),
|
||||||
|
elevation: 4,
|
||||||
|
),
|
||||||
|
onPressed: _skipThinkingPhase,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStepByStepSolutionCard(bool isMath, bool isPhysics) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'بناء الحل التراكمي خطوة بخطوة 📐',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.saqelCyan.withAlpha(25),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: AppColors.saqelCyan.withAlpha(60)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'الخطوة $_revealedStep من 4',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.saqelCyan,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
|
||||||
|
// Step 1
|
||||||
|
_buildStepTile(
|
||||||
|
stepNum: 1,
|
||||||
|
title: isMath
|
||||||
|
? 'الخطوة 1: عزل المتغير في المعادلة الخطية'
|
||||||
|
: (isPhysics
|
||||||
|
? 'الخطوة 1: تحليل السرعة الابتدائية إلى مركبتين'
|
||||||
|
: 'الخطوة 1: تحديد المعطيات وضبط الأركان'),
|
||||||
|
detail: isMath
|
||||||
|
? 'من المعادلة (1): y - x = 1 ⟹ y = x + 1\nتم جعل y موضوعاً للقانون لتسهيل التعويض.'
|
||||||
|
: (isPhysics
|
||||||
|
? 'v₀x = v₀ cos(37°) = 50 × 0.8 = 40 m/s (سرعة أفقية ثابتة)\n'
|
||||||
|
'v₀y = v₀ sin(37°) = 50 × 0.6 = 30 m/s (سرعة رأسية ابتدائية)'
|
||||||
|
: 'حصر المتغيرات والشروط الحاكمة للدرس وفق المنهج الوزاري.'),
|
||||||
|
isRevealed: _revealedStep >= 1,
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Step 2
|
||||||
|
_buildStepTile(
|
||||||
|
stepNum: 2,
|
||||||
|
title: isMath
|
||||||
|
? 'الخطوة 2: التعويض في المعادلة التربيعية وتصفيرها'
|
||||||
|
: (isPhysics
|
||||||
|
? 'الخطوة 2: تطبيق معادلة الحركة الرأسية عند الذروة'
|
||||||
|
: 'الخطوة 2: تطبيق القاعدة المنهجية المباشرة'),
|
||||||
|
detail: isMath
|
||||||
|
? 'نعوض y = x + 1 في المعادلة (2):\nx² + (x + 1)² = 13\nx² + (x² + 2x + 1) = 13\n2x² + 2x + 1 - 13 = 0 ⟹ 2x² + 2x - 12 = 0\nبالقسمة على 2: x² + x - 6 = 0'
|
||||||
|
: (isPhysics
|
||||||
|
? 'عند أقصى ارتفاع: vy = 0\n'
|
||||||
|
'نطبق: vy² = v₀y² - 2g(h_max)\n'
|
||||||
|
'0 = (30)² - 2(10)(h_max)\n'
|
||||||
|
'0 = 900 - 20(h_max) ⟹ 20(h_max) = 900 ⟹ h_max = 45 m'
|
||||||
|
: 'إجراء المقارنة النحوية أو الاستنتاج العلمي بناءً على الأركان السابقة.'),
|
||||||
|
isRevealed: _revealedStep >= 2,
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Step 3
|
||||||
|
_buildStepTile(
|
||||||
|
stepNum: 3,
|
||||||
|
title: isMath
|
||||||
|
? 'الخطوة 3: حساب المميز والتحليل إلى العوامل'
|
||||||
|
: (isPhysics
|
||||||
|
? 'الخطوة 3: حساب زمن الصعود وزمن التحليق الكلي'
|
||||||
|
: 'الخطوة 3: استخراج الحكم المنهجي النهائي'),
|
||||||
|
detail: isMath
|
||||||
|
? 'المميز الجبري: Δ = b² - 4ac = (1)² - 4(1)(-6) = 1 + 24 = 25 > 0 (يوجد حلان حقيقيان)\n'
|
||||||
|
'تحليل العبارة التربيعية:\n(x + 3)(x - 2) = 0\n'
|
||||||
|
'إما x = 2 أو x = -3'
|
||||||
|
: (isPhysics
|
||||||
|
? 'vy = v₀y - gt ⟹ 0 = 30 - 10t_up ⟹ t_up = 3 s\n'
|
||||||
|
'زمن التحليق الكلي: T_total = 2 × t_up = 2 × 3 = 6 s'
|
||||||
|
: 'التأكد من خلو الحل من التناقضات وتوافق الشروط الوزارية.'),
|
||||||
|
isRevealed: _revealedStep >= 3,
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Step 4
|
||||||
|
_buildStepTile(
|
||||||
|
stepNum: 4,
|
||||||
|
title: isMath
|
||||||
|
? 'الخطوة 4: إيجاد الأزواج المرتبة والتحقق البياني'
|
||||||
|
: (isPhysics
|
||||||
|
? 'الخطوة 4: حساب المدى الأفقي الكلي والتحقق'
|
||||||
|
: 'الخطوة 4: توثيق النتيجة ونموذج الإجابة النموذجية'),
|
||||||
|
detail: isMath
|
||||||
|
? 'عند x = 2: y = 2 + 1 = 3 ⟹ النقطة (2, 3)\n'
|
||||||
|
'عند x = -3: y = -3 + 1 = -2 ⟹ النقطة (-3, -2)\n\n'
|
||||||
|
'مجموعة حل النظام: {(2, 3), (-3, -2)}\n'
|
||||||
|
'التحقق: 2² + 3² = 4 + 9 = 13 ✔ (تم التحقق جبرياً وهندسياً بنجاح)'
|
||||||
|
: (isPhysics
|
||||||
|
? 'المدى الأفقي: R = vx × T_total = 40 × 6 = 240 m\n'
|
||||||
|
'القذيفة تقطع 240 متراً أفقياً وتصل ارتفاعاً قدره 45 متراً ✔'
|
||||||
|
: 'تم صياغة الجواب النموذجي المعتمد بالكامل مع الإثبات الوزاري ✔'),
|
||||||
|
isRevealed: _revealedStep >= 4,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStepTile({
|
||||||
|
required int stepNum,
|
||||||
|
required String title,
|
||||||
|
required String detail,
|
||||||
|
required bool isRevealed,
|
||||||
|
}) {
|
||||||
|
if (!isRevealed) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black26,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: Colors.white10),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white12,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'$stepNum',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white38,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'الخطوة $stepNum: قيد الإنشاء الذهني (اضغط لإظهار الخطوة)',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white38,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(CupertinoIcons.lock_fill,
|
||||||
|
color: Colors.white24, size: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return LuxuryCard(
|
||||||
|
borderColor: AppColors.saqelCyan.withAlpha(90),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.saqelCyan.withAlpha(30),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: AppColors.saqelCyan),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'$stepNum',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.saqelCyan,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w800),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(CupertinoIcons.checkmark_circle_fill,
|
||||||
|
color: AppColors.emeraldGreen, size: 18),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF07101E),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: Colors.white12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
detail,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 12.5,
|
||||||
|
height: 1.55,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBottomActionBar() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.darkSurface,
|
||||||
|
border: Border(top: BorderSide(color: AppColors.darkCardBorder)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (_thinkingPhaseActive) ...[
|
||||||
|
Expanded(
|
||||||
|
child: OutlinedButton(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: const BorderSide(color: Colors.white24),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('إغلاق والعودة للفيديو',
|
||||||
|
style: TextStyle(color: Colors.white70, fontSize: 13)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else if (_revealedStep < 4) ...[
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
icon: const Icon(CupertinoIcons.arrow_down_circle_fill,
|
||||||
|
size: 18),
|
||||||
|
label: Text(
|
||||||
|
'اكشف الخطوة التالية (${_revealedStep + 1} من 4) ⬇️'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.saqelCyan,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12)),
|
||||||
|
elevation: 2,
|
||||||
|
),
|
||||||
|
onPressed: _revealNextStep,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
icon: const Icon(CupertinoIcons.checkmark_seal_fill, size: 18),
|
||||||
|
label: const Text('اكتمل بناء الحل المنهجي بنجاح! استمر 🎯'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.emeraldGreen,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12)),
|
||||||
|
elevation: 2,
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ import '../../widgets/socratic_dialog.dart';
|
|||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
import 'package:flutter_tts/flutter_tts.dart';
|
import 'package:flutter_tts/flutter_tts.dart';
|
||||||
import '../curriculum/math_interactive_lab_view.dart';
|
import '../curriculum/math_interactive_lab_view.dart';
|
||||||
|
import 'scaffolded_thinking_pause_sheet.dart';
|
||||||
|
|
||||||
/// مشغل الفيديو السقراطي الذكي ونقاط الفحص والإرجاع العلاجي
|
/// مشغل الفيديو السقراطي الذكي ونقاط الفحص والإرجاع العلاجي
|
||||||
class SocraticVideoPlayerScreen extends StatefulWidget {
|
class SocraticVideoPlayerScreen extends StatefulWidget {
|
||||||
@@ -717,6 +718,19 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'وقفة تفكير وبناء الحل خطوة بخطوة',
|
||||||
|
icon: const Icon(CupertinoIcons.timer, color: AppColors.guardianAmber, size: 20),
|
||||||
|
onPressed: () {
|
||||||
|
context.read<VideoPlaybackCubit>().pause();
|
||||||
|
ScaffoldedThinkingPauseSheet.show(
|
||||||
|
context,
|
||||||
|
lesson: widget.lesson,
|
||||||
|
subject: widget.subject,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -844,6 +858,51 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// Thinking Pause & Scaffolded Solution Construction Card
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
context.read<VideoPlaybackCubit>().pause();
|
||||||
|
ScaffoldedThinkingPauseSheet.show(
|
||||||
|
context,
|
||||||
|
lesson: widget.lesson,
|
||||||
|
subject: widget.subject,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: LuxuryCard(
|
||||||
|
borderColor: AppColors.guardianAmber.withAlpha(90),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.guardianAmber.withAlpha(25),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: const Icon(CupertinoIcons.timer, color: AppColors.guardianAmber, size: 24),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
const Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'وقفة تفكير ذهني وبناء الحل خطوة بخطوة ⏱️',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 13.5, fontWeight: FontWeight.w800),
|
||||||
|
),
|
||||||
|
SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
'تأمل المسألة لمدة 60 ثانية قبل كشف خطوات الإثبات والحل المنهجي.',
|
||||||
|
style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 11.5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(CupertinoIcons.chevron_left, color: AppColors.guardianAmber, size: 18),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Interactive Digital Chalkboard & Key Concept Breakdown
|
// Interactive Digital Chalkboard & Key Concept Breakdown
|
||||||
@@ -1025,6 +1084,23 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
icon: const Icon(CupertinoIcons.timer, size: 16, color: AppColors.guardianAmber),
|
||||||
|
label: const Text('وقفة تفكير وبناء الحل (60 ثانية) ⏱️', style: TextStyle(color: AppColors.guardianAmber, fontSize: 12, fontWeight: FontWeight.w700)),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
side: const BorderSide(color: AppColors.guardianAmber),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
context.read<VideoPlaybackCubit>().pause();
|
||||||
|
ScaffoldedThinkingPauseSheet.show(
|
||||||
|
context,
|
||||||
|
lesson: widget.lesson,
|
||||||
|
subject: widget.subject,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
if (isMath)
|
if (isMath)
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
icon: const Icon(CupertinoIcons.function, size: 16, color: AppColors.saqelCyan),
|
icon: const Icon(CupertinoIcons.function, size: 16, color: AppColors.saqelCyan),
|
||||||
|
|||||||
@@ -0,0 +1,665 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'lab_identity.dart';
|
||||||
|
import 'lab_scaffold.dart';
|
||||||
|
|
||||||
|
/// ============================================================================
|
||||||
|
/// ARABIC — BUILD MY LANGUAGE (2): THE REQUESTIVE CONSTRUCTION (أسلوبُ
|
||||||
|
/// الإنشاءِ الطّلبيِّ — الإنشاءُ الطلبيُّ).
|
||||||
|
/// Lesson: الدرس السادس — أبني لغتي (2): الأسلوبُ الإنشائيّ (الإنشاءُ
|
||||||
|
/// الطلبيُّ). Source: grade_10/arabic_10/semester_1/unit_02/lesson_06.md
|
||||||
|
/// (pages 56-59).
|
||||||
|
/// ----------------------------------------------------------------------------
|
||||||
|
/// Built ONLY on extractable facts from the source pages:
|
||||||
|
/// - مفهومُ الإنشاءِ الطّلبيِّ (ص56-57): كلامٌ لا يحتملُ التّصديقَ أو
|
||||||
|
/// التّكذيبَ؛ يرادُ بهِ طلبُ حصولِ أمرٍ لم يتحقَّقْ وقتَ الطّلبِ.
|
||||||
|
/// - الإنشاءُ الطلبيُّ يطلبُ حصولَ شيءٍ لم يقعْ بَعْدُ؛ ولا يُحكَمُ عليهِ
|
||||||
|
/// بصحّةٍ أو بطلانٍ.
|
||||||
|
/// - أنواعُ الإنشاءِ الطّلبيِّ الستّةُ (ص56-57): النداءُ، والأمرُ،
|
||||||
|
/// والنهيُ، والاستفهامُ، والتّمنّي، والتّرجّي.
|
||||||
|
/// - أدواتُه (ص56-57): حروفُ النداءِ (يا وأخواتُها) للنداءِ؛ فعلُ الأمرِ
|
||||||
|
/// أو المضارعُ المقترنُ بلامِ الأمرِ للأمرِ؛ (لا) النّاهيةُ للنهيِ؛
|
||||||
|
/// أدواتُ الاستفهامِ (هل/كيف/متى/أين...) للاستفهامِ؛ (ليت) للتّمنّي؛
|
||||||
|
/// (لعل) للتّرجّي.
|
||||||
|
/// - أمثلةُ الدرسِ (ص57-59): ﴿يا شُعيبُ أَصَلاتُكَ تَأْمُرُكَ...﴾
|
||||||
|
/// (سورة هود 87) — نداءٌ + استفهامٌ؛ «ألا ليتَ شِعري هَل أبِيتُ
|
||||||
|
/// ليلةً...» (الفرزدقُ) — تمنٍّ + استفهامٌ؛ «يا أَيّها النّاسُ اتّقوا
|
||||||
|
/// ربَّكم» — نداءُ المعرَّفِ بـ(ال) بـ(أيّها)؛ «لا تَحسِبِ المَجْدَ
|
||||||
|
/// تَمْرًا أنتَ آكِلُهُ، لن تَبلُغَ المَجْدَ حتى تَلعَقَ الصَّبِرا»
|
||||||
|
/// (أبو العلاءِ المعرّي) — نهيٌ بـ(لا النّاهيةِ)؛ «السلامُ عليكم
|
||||||
|
/// دارَ قومٍ مؤمنينَ» — نداءٌ مقدَّرًا حرفُه (يا دارَ قومٍ)؛
|
||||||
|
/// (اللّهُمَّ) عُوِّضَ حرفُ النداءِ فيهِ بميمٍ مشدّدةٍ مبدَلٍ منَ حرفِ
|
||||||
|
/// النداءِ المحذوفِ.
|
||||||
|
/// ============================================================================
|
||||||
|
|
||||||
|
// مفهومُ الإنشاءِ الطّلبيِّ (صواب/خطأ).
|
||||||
|
const List<(String, bool)> _requestStatements = [
|
||||||
|
(
|
||||||
|
'الإنشاءُ الطلبيُّ كلامٌ لا يحتملُ التّصديقَ أو التّكذيبَ',
|
||||||
|
true
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'من صيغِ الإنشاءِ الطّلبيِّ: النداءُ، والأمرُ، والنهيُ، والاستفهامُ، والتّمنّي، والتّرجّي',
|
||||||
|
true
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'الأمرُ يكونُ بفعلِ الأمرِ أو بالمضارعِ المقترنِ بلامِ الأمرِ',
|
||||||
|
true
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'النهيُ يكونُ بـ(لا) النّاهيةِ قبلَ الفعلِ المضارعِ',
|
||||||
|
true
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'الاستفهامُ يكونُ بأدواتِ الاستفهامِ مثلَ: (هل، كيف، متى، أين)',
|
||||||
|
true
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'التّمنّي يكونُ بـ(ليت) والتّرجّي بـ(لعل)',
|
||||||
|
true
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'النداءُ يكونُ بحرفِ النداءِ وحدَهُ دونَ المنادى',
|
||||||
|
false
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'الإنشاءُ الطلبيُّ كلامٌ خبريٌّ يرادُ منهُ إخبارُ المخاطَبِ بشيءٍ وقعَ فعلًا',
|
||||||
|
false
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
// أنواعُ الإنشاءِ الطّلبيِّ معَ مثالِهِ منَ الدرسِ.
|
||||||
|
const List<(String, String, String, int)> _typesRows = [
|
||||||
|
(
|
||||||
|
'النداءُ',
|
||||||
|
'حرفُ النداءِ (يا وأخواتُها) ثمّ المنادى.',
|
||||||
|
'يا شُعيبُ أَصلاةُكَ تأمرُكَ...',
|
||||||
|
0
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'الأمرُ',
|
||||||
|
'فعلُ الأمرِ أو المضارعُ بلامِ الأمرِ.',
|
||||||
|
'اقرأْ باسمِ ربِّكَ الّذي خَلَقَ',
|
||||||
|
1
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'النهيُ',
|
||||||
|
'(لا) النّاهيةُ قبلَ المضارعِ.',
|
||||||
|
'لا تَحسِبِ المَجْدَ تَمْرًا أنتَ آكِلُهُ',
|
||||||
|
2
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'الاستفهامُ',
|
||||||
|
'أدواتُ الاستفهامِ (هل/كيف/متى/أين).',
|
||||||
|
'هَل أبِيتُ ليلةً...',
|
||||||
|
3
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'التّمنّي',
|
||||||
|
'(ليت).',
|
||||||
|
'ألا ليتَ شِعري هَل أبِيتُ...',
|
||||||
|
4
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'التّرجّي',
|
||||||
|
'(لعل).',
|
||||||
|
'لعلَّ اللهَ يَفرِّجُ عنّي',
|
||||||
|
5
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
// أمثلةُ توظيفِ الدرسِ (ص57-59) — اختيارُ النّوعِ الصّحيحِ.
|
||||||
|
const List<(String, String, int)> _situationalStatements = [
|
||||||
|
(
|
||||||
|
'يا أَيّها النّاسُ اتّقوا ربَّكم',
|
||||||
|
'النداءُ',
|
||||||
|
0
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'اقرأْ باسمِ ربِّكَ الّذي خَلَقَ',
|
||||||
|
'الأمرُ',
|
||||||
|
1
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'لا تَحسِبِ المَجْدَ تَمْرًا أنتَ آكِلُهُ',
|
||||||
|
'النهيُ',
|
||||||
|
2
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'هَل أبِيتُ ليلةً بِبَثنَةَ ليلةً',
|
||||||
|
'الاستفهامُ',
|
||||||
|
3
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'ألا ليتَ شِعري',
|
||||||
|
'التّمنّي',
|
||||||
|
4
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'لعلَّ اللهَ يَفرِّجُ عنّي',
|
||||||
|
'التّرجّي',
|
||||||
|
5
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
class ArabicUnit2InshaLabView extends StatefulWidget {
|
||||||
|
final LabCheckpointCallback? onCheckpointTriggered;
|
||||||
|
|
||||||
|
const ArabicUnit2InshaLabView({super.key, this.onCheckpointTriggered});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ArabicUnit2InshaLabView> createState() =>
|
||||||
|
_ArabicUnit2InshaLabViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ArabicUnit2InshaLabViewState extends State<ArabicUnit2InshaLabView> {
|
||||||
|
int _activity = 0;
|
||||||
|
|
||||||
|
// Activity 0 — مفهومُ الإنشاءِ الطّلبيِّ.
|
||||||
|
final List<bool> _request = List.filled(_requestStatements.length, false);
|
||||||
|
bool _requestTouched = false;
|
||||||
|
|
||||||
|
// Activity 1 — أدواتُ أنواعِ الإنشاءِ الطّلبيِّ.
|
||||||
|
final List<int> _selected = List.filled(_typesRows.length, -1);
|
||||||
|
bool _typesTouched = false;
|
||||||
|
|
||||||
|
// Activity 2 — توظيفُ الإنشاءِ الطّلبيِّ في أمثلةِ الدرسِ.
|
||||||
|
final List<int> _situational = List.filled(_situationalStatements.length, -1);
|
||||||
|
bool _situationalTouched = false;
|
||||||
|
|
||||||
|
bool get _requestDone {
|
||||||
|
for (var i = 0; i < _requestStatements.length; i++) {
|
||||||
|
if (_request[i] != _requestStatements[i].$2) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _typesDone {
|
||||||
|
if (!_typesTouched) return false;
|
||||||
|
for (var i = 0; i < _typesRows.length; i++) {
|
||||||
|
if (_selected[i] != _typesRows[i].$4) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _situationalDone {
|
||||||
|
if (!_situationalTouched) return false;
|
||||||
|
for (var i = 0; i < _situationalStatements.length; i++) {
|
||||||
|
if (_situational[i] != _situationalStatements[i].$3) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _done {
|
||||||
|
if (_activity == 0) return _requestDone && _requestTouched;
|
||||||
|
if (_activity == 1) return _typesDone;
|
||||||
|
return _situationalDone;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCanvas() {
|
||||||
|
switch (_activity) {
|
||||||
|
case 0:
|
||||||
|
return CustomPaint(
|
||||||
|
painter: _RequestConstructionPainter(
|
||||||
|
checks: List.of(_request), done: _requestDone),
|
||||||
|
size: Size.infinite,
|
||||||
|
);
|
||||||
|
case 1:
|
||||||
|
return CustomPaint(
|
||||||
|
painter: _RequestTypesPainter(
|
||||||
|
selected: List.of(_selected), done: _typesDone),
|
||||||
|
size: Size.infinite,
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return CustomPaint(
|
||||||
|
painter: _RequestSituationalPainter(
|
||||||
|
picks: List.of(_situational), done: _situationalDone),
|
||||||
|
size: Size.infinite,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildControls() {
|
||||||
|
final c = <Widget>[
|
||||||
|
LabSegments<int>(
|
||||||
|
labels: const ['مفهومُ الإنشاءِ', 'أنواعُ الإنشاءِ', 'توظيفُ الإنشاءِ'],
|
||||||
|
values: const [0, 1, 2],
|
||||||
|
current: _activity,
|
||||||
|
onSelected: (v) => setState(() {
|
||||||
|
_activity = v;
|
||||||
|
saqelTick();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (_activity == 0) {
|
||||||
|
c.addAll([
|
||||||
|
const Text(
|
||||||
|
'أُعيِّنُ (صوابًا أو خطأً) على جُمَلِ مفهومِ الإنشاءِ الطّلبيِّ وصيغِهِ.',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 12.5, height: 1.6),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
for (var i = 0; i < _requestStatements.length; i++)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: LabToggle(
|
||||||
|
label: _requestStatements[i].$1,
|
||||||
|
value: _request[i],
|
||||||
|
onChanged: (v) => setState(() {
|
||||||
|
_request[i] = v;
|
||||||
|
_requestTouched = true;
|
||||||
|
if (_requestDone) saqelTick();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_requestDone && _requestTouched)
|
||||||
|
const LabPill(
|
||||||
|
'مفهومُ الإنشاءِ الطّلبيِّ مثبَّتٌ: كلامٌ لا يحتملُ التّصديقَ أو التّكذيبَ، يطلبُ حصولَ شيءٍ لم يقعْ بَعْدُ؛ والمرادُ: طلبُ حصولِ أمرٍ غيرِ متحقِّقٍ، لا إخبارٌ عمّا وقعْ ✓',
|
||||||
|
color: Color(0xFF30D158))
|
||||||
|
else
|
||||||
|
const LabPill(
|
||||||
|
'الصوابُ: الإنشاءُ الطلبيُّ كلامٌ لا يحتملُ التّصديقَ أو التّكذيبَ، يطلبُ حصولَ أمرٍ لم يتحقَّقْ وقتَ الطّلبِ (نداءٌ، أمرٌ، نهيٌ، استفهامٌ، تمنٍّ، ترجٍّ). الخطأُ: النداءُ بلا منادى، والخبريُّ الذي يرادُ بهِ إخبارُ المخاطَبِ بما وقعَ.',
|
||||||
|
color: Color(0xFFFF9F0A)),
|
||||||
|
]);
|
||||||
|
} else if (_activity == 1) {
|
||||||
|
c.addAll([
|
||||||
|
const Text(
|
||||||
|
'أُقرِّرُ لكلِّ نوعٍ من أنواعِ الإنشاءِ الطّلبيِّ أسلوبَهُ الصّحيحَ من خلالِ مثالِ الدرسِ.',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 12.5, height: 1.6),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
for (var r = 0; r < _typesRows.length; r++)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_typesRows[r].$1,
|
||||||
|
style: const TextStyle(color: Color(0xFF00F5D4), fontSize: 12.5),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: [
|
||||||
|
for (var t = 0; t < _typesRows.length; t++)
|
||||||
|
ChoiceChip(
|
||||||
|
label: Text(
|
||||||
|
_typesRows[t].$1,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 11),
|
||||||
|
),
|
||||||
|
selected: _selected[r] == t,
|
||||||
|
onSelected: (v) => setState(() {
|
||||||
|
_selected[r] = v ? t : -1;
|
||||||
|
_typesTouched = true;
|
||||||
|
if (_typesDone) saqelTick();
|
||||||
|
}),
|
||||||
|
selectedColor: const Color(0xFF00F5D4).withValues(alpha: 0.25),
|
||||||
|
backgroundColor: const Color(0x0DFFFFFF),
|
||||||
|
side: BorderSide(
|
||||||
|
color: _selected[r] == t
|
||||||
|
? const Color(0xFF00F5D4)
|
||||||
|
: Colors.white12,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_typesDone)
|
||||||
|
const LabPill(
|
||||||
|
'أدواتُ أنواعِ الإنشاءِ الطّلبيِّ مثبّتةٌ: النداءُ (يا وأخواتُها)، الأمرُ (فعلُ الأمرِ/لامُ الأمرِ)، النهيُ (لا النّاهيةُ)، الاستفهامُ (هل/كيف/متى/أين)، التّمنّي (ليت)، التّرجّي (لعل) ✓',
|
||||||
|
color: Color(0xFF30D158))
|
||||||
|
else
|
||||||
|
const LabPill(
|
||||||
|
'راجع: النداءُ يُبنى على حرفِ النداءِ والمنادى؛ الأمرُ بفعلِ الأمرِ؛ النهيُ بلا النّاهيةِ؛ الاستفهامُ بأدواتِهِ؛ التّمنّي بليت؛ والتّرجّي بلعل.',
|
||||||
|
color: Color(0xFFFF9F0A)),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
c.addAll([
|
||||||
|
const Text(
|
||||||
|
'أُحدِّدُ نوعَ الإنشاءِ الطّلبيِّ في كلِّ مثالٍ من أمثلةِ الدرسِ.',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 12.5, height: 1.6),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
for (var i = 0; i < _situationalStatements.length; i++)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_situationalStatements[i].$1,
|
||||||
|
style: const TextStyle(color: Colors.white70, fontSize: 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
SizedBox(
|
||||||
|
width: 150,
|
||||||
|
child: DropdownButtonFormField<int>(
|
||||||
|
initialValue: _situational[i] < 0 ? null : _situational[i],
|
||||||
|
dropdownColor: const Color(0xFF0B1728),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
filled: true,
|
||||||
|
fillColor: const Color(0x0DFFFFFF),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
for (var t = 0; t < _typesRows.length; t++)
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: t,
|
||||||
|
child: Text(
|
||||||
|
_typesRows[t].$1,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white, fontSize: 11),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (v) => setState(() {
|
||||||
|
_situational[i] = v!;
|
||||||
|
_situationalTouched = true;
|
||||||
|
if (_situationalDone) saqelTick();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_situationalDone)
|
||||||
|
const LabPill(
|
||||||
|
'توظيفُ الإنشاءِ الطّلبيِّ سليمٌ في أمثلةِ الدرسِ كلِّها ✓',
|
||||||
|
color: Color(0xFF30D158))
|
||||||
|
else
|
||||||
|
const LabPill(
|
||||||
|
'الصوابُ: (يا أَيّها النّاسُ) نداءٌ، (اقرأْ) أمرٌ، (لا تَحسِبْ) نهيٌ، (هل) استفهامٌ، (ليت) تمنٍّ، (لعل) ترجٍّ.',
|
||||||
|
color: Color(0xFFFF9F0A)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_done) {
|
||||||
|
c.addAll([
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
LabCheckpointButton(
|
||||||
|
question:
|
||||||
|
'لماذا لا يحتملُ الإنشاءُ الطلبيُّ التّصديقَ أو التّكذيبَ؟ وكيف نفرِّقُ بينَ الخبريِّ والإنشائيّ الطّلبيِّ؟',
|
||||||
|
options: const [
|
||||||
|
'لأنّهُ يطلبُ حصولَ أمرٍ لم يتحقَّقْ وقتَ الطّلبِ، فهوَ ليسَ خبرًا يُصحَّحُ أو يُكذَّبُ',
|
||||||
|
'لأنّهُ خبرٌ صادقٌ يقبلُ التّصديقَ أو التّكذيبَ',
|
||||||
|
'لأنّهُ طلبٌ لا يرتبطُ بحصولِ أمرٍ في المستقبلِ',
|
||||||
|
],
|
||||||
|
correctIdx: 0,
|
||||||
|
onCheckpointTriggered: widget.onCheckpointTriggered,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SaqelLabScaffold(
|
||||||
|
identity: kArabicUnit2InshaLabIdentity,
|
||||||
|
titleAr: 'مختبرُ الأسلوبِ الإنشائيّ الطّلبيِّ',
|
||||||
|
subtitleAr: 'أبني لغتي (2) — الإنشاءُ الطلبيُّ: طلبُ حصولِ أمرٍ لم يقعْ بَعْدُ، لا يحتملُ التّصديقَ أو التّكذيبَ.',
|
||||||
|
canvas: _buildCanvas(),
|
||||||
|
controls: _buildControls(),
|
||||||
|
footerNote:
|
||||||
|
'مصدر: صفحات 56-59. الإنشاءُ الطلبيُّ طلبُ أمرٍ غيرِ متحقِّقٍ وقتَ الطّلبِ، لا يحتملُ التّصديقَ أو التّكذيبَ، بخلافِ الخبريِّ. أنوعُه الستّةُ بأدواتِها وأمثلةُ الدرسِ (يا شُعيبُ، قرأْ، لا تَحسِبْ، هل، ليت، لعل) — قابلٌ للإثباتِ من ص56-59.',
|
||||||
|
checkpointQuestion:
|
||||||
|
'لماذا لا يحتملُ الإنشاءُ الطلبيُّ التّصديقَ أو التّكذيبَ؟',
|
||||||
|
checkpointOptions: const [
|
||||||
|
'لأنّهُ يطلبُ حصولَ أمرٍ لم يتحقَّقْ وقتَ الطّلبِ، فهوَ ليسَ خبرًا يُصحَّحُ أو يُكذَّبُ.',
|
||||||
|
'لأنّهُ خبرٌ صادقٌ يُقبلُ التّصديقَ أو التّكذيبَ.',
|
||||||
|
'لأنّهُ طلبٌ لا يرتبطُ بأمرٍ يُطلبُ.',
|
||||||
|
],
|
||||||
|
checkpointCorrectIdx: 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Canvas 0 — مفهومُ الإنشاءِ الطّلبيِّ.
|
||||||
|
// ============================================================================
|
||||||
|
class _RequestConstructionPainter extends CustomPainter {
|
||||||
|
final List<bool> checks;
|
||||||
|
final bool done;
|
||||||
|
|
||||||
|
const _RequestConstructionPainter({
|
||||||
|
required this.checks,
|
||||||
|
required this.done,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final w = size.width, h = size.height;
|
||||||
|
var lit = 0;
|
||||||
|
for (var i = 0; i < checks.length; i++) {
|
||||||
|
if (checks[i] == _requestStatements[i].$2) lit++;
|
||||||
|
}
|
||||||
|
final pct = _requestStatements.isEmpty ? 0.0 : lit / _requestStatements.length;
|
||||||
|
canvas.drawCircle(
|
||||||
|
Offset(w / 2, h * 0.22),
|
||||||
|
30,
|
||||||
|
Paint()..color = const Color(0xFF0B1728),
|
||||||
|
);
|
||||||
|
canvas.drawArc(
|
||||||
|
Rect.fromCircle(center: Offset(w / 2, h * 0.22), radius: 34),
|
||||||
|
-math.pi / 2,
|
||||||
|
math.pi * 2 * pct,
|
||||||
|
false,
|
||||||
|
Paint()
|
||||||
|
..color = done ? const Color(0xFF30D158) : const Color(0xFF00F5D4)
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 3,
|
||||||
|
);
|
||||||
|
_centerText(
|
||||||
|
canvas,
|
||||||
|
Offset(w / 2, h * 0.22),
|
||||||
|
done ? 'طلبٌ متحقِّقٌ ✓' : 'طلبُ حصولِ أمرٍ لم يقعْ بَعْدُ',
|
||||||
|
TextStyle(
|
||||||
|
color: done ? const Color(0xFF30D158) : Colors.white70,
|
||||||
|
fontSize: 9.5,
|
||||||
|
fontWeight: FontWeight.w700),
|
||||||
|
maxWidth: w * 0.4,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (var i = 0; i < _requestStatements.length; i++) {
|
||||||
|
final y = h * 0.38 + i * (h * 0.55) / _requestStatements.length;
|
||||||
|
final on = checks[i] == _requestStatements[i].$2;
|
||||||
|
canvas.drawRRect(
|
||||||
|
RRect.fromRectAndRadius(
|
||||||
|
Rect.fromCenter(
|
||||||
|
center: Offset(w / 2, y), width: w * 0.82, height: 30),
|
||||||
|
const Radius.circular(15),
|
||||||
|
),
|
||||||
|
Paint()..color = const Color(0xFF0B1728),
|
||||||
|
);
|
||||||
|
canvas.drawRRect(
|
||||||
|
RRect.fromRectAndRadius(
|
||||||
|
Rect.fromCenter(
|
||||||
|
center: Offset(w / 2, y), width: w * 0.82, height: 30),
|
||||||
|
const Radius.circular(15),
|
||||||
|
),
|
||||||
|
Paint()
|
||||||
|
..color = on ? const Color(0xFF30D158) : Colors.white12
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1.2,
|
||||||
|
);
|
||||||
|
_centerText(
|
||||||
|
canvas,
|
||||||
|
Offset(w / 2, y),
|
||||||
|
(on ? '✓ ' : '· ') + _requestStatements[i].$1,
|
||||||
|
TextStyle(
|
||||||
|
color: on ? const Color(0xFF30D158) : Colors.white70,
|
||||||
|
fontSize: 9.5),
|
||||||
|
maxWidth: w * 0.76,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_RequestConstructionPainter old) =>
|
||||||
|
old.checks != checks || old.done != done;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Canvas 1 — أدواتُ أنواعِ الإنشاءِ الطّلبيِّ.
|
||||||
|
// ============================================================================
|
||||||
|
class _RequestTypesPainter extends CustomPainter {
|
||||||
|
final List<int> selected;
|
||||||
|
final bool done;
|
||||||
|
|
||||||
|
const _RequestTypesPainter({required this.selected, required this.done});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final w = size.width, h = size.height;
|
||||||
|
final center = Offset(w / 2, h * 0.5);
|
||||||
|
_centerText(
|
||||||
|
canvas,
|
||||||
|
Offset(w / 2, h * 0.12),
|
||||||
|
done ? 'عجلةُ الإنشاءِ الطّلبيِّ مضبوطةٌ ✓' : 'أنواعُ الإنشاءِ الطّلبيِّ',
|
||||||
|
TextStyle(
|
||||||
|
color: done ? const Color(0xFF30D158) : Colors.white70,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700),
|
||||||
|
maxWidth: w * 0.86,
|
||||||
|
);
|
||||||
|
|
||||||
|
canvas.drawCircle(center, 30, Paint()..color = const Color(0xFF0B1728));
|
||||||
|
canvas.drawCircle(
|
||||||
|
center,
|
||||||
|
90,
|
||||||
|
Paint()
|
||||||
|
..color = done ? const Color(0xFF30D158) : Colors.white12
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (var i = 0; i < _typesRows.length; i++) {
|
||||||
|
final a = -math.pi / 2 + i * 2 * math.pi / _typesRows.length;
|
||||||
|
final pos = center + Offset(math.cos(a), math.sin(a)) * 68;
|
||||||
|
final on = selected[i] == i;
|
||||||
|
canvas.drawCircle(pos, 15, Paint()..color = const Color(0xFF0B1728));
|
||||||
|
canvas.drawCircle(
|
||||||
|
pos,
|
||||||
|
15,
|
||||||
|
Paint()
|
||||||
|
..color = on ? const Color(0xFF30D158) : Colors.white12
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1.2,
|
||||||
|
);
|
||||||
|
_centerText(
|
||||||
|
canvas,
|
||||||
|
pos,
|
||||||
|
on ? '✓ ${_typesRows[i].$1}' : _typesRows[i].$1,
|
||||||
|
TextStyle(
|
||||||
|
color: on ? const Color(0xFF30D158) : Colors.white70,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w700),
|
||||||
|
maxWidth: w * 0.3,
|
||||||
|
);
|
||||||
|
final io = center + Offset(math.cos(a), math.sin(a)) * 68;
|
||||||
|
_centerText(
|
||||||
|
canvas,
|
||||||
|
io + const Offset(0, 28),
|
||||||
|
_typesRows[i].$2,
|
||||||
|
TextStyle(
|
||||||
|
color: done ? const Color(0xFF30D158) : Colors.white60,
|
||||||
|
fontSize: 7.5),
|
||||||
|
maxWidth: w * 0.26,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_RequestTypesPainter old) =>
|
||||||
|
old.selected != selected || old.done != done;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Canvas 2 — توظيفُ أمثلةِ الدرسِ.
|
||||||
|
// ============================================================================
|
||||||
|
class _RequestSituationalPainter extends CustomPainter {
|
||||||
|
final List<int> picks;
|
||||||
|
final bool done;
|
||||||
|
|
||||||
|
const _RequestSituationalPainter({required this.picks, required this.done});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final w = size.width, h = size.height;
|
||||||
|
_centerText(
|
||||||
|
canvas,
|
||||||
|
Offset(w / 2, h * 0.10),
|
||||||
|
done ? 'توظيفُ الإنشاءِ الطّلبيِّ سليمٌ ✓' : 'أُحدِّدُ نوعَ الإنشاءِ في مثالٍ',
|
||||||
|
TextStyle(
|
||||||
|
color: done ? const Color(0xFF30D158) : Colors.white70,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700),
|
||||||
|
maxWidth: w * 0.9,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (var i = 0; i < _situationalStatements.length; i++) {
|
||||||
|
final correct = _situationalStatements[i].$3;
|
||||||
|
final on = picks[i] == correct;
|
||||||
|
final y = h * 0.20 + i * (h * 0.72) / _situationalStatements.length;
|
||||||
|
final outline = RRect.fromRectAndRadius(
|
||||||
|
Rect.fromCenter(
|
||||||
|
center: Offset(w / 2, y), width: w * 0.86, height: 30),
|
||||||
|
const Radius.circular(15),
|
||||||
|
);
|
||||||
|
canvas.drawRRect(outline, Paint()..color = const Color(0xFF0B1728));
|
||||||
|
canvas.drawRRect(
|
||||||
|
outline,
|
||||||
|
Paint()
|
||||||
|
..color = on ? const Color(0xFF30D158) : Colors.white12
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1.2,
|
||||||
|
);
|
||||||
|
_centerText(
|
||||||
|
canvas,
|
||||||
|
Offset(w / 2, y),
|
||||||
|
(on ? '✓ ' : '· ') + _situationalStatements[i].$1,
|
||||||
|
TextStyle(
|
||||||
|
color: on ? const Color(0xFF30D158) : Colors.white70,
|
||||||
|
fontSize: 9.5),
|
||||||
|
maxWidth: w * 0.8,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(_RequestSituationalPainter old) =>
|
||||||
|
old.picks != picks || old.done != done;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// مساعداتُ رسمٍ مشتركةٌ.
|
||||||
|
// ============================================================================
|
||||||
|
void _centerText(
|
||||||
|
Canvas canvas,
|
||||||
|
Offset center,
|
||||||
|
String text,
|
||||||
|
TextStyle style, {
|
||||||
|
double? maxWidth,
|
||||||
|
}) {
|
||||||
|
final tp = TextPainter(
|
||||||
|
text: TextSpan(text: text, style: style),
|
||||||
|
textDirection: TextDirection.rtl,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 4,
|
||||||
|
)..layout(maxWidth: maxWidth ?? double.infinity);
|
||||||
|
tp.paint(canvas, Offset(center.dx - tp.width / 2, center.dy - tp.height / 2));
|
||||||
|
}
|
||||||
@@ -0,0 +1,561 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import 'lab_identity.dart';
|
||||||
|
import 'lab_scaffold.dart';
|
||||||
|
|
||||||
|
/// ============================================================================
|
||||||
|
/// EARTH & ENVIRONMENTAL SCIENCES — GRADE 10
|
||||||
|
/// Unit 3, Lesson 1 — الكتل والجبهات الهوائية (Air Masses & Fronts)
|
||||||
|
/// Grounded verbatim in Ministry textbook pages 8-15:
|
||||||
|
/// - 4 Air Masses: cP (قارية قطبية), mP (بحرية قطبية), cT (قارية مدارية), mT (بحرية مدارية)
|
||||||
|
/// - 4 Front Types: جبهة باردة (Cold), جبهة دافئة (Warm), جبهة مقفلة (Occluded), جبهة مستقرة (Stationary)
|
||||||
|
/// - Visual cloud formation, precipitation styles, and temperature boundary profile
|
||||||
|
/// ============================================================================
|
||||||
|
|
||||||
|
class EarthAirMassesLabView extends StatefulWidget {
|
||||||
|
final LabCheckpointCallback? onCheckpointTriggered;
|
||||||
|
const EarthAirMassesLabView({super.key, this.onCheckpointTriggered});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EarthAirMassesLabView> createState() => _EarthAirMassesLabViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EarthAirMassesLabViewState extends State<EarthAirMassesLabView>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
int _frontMode = 0; // 0 = Cold Front, 1 = Warm Front, 2 = Stationary, 3 = Occluded
|
||||||
|
int _selectedAirMass = 0; // 0 = cP, 1 = mP, 2 = cT, 3 = mT
|
||||||
|
late final AnimationController _cloudAnimController;
|
||||||
|
|
||||||
|
static const List<Map<String, dynamic>> _airMasses = [
|
||||||
|
{
|
||||||
|
'code': 'cP',
|
||||||
|
'name': 'قارية قطبية (Continental Polar)',
|
||||||
|
'temp': 'شديدة البرودة (-10°C إلى 2°C)',
|
||||||
|
'humidity': 'جافة جداً (رطوبة منخفضة)',
|
||||||
|
'source': 'سيبيريا وشمال كندا وأوراسيا',
|
||||||
|
'jordanImpact': 'موجات صقيع وانجماد جافة شتاءً في الأردن',
|
||||||
|
'color': Color(0xFF60A5FA),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'mP',
|
||||||
|
'name': 'بحرية قطبية (Maritime Polar)',
|
||||||
|
'temp': 'باردة ورطبة (2°C إلى 8°C)',
|
||||||
|
'humidity': 'عالية الرطوبة والتشبع',
|
||||||
|
'source': 'شمال المحيط الأطلسي والقطب الشمالي',
|
||||||
|
'jordanImpact': 'منخفضات جوية شتوية مصحوبة بأمطار وثلوج غزيرة',
|
||||||
|
'color': Color(0xFF38BDF8),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'cT',
|
||||||
|
'name': 'قارية مدارية (Continental Tropical)',
|
||||||
|
'temp': 'حارة جداً وجافة (34°C إلى 42°C)',
|
||||||
|
'humidity': 'جافة ومغبرة أحياناً',
|
||||||
|
'source': 'شبه الجزيرة العربية والصحراء الكبرى',
|
||||||
|
'jordanImpact': 'موجات حر صيفية ورياح خماسينية في الربيع',
|
||||||
|
'color': Color(0xFFF59E0B),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'code': 'mT',
|
||||||
|
'name': 'بحرية مدارية (Maritime Tropical)',
|
||||||
|
'temp': 'دافئة ورطبة (24°C إلى 30°C)',
|
||||||
|
'humidity': 'رطوبة جوية مرتفعة وضباب',
|
||||||
|
'source': 'المحيط الأطلسي والبحر الأحمر وخليج العقبة',
|
||||||
|
'jordanImpact': 'حالات عدم استقرار جوي وزخات رعدية مفاجئة',
|
||||||
|
'color': Color(0xFF10B981),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
static const List<Map<String, dynamic>> _frontTypes = [
|
||||||
|
{
|
||||||
|
'title': 'الجبهة الهوائية الباردة (Cold Front)',
|
||||||
|
'symbol': 'مثلثات زرقاء تشير لاتجاه الحركة ▲▲▲',
|
||||||
|
'mechanism': 'هواء بارد كثيف يندفع سريعاً تحت الهواء الدافئ الأقل كثافة، فيرفعه بقوة للأعلى.',
|
||||||
|
'clouds': 'غيوم المزن الركامية (Cumulonimbus) الشاهقة',
|
||||||
|
'weather': 'أمطار غزيرة مفاجئة، عواصف رعدية وزخات بَرَد ورياح نشطة يعقبها انخفاض ملموس في الحرارة.',
|
||||||
|
'color': Color(0xFF2563EB),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'الجبهة الهوائية الدافئة (Warm Front)',
|
||||||
|
'symbol': 'أنصاف دوائر حمراء تشير لاتجاه الحركة ●●●',
|
||||||
|
'mechanism': 'هواء دافئ يصعد تدريجياً وببطء فوق كتلة هوائية باردة ثابتة أو بطيئة.',
|
||||||
|
'clouds': 'غيوم طبقية (Stratus) تبدأ بالسمحاقية ثم الركامية المتوسطة فالطبقية المنبسطة',
|
||||||
|
'weather': 'أمطار ديمية مستمرة وخفيفة إلى متوسطة على مساحات شاسعة، مع ارتفاع تدريجي في درجات الحرارة.',
|
||||||
|
'color': Color(0xFFDC2626),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'الجبهة الهوائية المستقرة (Stationary Front)',
|
||||||
|
'symbol': 'مثلثات زرقاء في جهة وأنصاف دوائر حمراء في الجهة المقابلة',
|
||||||
|
'mechanism': 'تلتقي كتلة دافئة وأخرى باردة دون أن تتمكن إحداهما من إزاحة الأخرى لتعادل القوى.',
|
||||||
|
'clouds': 'غيوم طبقية رمادية كثيفة',
|
||||||
|
'weather': 'أجواء غائمة لعدة أيام مع هطول أمطار متقطعة ومستمرة في نفس المنطقة الجغرافية.',
|
||||||
|
'color': Color(0xFF8B5CF6),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'title': 'الجبهة الهوائية المقفلة (Occluded Front)',
|
||||||
|
'symbol': 'مثلثات وأنصاف دوائر بنفسجية متبادلة على نفس الخط',
|
||||||
|
'mechanism': 'جبهة باردة سريعة الحركة تلحق بجبهة دافئة وترفع الهواء الدافئ عن سطح الأرض بالكامل.',
|
||||||
|
'clouds': 'مزيج معقد من الغيوم الركامية والطبقية الكثيفة',
|
||||||
|
'weather': 'أمطار معقدة وشديدة وتبريد حاد يليه استقرار وتلاشي تدريجي للمنخفض الجوي.',
|
||||||
|
'color': Color(0xFF7C3AED),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_cloudAnimController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(seconds: 4),
|
||||||
|
)..repeat();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_cloudAnimController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final activeAirMass = _airMasses[_selectedAirMass];
|
||||||
|
final activeFront = _frontTypes[_frontMode];
|
||||||
|
|
||||||
|
return SaqelLabScaffold(
|
||||||
|
titleAr: 'الكتل والجبهات الهوائية — علوم الأرض',
|
||||||
|
subtitleAr: 'محاكاة ديناميكية لتصادم الكتل الهوائية وتشكل الغيوم وأنماط الهطول المعتمدة',
|
||||||
|
identity: const LabIdentity(
|
||||||
|
curriculumLessonId: 'earth_sciences_10_semester_2_unit_03_lesson_01',
|
||||||
|
subjectKey: 'earth_sciences_10',
|
||||||
|
semesterKey: 'semester_2',
|
||||||
|
unitKey: 'unit_03',
|
||||||
|
lessonKey: 'lesson_01',
|
||||||
|
sourceMarkdown: 'grade_10/earth_sciences_10/semester_2/unit_03/lesson_01.md',
|
||||||
|
subjectAr: 'علوم الأرض والبيئة',
|
||||||
|
lessonAr: 'الكتل والجبهات الهوائية',
|
||||||
|
),
|
||||||
|
onCheckpointTriggered: widget.onCheckpointTriggered,
|
||||||
|
checkpointQuestion:
|
||||||
|
'عندما يندفع هواء بارد كثيف سريعاً أسفل هواء دافئ رطب، تتشكل جبهة باردة تؤدي إلى …',
|
||||||
|
checkpointOptions: const [
|
||||||
|
'غيوم المزن الركامية الشاهقة وأمطار غزيرة وعواصف رعدية',
|
||||||
|
'أجواء صافية وجافة تماماً دون أي غيوم',
|
||||||
|
'ارتفاع مفاجئ في درجات الحرارة والرياح الخماسينية',
|
||||||
|
'غيوم رقيقة جداً لا ينتج عنها أي هطول'
|
||||||
|
],
|
||||||
|
checkpointCorrectIdx: 0,
|
||||||
|
telemetry: [
|
||||||
|
LabPill('النوع: ${activeFront['title']!.split('(').first.trim()}',
|
||||||
|
color: activeFront['color'] as Color),
|
||||||
|
LabPill('الكتلة: ${activeAirMass['code']}',
|
||||||
|
color: activeAirMass['color'] as Color),
|
||||||
|
LabPill('الرمز: ${activeFront['symbol']!.split(' ').first}'),
|
||||||
|
],
|
||||||
|
canvas: AnimatedBuilder(
|
||||||
|
animation: _cloudAnimController,
|
||||||
|
builder: (context, _) {
|
||||||
|
return CustomPaint(
|
||||||
|
painter: _AtmosphericFrontPainter(
|
||||||
|
frontMode: _frontMode,
|
||||||
|
airMassCode: activeAirMass['code'] as String,
|
||||||
|
airMassColor: activeAirMass['color'] as Color,
|
||||||
|
progress: _cloudAnimController.value,
|
||||||
|
),
|
||||||
|
child: Container(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
controls: [
|
||||||
|
const Text(
|
||||||
|
'اختر نمط الجبهة الهوائية للتصادم (Front Dynamics):',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
LabSegments<int>(
|
||||||
|
labels: const ['جبهة باردة ❄️', 'جبهة دافئة ☀️', 'مستقرة ⏸️', 'مقفلة 🌀'],
|
||||||
|
values: const [0, 1, 2, 3],
|
||||||
|
current: _frontMode,
|
||||||
|
onSelected: (val) => setState(() => _frontMode = val),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
|
||||||
|
// Information card about current front
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF0B1424),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: (activeFront['color'] as Color).withAlpha(90)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(CupertinoIcons.wind, color: activeFront['color'] as Color, size: 16),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
activeFront['title'] as String,
|
||||||
|
style: TextStyle(
|
||||||
|
color: activeFront['color'] as Color,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'آلية التكون: ${activeFront['mechanism']}',
|
||||||
|
style: const TextStyle(color: Colors.white70, fontSize: 11.5, height: 1.45),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'نوع الغيوم الناتجة: ${activeFront['clouds']}',
|
||||||
|
style: const TextStyle(color: AppColors.saqelCyan, fontSize: 11.5, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'الطقس المصاحب: ${activeFront['weather']}',
|
||||||
|
style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 11),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'فحص تصنيف الكتل الهوائية المؤثرة على الأردن (Air Masses):',
|
||||||
|
style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
LabSegments<int>(
|
||||||
|
labels: const ['cP قارية قطبية', 'mP بحرية قطبية', 'cT قارية مدارية', 'mT بحرية مدارية'],
|
||||||
|
values: const [0, 1, 2, 3],
|
||||||
|
current: _selectedAirMass,
|
||||||
|
onSelected: (val) => setState(() => _selectedAirMass = val),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF07101E),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.white12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${activeAirMass['code']}: ${activeAirMass['name']}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: activeAirMass['color'] as Color,
|
||||||
|
fontSize: 12.5,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'الحرارة والرطوبة: ${activeAirMass['temp']} • ${activeAirMass['humidity']}',
|
||||||
|
style: const TextStyle(color: Colors.white70, fontSize: 11.5),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'المصدر الجغرافي: ${activeAirMass['source']}',
|
||||||
|
style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 11),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'أثرها المباشر في الأردن: ${activeAirMass['jordanImpact']}',
|
||||||
|
style: const TextStyle(color: AppColors.guardianAmber, fontSize: 11, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dynamic Custom Painter illustrating atmospheric cross-section:
|
||||||
|
/// ground, warm air wedge, cold air undercut, dynamic cloud rendering, and rain streams.
|
||||||
|
class _AtmosphericFrontPainter extends CustomPainter {
|
||||||
|
final int frontMode; // 0=Cold, 1=Warm, 2=Stationary, 3=Occluded
|
||||||
|
final String airMassCode;
|
||||||
|
final Color airMassColor;
|
||||||
|
final double progress;
|
||||||
|
|
||||||
|
_AtmosphericFrontPainter({
|
||||||
|
required this.frontMode,
|
||||||
|
required this.airMassCode,
|
||||||
|
required this.airMassColor,
|
||||||
|
required this.progress,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final w = size.width;
|
||||||
|
final h = size.height;
|
||||||
|
|
||||||
|
// 1. Sky & Atmospheric background gradient
|
||||||
|
final skyPaint = Paint()
|
||||||
|
..shader = const LinearGradient(
|
||||||
|
colors: [Color(0xFF0B1B36), Color(0xFF071020)],
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
).createShader(Rect.fromLTWH(0, 0, w, h));
|
||||||
|
canvas.drawRect(Rect.fromLTWH(0, 0, w, h), skyPaint);
|
||||||
|
|
||||||
|
// 2. Ground surface
|
||||||
|
final groundY = h * 0.85;
|
||||||
|
final groundPaint = Paint()..color = const Color(0xFF1E293B);
|
||||||
|
canvas.drawRect(Rect.fromLTWH(0, groundY, w, h - groundY), groundPaint);
|
||||||
|
|
||||||
|
// Ground grass line
|
||||||
|
final grassPaint = Paint()
|
||||||
|
..color = const Color(0xFF10B981)
|
||||||
|
..strokeWidth = 2.5;
|
||||||
|
canvas.drawLine(Offset(0, groundY), Offset(w, groundY), grassPaint);
|
||||||
|
|
||||||
|
// 3. Draw Front Boundary and Air wedges based on mode
|
||||||
|
if (frontMode == 0) {
|
||||||
|
// COLD FRONT: Steep cold wedge pushing rightward under warm air
|
||||||
|
_drawColdFront(canvas, w, h, groundY);
|
||||||
|
} else if (frontMode == 1) {
|
||||||
|
// WARM FRONT: Gentle slope, warm air gliding up over retreating cold air
|
||||||
|
_drawWarmFront(canvas, w, h, groundY);
|
||||||
|
} else if (frontMode == 2) {
|
||||||
|
// STATIONARY FRONT: Two opposing air masses side-by-side
|
||||||
|
_drawStationaryFront(canvas, w, h, groundY);
|
||||||
|
} else {
|
||||||
|
// OCCLUDED FRONT: Cold air catches up, lifting warm pocket completely aloft
|
||||||
|
_drawOccludedFront(canvas, w, h, groundY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Draw Air Mass Telemetry Watermark on canvas
|
||||||
|
final tagPainter = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: 'كتلة هوائية نشطة: $airMassCode',
|
||||||
|
style: TextStyle(
|
||||||
|
color: airMassColor.withAlpha(200),
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textDirection: TextDirection.rtl,
|
||||||
|
)..layout();
|
||||||
|
tagPainter.paint(canvas, Offset(w - tagPainter.width - 16, 16));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawColdFront(Canvas canvas, double w, double h, double groundY) {
|
||||||
|
// Cold air wedge (steep slope on left)
|
||||||
|
final coldWedgePath = Path()
|
||||||
|
..moveTo(0, groundY)
|
||||||
|
..lineTo(w * 0.55, groundY)
|
||||||
|
..quadraticBezierTo(w * 0.48, groundY - 140, 0, groundY - 190)
|
||||||
|
..close();
|
||||||
|
|
||||||
|
final coldPaint = Paint()
|
||||||
|
..color = const Color(0xFF2563EB).withAlpha(120)
|
||||||
|
..style = PaintingStyle.fill;
|
||||||
|
canvas.drawPath(coldWedgePath, coldPaint);
|
||||||
|
|
||||||
|
// Cold air label
|
||||||
|
_drawText(canvas, 'هواء بارد كثيف (Cold Air)', Offset(w * 0.12, groundY - 50),
|
||||||
|
Colors.white70, 11);
|
||||||
|
|
||||||
|
// Warm air pushed upwards
|
||||||
|
final warmArrowPaint = Paint()
|
||||||
|
..color = const Color(0xFFEF4444).withAlpha(190)
|
||||||
|
..strokeWidth = 2.5
|
||||||
|
..style = PaintingStyle.stroke;
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(w * 0.65, groundY - 40), Offset(w * 0.50, groundY - 150), warmArrowPaint);
|
||||||
|
_drawText(canvas, 'هواء دافئ يرتفع بقوة ⇈', Offset(w * 0.54, groundY - 170),
|
||||||
|
const Color(0xFFFCA5A5), 11);
|
||||||
|
|
||||||
|
// Towering Cumulonimbus clouds at boundary
|
||||||
|
_drawCloud(canvas, Offset(w * 0.46, groundY - 180), 55, const Color(0xFF475569));
|
||||||
|
_drawCloud(canvas, Offset(w * 0.52, groundY - 150), 45, const Color(0xFF334155));
|
||||||
|
_drawCloud(canvas, Offset(w * 0.48, groundY - 110), 40, const Color(0xFF1E293B));
|
||||||
|
|
||||||
|
// Heavy rain streams under cloud
|
||||||
|
final rainPaint = Paint()
|
||||||
|
..color = const Color(0xFF38BDF8).withAlpha(180)
|
||||||
|
..strokeWidth = 1.8;
|
||||||
|
for (int i = 0; i < 8; i++) {
|
||||||
|
final rx = w * 0.42 + (i * 14);
|
||||||
|
final ry = groundY - 90 + ((progress * 40 + i * 10) % 80);
|
||||||
|
canvas.drawLine(Offset(rx, ry), Offset(rx - 4, ry + 16), rainPaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Front boundary line with blue triangles
|
||||||
|
final frontLinePaint = Paint()
|
||||||
|
..color = const Color(0xFF38BDF8)
|
||||||
|
..strokeWidth = 3;
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(w * 0.48, groundY - 140), Offset(w * 0.55, groundY), frontLinePaint);
|
||||||
|
|
||||||
|
// Draw blue triangle markers
|
||||||
|
_drawFrontMarkerTriangle(
|
||||||
|
canvas, Offset(w * 0.50, groundY - 80), const Color(0xFF2563EB));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawWarmFront(Canvas canvas, double w, double h, double groundY) {
|
||||||
|
// Cold retreating air wedge (gentle slope on right)
|
||||||
|
final coldWedgePath = Path()
|
||||||
|
..moveTo(w, groundY)
|
||||||
|
..lineTo(w * 0.20, groundY)
|
||||||
|
..lineTo(w, groundY - 180)
|
||||||
|
..close();
|
||||||
|
|
||||||
|
final coldPaint = Paint()
|
||||||
|
..color = const Color(0xFF3B82F6).withAlpha(90)
|
||||||
|
..style = PaintingStyle.fill;
|
||||||
|
canvas.drawPath(coldWedgePath, coldPaint);
|
||||||
|
|
||||||
|
_drawText(canvas, 'هواء بارد ينسحب ببطء', Offset(w * 0.65, groundY - 40),
|
||||||
|
Colors.white70, 11);
|
||||||
|
|
||||||
|
// Warm air gliding over cold wedge
|
||||||
|
final warmArrowPaint = Paint()
|
||||||
|
..color = const Color(0xFFEF4444).withAlpha(190)
|
||||||
|
..strokeWidth = 2.5
|
||||||
|
..style = PaintingStyle.stroke;
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(w * 0.10, groundY - 20), Offset(w * 0.70, groundY - 160), warmArrowPaint);
|
||||||
|
_drawText(canvas, 'هواء دافئ يصعد بانحدار لطيف ↗', Offset(w * 0.15, groundY - 110),
|
||||||
|
const Color(0xFFFCA5A5), 11);
|
||||||
|
|
||||||
|
// Layered Stratus Clouds spread wide
|
||||||
|
_drawCloud(canvas, Offset(w * 0.45, groundY - 120), 45, const Color(0xFF64748B));
|
||||||
|
_drawCloud(canvas, Offset(w * 0.65, groundY - 150), 40, const Color(0xFF94A3B8));
|
||||||
|
_drawCloud(canvas, Offset(w * 0.85, groundY - 175), 30, const Color(0xFFCBD5E1));
|
||||||
|
|
||||||
|
// Gentle continuous rain
|
||||||
|
final rainPaint = Paint()
|
||||||
|
..color = const Color(0xFF67E8F9).withAlpha(130)
|
||||||
|
..strokeWidth = 1.2;
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
final rx = w * 0.35 + (i * 20);
|
||||||
|
final ry = groundY - 70 + ((progress * 30 + i * 8) % 65);
|
||||||
|
canvas.drawLine(Offset(rx, ry), Offset(rx - 2, ry + 12), rainPaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Front boundary line with red semicircles
|
||||||
|
final frontLinePaint = Paint()
|
||||||
|
..color = const Color(0xFFEF4444)
|
||||||
|
..strokeWidth = 3;
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(w * 0.20, groundY), Offset(w * 0.80, groundY - 140), frontLinePaint);
|
||||||
|
|
||||||
|
// Draw red semicircle marker
|
||||||
|
_drawFrontMarkerSemicircle(
|
||||||
|
canvas, Offset(w * 0.45, groundY - 60), const Color(0xFFDC2626));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawStationaryFront(Canvas canvas, double w, double h, double groundY) {
|
||||||
|
// Air masses abutting in the middle
|
||||||
|
canvas.drawRect(
|
||||||
|
Rect.fromLTWH(0, groundY - 160, w * 0.5, 160),
|
||||||
|
Paint()..color = const Color(0xFF2563EB).withAlpha(70),
|
||||||
|
);
|
||||||
|
canvas.drawRect(
|
||||||
|
Rect.fromLTWH(w * 0.5, groundY - 160, w * 0.5, 160),
|
||||||
|
Paint()..color = const Color(0xFFDC2626).withAlpha(70),
|
||||||
|
);
|
||||||
|
|
||||||
|
_drawText(canvas, 'كتلة باردة ←', Offset(w * 0.15, groundY - 60), Colors.white, 12);
|
||||||
|
_drawText(canvas, '→ كتلة دافئة', Offset(w * 0.65, groundY - 60), Colors.white, 12);
|
||||||
|
_drawText(canvas, 'توازن القوى (لا تقدم لأي طرف)', Offset(w * 0.32, groundY - 20),
|
||||||
|
AppColors.guardianAmber, 11);
|
||||||
|
|
||||||
|
// Stationary front line
|
||||||
|
final linePaint = Paint()
|
||||||
|
..color = const Color(0xFF8B5CF6)
|
||||||
|
..strokeWidth = 3;
|
||||||
|
canvas.drawLine(Offset(w * 0.5, groundY), Offset(w * 0.5, groundY - 160), linePaint);
|
||||||
|
|
||||||
|
// Draw clouds at boundary
|
||||||
|
_drawCloud(canvas, Offset(w * 0.5, groundY - 140), 45, const Color(0xFF475569));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawOccludedFront(Canvas canvas, double w, double h, double groundY) {
|
||||||
|
// Cold air undercuts from both sides, warm pocket lifted aloft
|
||||||
|
final coldWedge1 = Path()
|
||||||
|
..moveTo(0, groundY)
|
||||||
|
..lineTo(w * 0.65, groundY)
|
||||||
|
..lineTo(0, groundY - 160)
|
||||||
|
..close();
|
||||||
|
canvas.drawPath(coldWedge1, Paint()..color = const Color(0xFF1D4ED8).withAlpha(120));
|
||||||
|
|
||||||
|
// Warm air lifted pocket
|
||||||
|
final warmPocket = Path()
|
||||||
|
..moveTo(w * 0.35, groundY - 110)
|
||||||
|
..quadraticBezierTo(w * 0.50, groundY - 190, w * 0.65, groundY - 110)
|
||||||
|
..close();
|
||||||
|
canvas.drawPath(warmPocket, Paint()..color = const Color(0xFFEF4444).withAlpha(180));
|
||||||
|
|
||||||
|
_drawText(canvas, 'هواء دافئ معزول بالكامل في الأعلى',
|
||||||
|
Offset(w * 0.28, groundY - 180), const Color(0xFFFECACA), 11);
|
||||||
|
_drawText(canvas, 'هواء بارد سطحي', Offset(w * 0.15, groundY - 30), Colors.white70, 11);
|
||||||
|
|
||||||
|
// Occluded purple boundary
|
||||||
|
final linePaint = Paint()
|
||||||
|
..color = const Color(0xFF7C3AED)
|
||||||
|
..strokeWidth = 3;
|
||||||
|
canvas.drawLine(
|
||||||
|
Offset(w * 0.50, groundY), Offset(w * 0.50, groundY - 110), linePaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawCloud(Canvas canvas, Offset center, double radius, Color color) {
|
||||||
|
final cloudPaint = Paint()..color = color;
|
||||||
|
canvas.drawCircle(center, radius, cloudPaint);
|
||||||
|
canvas.drawCircle(Offset(center.dx - radius * 0.6, center.dy + radius * 0.2),
|
||||||
|
radius * 0.75, cloudPaint);
|
||||||
|
canvas.drawCircle(Offset(center.dx + radius * 0.6, center.dy + radius * 0.2),
|
||||||
|
radius * 0.75, cloudPaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawFrontMarkerTriangle(Canvas canvas, Offset center, Color color) {
|
||||||
|
final path = Path()
|
||||||
|
..moveTo(center.dx, center.dy - 10)
|
||||||
|
..lineTo(center.dx + 12, center.dy + 4)
|
||||||
|
..lineTo(center.dx - 2, center.dy + 10)
|
||||||
|
..close();
|
||||||
|
canvas.drawPath(path, Paint()..color = color);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawFrontMarkerSemicircle(Canvas canvas, Offset center, Color color) {
|
||||||
|
final rect = Rect.fromCircle(center: center, radius: 8);
|
||||||
|
canvas.drawArc(rect, 0, math.pi, true, Paint()..color = color);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawText(
|
||||||
|
Canvas canvas, String text, Offset offset, Color color, double fontSize) {
|
||||||
|
final tp = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: text,
|
||||||
|
style: TextStyle(
|
||||||
|
color: color,
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textDirection: TextDirection.rtl,
|
||||||
|
)..layout();
|
||||||
|
tp.paint(canvas, offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant _AtmosphericFrontPainter old) {
|
||||||
|
return old.frontMode != frontMode ||
|
||||||
|
old.airMassCode != airMassCode ||
|
||||||
|
old.airMassColor != airMassColor ||
|
||||||
|
old.progress != progress;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,6 +95,17 @@ class LabIdentity {
|
|||||||
// LESSON-BOUND LAB IDENTITIES (verified against spec front-matter)
|
// LESSON-BOUND LAB IDENTITIES (verified against spec front-matter)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const LabIdentity kPhysicsVectorsIntroLabIdentity = LabIdentity(
|
||||||
|
curriculumLessonId: 'physics_10_semester_1_unit_01_lesson_01',
|
||||||
|
subjectKey: 'physics_10',
|
||||||
|
semesterKey: 'semester_1',
|
||||||
|
unitKey: 'unit_01',
|
||||||
|
lessonKey: 'lesson_01',
|
||||||
|
sourceMarkdown: 'grade_10/physics_10/semester_1/unit_01/lesson_01.md',
|
||||||
|
subjectAr: 'الفيزياء',
|
||||||
|
lessonAr: 'الكميات القياسية والمتجهة وتمثيلها',
|
||||||
|
);
|
||||||
|
|
||||||
const LabIdentity kPhysicsVectorAdditionLabIdentity = LabIdentity(
|
const LabIdentity kPhysicsVectorAdditionLabIdentity = LabIdentity(
|
||||||
curriculumLessonId: 'physics_10_semester_1_unit_01_lesson_02',
|
curriculumLessonId: 'physics_10_semester_1_unit_01_lesson_02',
|
||||||
subjectKey: 'physics_10',
|
subjectKey: 'physics_10',
|
||||||
@@ -117,6 +128,17 @@ const LabIdentity kPhysicsMotion1DLabIdentity = LabIdentity(
|
|||||||
lessonAr: 'الحركة في بعد واحد',
|
lessonAr: 'الحركة في بعد واحد',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const LabIdentity kPhysicsProjectileMotionLabIdentity = LabIdentity(
|
||||||
|
curriculumLessonId: 'physics_10_semester_1_unit_02_lesson_02',
|
||||||
|
subjectKey: 'physics_10',
|
||||||
|
semesterKey: 'semester_1',
|
||||||
|
unitKey: 'unit_02',
|
||||||
|
lessonKey: 'lesson_02',
|
||||||
|
sourceMarkdown: 'grade_10/physics_10/semester_1/unit_02/lesson_02.md',
|
||||||
|
subjectAr: 'الفيزياء',
|
||||||
|
lessonAr: 'حركة المقذوفات في بعدين',
|
||||||
|
);
|
||||||
|
|
||||||
const LabIdentity kPhysicsCircularMotionLabIdentity = LabIdentity(
|
const LabIdentity kPhysicsCircularMotionLabIdentity = LabIdentity(
|
||||||
curriculumLessonId: 'physics_10_semester_2_unit_04_lesson_03',
|
curriculumLessonId: 'physics_10_semester_2_unit_04_lesson_03',
|
||||||
subjectKey: 'physics_10',
|
subjectKey: 'physics_10',
|
||||||
@@ -196,6 +218,17 @@ const LabIdentity kEarthRockCycleLabIdentity = LabIdentity(
|
|||||||
lessonAr: 'دورة الصخور',
|
lessonAr: 'دورة الصخور',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const LabIdentity kEarthAirMassesLabIdentity = LabIdentity(
|
||||||
|
curriculumLessonId: 'earth_sciences_10_semester_2_unit_03_lesson_01',
|
||||||
|
subjectKey: 'earth_sciences_10',
|
||||||
|
semesterKey: 'semester_2',
|
||||||
|
unitKey: 'unit_03',
|
||||||
|
lessonKey: 'lesson_01',
|
||||||
|
sourceMarkdown: 'grade_10/earth_sciences_10/semester_2/unit_03/lesson_01.md',
|
||||||
|
subjectAr: 'علوم الأرض والبيئة',
|
||||||
|
lessonAr: 'الكتل والجبهات الهوائية',
|
||||||
|
);
|
||||||
|
|
||||||
const LabIdentity kMathSystemsLabIdentity = LabIdentity(
|
const LabIdentity kMathSystemsLabIdentity = LabIdentity(
|
||||||
curriculumLessonId: 'math_10_semester_1_unit_01_lesson_02',
|
curriculumLessonId: 'math_10_semester_1_unit_01_lesson_02',
|
||||||
subjectKey: 'math_10',
|
subjectKey: 'math_10',
|
||||||
@@ -440,6 +473,20 @@ const LabIdentity kArabicUnit2VocativeLabIdentity = LabIdentity(
|
|||||||
lessonAr: 'أبني لغتي (1) — أسلوبُ النّداءِ',
|
lessonAr: 'أبني لغتي (1) — أسلوبُ النّداءِ',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Unit 2, lesson 6 — build my language (2): the requestive construction (الإنشاءُ الطلبيُّ).
|
||||||
|
/// Built on pages 56-59: concept of insha talabi (cannot be verified/falsified),
|
||||||
|
/// six types (النداء، الأمر، النهي، الاستفهام، التمني، الترجي) with tools and examples.
|
||||||
|
const LabIdentity kArabicUnit2InshaLabIdentity = LabIdentity(
|
||||||
|
curriculumLessonId: 'arabic_10_semester_1_unit_02_lesson_06',
|
||||||
|
subjectKey: 'arabic_10',
|
||||||
|
semesterKey: 'semester_1',
|
||||||
|
unitKey: 'unit_02',
|
||||||
|
lessonKey: 'lesson_06',
|
||||||
|
sourceMarkdown: 'grade_10/arabic_10/semester_1/unit_02/lesson_06.md',
|
||||||
|
subjectAr: 'العربية لغتي',
|
||||||
|
lessonAr: 'أبني لغتي (2) — الأسلوبُ الإنشائيّ (الإنشاءُ الطّلبيُّ)',
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// STANDALONE AUTHORING TOOLS (NO curriculum-lesson anchor in the corpus)
|
// STANDALONE AUTHORING TOOLS (NO curriculum-lesson anchor in the corpus)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -504,18 +551,33 @@ const LabIdentity kArabicProsodyToolIdentity = LabIdentity(
|
|||||||
lessonAr: 'العروض والموسيقى الشعرية',
|
lessonAr: 'العروض والموسيقى الشعرية',
|
||||||
);
|
);
|
||||||
|
|
||||||
const LabIdentity kIslamicTajweedToolIdentity = LabIdentity(
|
const LabIdentity kIslamicTajweedLabIdentity = LabIdentity(
|
||||||
toolKey: 'islamic_tajweed',
|
gradeKey: 'grade_10',
|
||||||
|
subjectKey: 'islamic_10',
|
||||||
|
semesterKey: 'semester_1',
|
||||||
|
unitKey: 'unit_01',
|
||||||
|
lessonKey: 'lesson_01',
|
||||||
|
curriculumLessonId: 'islamic_10_semester_1_unit_01_lesson_01',
|
||||||
|
lessonAr: 'واجب المسلم تجاه القرآن الكريم (أحكام التلاوة والمخارج)',
|
||||||
subjectAr: 'التربية الإسلامية',
|
subjectAr: 'التربية الإسلامية',
|
||||||
lessonAr: 'التجويد ومخارج الحروف',
|
sourceMarkdown: 'grade_10/islamic_10/semester_1/unit_01/lesson_01.md',
|
||||||
);
|
);
|
||||||
|
|
||||||
const LabIdentity kIslamicInheritanceToolIdentity = LabIdentity(
|
const LabIdentity kIslamicInheritanceLabIdentity = LabIdentity(
|
||||||
toolKey: 'islamic_inheritance',
|
gradeKey: 'grade_10',
|
||||||
|
subjectKey: 'islamic_10',
|
||||||
|
semesterKey: 'semester_1',
|
||||||
|
unitKey: 'unit_01',
|
||||||
|
lessonKey: 'lesson_02',
|
||||||
|
curriculumLessonId: 'islamic_10_semester_1_unit_01_lesson_02',
|
||||||
|
lessonAr: 'فقه المعاملات والفرائض (حاسبة المواريث والأنصبة)',
|
||||||
subjectAr: 'التربية الإسلامية',
|
subjectAr: 'التربية الإسلامية',
|
||||||
lessonAr: 'المواريث والفرائض',
|
sourceMarkdown: 'grade_10/islamic_10/semester_1/unit_01/lesson_02.md',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const LabIdentity kIslamicTajweedToolIdentity = kIslamicTajweedLabIdentity;
|
||||||
|
const LabIdentity kIslamicInheritanceToolIdentity = kIslamicInheritanceLabIdentity;
|
||||||
|
|
||||||
const LabIdentity kFinanceBudgetToolIdentity = LabIdentity(
|
const LabIdentity kFinanceBudgetToolIdentity = LabIdentity(
|
||||||
toolKey: 'finance_budget',
|
toolKey: 'finance_budget',
|
||||||
subjectAr: 'الثقافة المالية',
|
subjectAr: 'الثقافة المالية',
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import '../../../data/models/socratic_checkpoint_model.dart';
|
|||||||
import '../../widgets/socratic_dialog.dart';
|
import '../../widgets/socratic_dialog.dart';
|
||||||
import 'lab_identity.dart';
|
import 'lab_identity.dart';
|
||||||
import 'lab_scaffold.dart';
|
import 'lab_scaffold.dart';
|
||||||
|
import 'physics_vectors_intro_lab.dart';
|
||||||
|
import 'physics_projectile_motion_lab.dart';
|
||||||
import 'physics_labs.dart';
|
import 'physics_labs.dart';
|
||||||
import 'chemistry_labs.dart';
|
import 'chemistry_labs.dart';
|
||||||
import 'biology_labs.dart';
|
import 'biology_labs.dart';
|
||||||
import 'earth_labs.dart';
|
import 'earth_labs.dart';
|
||||||
|
import 'earth_air_masses_lab.dart';
|
||||||
import 'math_labs.dart';
|
import 'math_labs.dart';
|
||||||
import 'english_labs.dart';
|
import 'english_labs.dart';
|
||||||
import 'islamic_labs.dart';
|
import 'islamic_labs.dart';
|
||||||
@@ -28,6 +31,7 @@ import 'arabic_unit2_vocative_lab.dart';
|
|||||||
import 'arabic_unit2_writing_lab.dart';
|
import 'arabic_unit2_writing_lab.dart';
|
||||||
import 'arabic_unit2_poetry_lab.dart';
|
import 'arabic_unit2_poetry_lab.dart';
|
||||||
import 'arabic_unit2_speaking_lab.dart';
|
import 'arabic_unit2_speaking_lab.dart';
|
||||||
|
import 'arabic_unit2_insha_lab.dart';
|
||||||
|
|
||||||
/// ============================================================================
|
/// ============================================================================
|
||||||
/// GRADE-10 VIRTUAL LABS REGISTRY
|
/// GRADE-10 VIRTUAL LABS REGISTRY
|
||||||
@@ -65,7 +69,11 @@ class Grade10LabsRegistry {
|
|||||||
// Bound lesson labs (17) and standalone authoring tools (16).
|
// Bound lesson labs (17) and standalone authoring tools (16).
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
static final List<Grade10LabEntry> all = [
|
static final List<Grade10LabEntry> all = [
|
||||||
// ---- Physics (4: 1 bound + 3 bound) ----
|
// ---- Physics (6: 6 bound) ----
|
||||||
|
Grade10LabEntry(
|
||||||
|
identity: kPhysicsVectorsIntroLabIdentity,
|
||||||
|
builder: (cb) => PhysicsVectorsIntroLabView(onCheckpointTriggered: cb),
|
||||||
|
),
|
||||||
Grade10LabEntry(
|
Grade10LabEntry(
|
||||||
identity: kPhysicsVectorAdditionLabIdentity,
|
identity: kPhysicsVectorAdditionLabIdentity,
|
||||||
builder: (cb) => PhysicsVectorAdditionLabView(onCheckpointTriggered: cb),
|
builder: (cb) => PhysicsVectorAdditionLabView(onCheckpointTriggered: cb),
|
||||||
@@ -74,6 +82,11 @@ class Grade10LabsRegistry {
|
|||||||
identity: kPhysicsMotion1DLabIdentity,
|
identity: kPhysicsMotion1DLabIdentity,
|
||||||
builder: (cb) => PhysicsMotion1DLabView(onCheckpointTriggered: cb),
|
builder: (cb) => PhysicsMotion1DLabView(onCheckpointTriggered: cb),
|
||||||
),
|
),
|
||||||
|
Grade10LabEntry(
|
||||||
|
identity: kPhysicsProjectileMotionLabIdentity,
|
||||||
|
builder: (cb) =>
|
||||||
|
PhysicsProjectileMotionLabView(onCheckpointTriggered: cb),
|
||||||
|
),
|
||||||
Grade10LabEntry(
|
Grade10LabEntry(
|
||||||
identity: kPhysicsCircularMotionLabIdentity,
|
identity: kPhysicsCircularMotionLabIdentity,
|
||||||
builder: (cb) => PhysicsCircularMotionLabView(onCheckpointTriggered: cb),
|
builder: (cb) => PhysicsCircularMotionLabView(onCheckpointTriggered: cb),
|
||||||
@@ -117,7 +130,7 @@ class Grade10LabsRegistry {
|
|||||||
identity: kBiologyMicroscopeToolIdentity,
|
identity: kBiologyMicroscopeToolIdentity,
|
||||||
builder: (cb) => BiologyMicroscopeLabView(onCheckpointTriggered: cb),
|
builder: (cb) => BiologyMicroscopeLabView(onCheckpointTriggered: cb),
|
||||||
),
|
),
|
||||||
// ---- Earth (3: 2 tools + 1 bound) ----
|
// ---- Earth (4: 2 tools + 2 bound) ----
|
||||||
Grade10LabEntry(
|
Grade10LabEntry(
|
||||||
identity: kEarthMohsHardnessToolIdentity,
|
identity: kEarthMohsHardnessToolIdentity,
|
||||||
builder: (cb) => EarthMohsHardnessLabView(onCheckpointTriggered: cb),
|
builder: (cb) => EarthMohsHardnessLabView(onCheckpointTriggered: cb),
|
||||||
@@ -130,6 +143,10 @@ class Grade10LabsRegistry {
|
|||||||
identity: kEarthStratigraphyToolIdentity,
|
identity: kEarthStratigraphyToolIdentity,
|
||||||
builder: (cb) => EarthStratigraphyLabView(onCheckpointTriggered: cb),
|
builder: (cb) => EarthStratigraphyLabView(onCheckpointTriggered: cb),
|
||||||
),
|
),
|
||||||
|
Grade10LabEntry(
|
||||||
|
identity: kEarthAirMassesLabIdentity,
|
||||||
|
builder: (cb) => EarthAirMassesLabView(onCheckpointTriggered: cb),
|
||||||
|
),
|
||||||
// ---- Math (3 bound) ----
|
// ---- Math (3 bound) ----
|
||||||
Grade10LabEntry(
|
Grade10LabEntry(
|
||||||
identity: kMathSystemsLabIdentity,
|
identity: kMathSystemsLabIdentity,
|
||||||
@@ -152,7 +169,11 @@ class Grade10LabsRegistry {
|
|||||||
identity: kEnglishTenseToolIdentity,
|
identity: kEnglishTenseToolIdentity,
|
||||||
builder: (cb) => EnglishTenseTimelineLabView(onCheckpointTriggered: cb),
|
builder: (cb) => EnglishTenseTimelineLabView(onCheckpointTriggered: cb),
|
||||||
),
|
),
|
||||||
// ---- Arabic (13: 2 tools + 11 bound) ----
|
// ---- Arabic (14: 2 tools + 12 bound) ----
|
||||||
|
Grade10LabEntry(
|
||||||
|
identity: kArabicUnit2InshaLabIdentity,
|
||||||
|
builder: (cb) => ArabicUnit2InshaLabView(onCheckpointTriggered: cb),
|
||||||
|
),
|
||||||
Grade10LabEntry(
|
Grade10LabEntry(
|
||||||
identity: kArabicUnit2VocativeLabIdentity,
|
identity: kArabicUnit2VocativeLabIdentity,
|
||||||
builder: (cb) => ArabicUnit2VocativeLabView(onCheckpointTriggered: cb),
|
builder: (cb) => ArabicUnit2VocativeLabView(onCheckpointTriggered: cb),
|
||||||
@@ -207,13 +228,13 @@ class Grade10LabsRegistry {
|
|||||||
identity: kArabicProsodyToolIdentity,
|
identity: kArabicProsodyToolIdentity,
|
||||||
builder: (cb) => ArabicProsodyLabView(onCheckpointTriggered: cb),
|
builder: (cb) => ArabicProsodyLabView(onCheckpointTriggered: cb),
|
||||||
),
|
),
|
||||||
// ---- Islamic (2 tools) ----
|
// ---- Islamic (2 bound curriculum labs) ----
|
||||||
Grade10LabEntry(
|
Grade10LabEntry(
|
||||||
identity: kIslamicTajweedToolIdentity,
|
identity: kIslamicTajweedLabIdentity,
|
||||||
builder: (cb) => IslamicTajweedLabView(onCheckpointTriggered: cb),
|
builder: (cb) => IslamicTajweedLabView(onCheckpointTriggered: cb),
|
||||||
),
|
),
|
||||||
Grade10LabEntry(
|
Grade10LabEntry(
|
||||||
identity: kIslamicInheritanceToolIdentity,
|
identity: kIslamicInheritanceLabIdentity,
|
||||||
builder: (cb) => IslamicInheritanceLabView(onCheckpointTriggered: cb),
|
builder: (cb) => IslamicInheritanceLabView(onCheckpointTriggered: cb),
|
||||||
),
|
),
|
||||||
// ---- Finance (3: 1 tool + 2 bound) ----
|
// ---- Finance (3: 1 tool + 2 bound) ----
|
||||||
@@ -382,7 +403,7 @@ class Grade10LabsRegistry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return candidates.first;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+645
@@ -0,0 +1,645 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import 'lab_identity.dart';
|
||||||
|
import 'lab_scaffold.dart';
|
||||||
|
|
||||||
|
/// ============================================================================
|
||||||
|
/// PHYSICS — GRADE 10 VIRTUAL LAB (Jordanian MoE curriculum)
|
||||||
|
/// Unit 2, Lesson 2: حركة المقذوفات في بعدين (Projectile Motion)
|
||||||
|
/// Curriculum Lesson ID: physics_10_semester_1_unit_02_lesson_02
|
||||||
|
///
|
||||||
|
/// Textbook mapping (Pages 57-66):
|
||||||
|
/// - Horizontal motion: constant speed ax = 0, Vx = V0 cos(θ)
|
||||||
|
/// - Vertical motion: free fall ay = -g, Vy = V0 sin(θ) - g t
|
||||||
|
/// - Time to apex: th = (V0 sin θ) / g
|
||||||
|
/// - Total flight time: T = 2 th = (2 V0 sin θ) / g
|
||||||
|
/// - Maximum height: h = (V0 sin θ)^2 / (2g)
|
||||||
|
/// - Range: R = (V0^2 sin(2θ)) / g (Max range at 45°; equal for complementary angles θ & 90-θ)
|
||||||
|
/// ============================================================================
|
||||||
|
|
||||||
|
class PhysicsProjectileMotionLabView extends StatefulWidget {
|
||||||
|
final LabCheckpointCallback? onCheckpointTriggered;
|
||||||
|
const PhysicsProjectileMotionLabView({super.key, this.onCheckpointTriggered});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PhysicsProjectileMotionLabView> createState() =>
|
||||||
|
_PhysicsProjectileMotionLabViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PhysicsProjectileMotionLabViewState
|
||||||
|
extends State<PhysicsProjectileMotionLabView>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
double _v0 = 24.0; // Initial velocity m/s (10..40)
|
||||||
|
double _angleDeg = 45.0; // Launch angle (15..80)
|
||||||
|
final double _gravity = 9.8; // m/s^2
|
||||||
|
|
||||||
|
bool _showVelocityVectors = true;
|
||||||
|
bool _showComplementaryTrajectory = false;
|
||||||
|
bool _slowMotion = false;
|
||||||
|
|
||||||
|
// Animation / simulation state:
|
||||||
|
bool _isPlaying = false;
|
||||||
|
double _simTime = 0.0; // elapsed time in seconds
|
||||||
|
late final AnimationController _animCtl;
|
||||||
|
final List<Offset> _firedPoints = [];
|
||||||
|
|
||||||
|
// Target challenge:
|
||||||
|
final double _targetDistance = 48.0; // meters
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_animCtl = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(seconds: 10),
|
||||||
|
)..addListener(_tickSimulation);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _tickSimulation() {
|
||||||
|
if (!_isPlaying) return;
|
||||||
|
final totalFlightTime = _calcFlightTime(_v0, _angleDeg);
|
||||||
|
final dt = _slowMotion ? 0.012 : 0.028;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_simTime += dt;
|
||||||
|
final currentPos = _calcPositionAtTime(_simTime, _v0, _angleDeg);
|
||||||
|
_firedPoints.add(currentPos);
|
||||||
|
|
||||||
|
if (_simTime >= totalFlightTime) {
|
||||||
|
_simTime = totalFlightTime;
|
||||||
|
_isPlaying = false;
|
||||||
|
_animCtl.stop();
|
||||||
|
saqelTick();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_animCtl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Physics Helpers ---
|
||||||
|
double _calcFlightTime(double v0, double ang) {
|
||||||
|
final rad = ang * math.pi / 180.0;
|
||||||
|
return (2.0 * v0 * math.sin(rad)) / _gravity;
|
||||||
|
}
|
||||||
|
|
||||||
|
double _calcMaxHeight(double v0, double ang) {
|
||||||
|
final rad = ang * math.pi / 180.0;
|
||||||
|
final vy0 = v0 * math.sin(rad);
|
||||||
|
return (vy0 * vy0) / (2.0 * _gravity);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _calcRange(double v0, double ang) {
|
||||||
|
final rad = ang * math.pi / 180.0;
|
||||||
|
return (v0 * v0 * math.sin(2.0 * rad)) / _gravity;
|
||||||
|
}
|
||||||
|
|
||||||
|
Offset _calcPositionAtTime(double t, double v0, double ang) {
|
||||||
|
final rad = ang * math.pi / 180.0;
|
||||||
|
final vx = v0 * math.cos(rad);
|
||||||
|
final vy0 = v0 * math.sin(rad);
|
||||||
|
final x = vx * t;
|
||||||
|
final y = vy0 * t - 0.5 * _gravity * t * t;
|
||||||
|
return Offset(x, math.max(0.0, y));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _launchProjectile() {
|
||||||
|
saqelTick();
|
||||||
|
setState(() {
|
||||||
|
_isPlaying = true;
|
||||||
|
_simTime = 0.0;
|
||||||
|
_firedPoints.clear();
|
||||||
|
});
|
||||||
|
_animCtl.repeat();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resetSimulation() {
|
||||||
|
saqelTick();
|
||||||
|
setState(() {
|
||||||
|
_isPlaying = false;
|
||||||
|
_simTime = 0.0;
|
||||||
|
_firedPoints.clear();
|
||||||
|
_animCtl.reset();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final flightTime = _calcFlightTime(_v0, _angleDeg);
|
||||||
|
final maxHeight = _calcMaxHeight(_v0, _angleDeg);
|
||||||
|
final totalRange = _calcRange(_v0, _angleDeg);
|
||||||
|
|
||||||
|
// Current instant values:
|
||||||
|
final rad = _angleDeg * math.pi / 180.0;
|
||||||
|
final curVx = _v0 * math.cos(rad);
|
||||||
|
final curVy = _v0 * math.sin(rad) - _gravity * _simTime;
|
||||||
|
final isAtApex = curVy.abs() < 1.0;
|
||||||
|
final isTargetHit = (_calcRange(_v0, _angleDeg) - _targetDistance).abs() <= 2.2;
|
||||||
|
|
||||||
|
return SaqelLabScaffold(
|
||||||
|
titleAr: 'حركة المقذوفات في بُعدين',
|
||||||
|
subtitleAr: 'مسار منحني • مركبتان متعامدتان vx و vy • المدى الأقصى والارتفاع',
|
||||||
|
identity: kPhysicsProjectileMotionLabIdentity,
|
||||||
|
onCheckpointTriggered: widget.onCheckpointTriggered,
|
||||||
|
checkpointQuestion:
|
||||||
|
'عند وصول المقذوف إلى أقصى ارتفاع رأسي (Apex)، كم تكون قيمة المركبة الرأسية للسرعة vy؟',
|
||||||
|
checkpointOptions: const [
|
||||||
|
'تساوي صفراً لحظياً، بينما تبقى السرعة الأفقية vx ثابتة',
|
||||||
|
'تكون في قيمتها العظمى القصوى متجهة لأعلى',
|
||||||
|
'تساوي تسارع الجاذبية الأرضية g مضروباً في 2',
|
||||||
|
'تنعكس فوراً دون المرور بنقطة السكون اللحظي',
|
||||||
|
],
|
||||||
|
checkpointCorrectIdx: 0,
|
||||||
|
telemetry: [
|
||||||
|
LabPill('المدى R = ${totalRange.toStringAsFixed(1)} m'),
|
||||||
|
LabPill('أقصى ارتفاع h = ${maxHeight.toStringAsFixed(1)} m',
|
||||||
|
color: const Color(0xFFFF9F0A)),
|
||||||
|
LabPill('زمن التحليق T = ${flightTime.toStringAsFixed(2)} s',
|
||||||
|
color: const Color(0xFF30D158)),
|
||||||
|
if (isTargetHit)
|
||||||
|
const LabPill('إصابة مباشرة للهدف! 🎯', color: Color(0xFF00F5D4)),
|
||||||
|
],
|
||||||
|
canvas: CustomPaint(
|
||||||
|
painter: _ProjectileCanvasPainter(
|
||||||
|
v0: _v0,
|
||||||
|
angleDeg: _angleDeg,
|
||||||
|
gravity: _gravity,
|
||||||
|
simTime: _simTime,
|
||||||
|
isPlaying: _isPlaying,
|
||||||
|
showVelocityVectors: _showVelocityVectors,
|
||||||
|
showComplementary: _showComplementaryTrajectory,
|
||||||
|
targetDistance: _targetDistance,
|
||||||
|
firedPoints: List.of(_firedPoints),
|
||||||
|
),
|
||||||
|
child: Container(),
|
||||||
|
),
|
||||||
|
controls: [
|
||||||
|
// Launch and Reset row:
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
icon: Icon(
|
||||||
|
_isPlaying ? CupertinoIcons.pause_fill : CupertinoIcons.play_arrow_solid,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
_isPlaying ? 'إيقاف مؤقت' : 'إطلاق القذيفة 🚀',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w800, fontSize: 13),
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.saqelCyan,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
),
|
||||||
|
onPressed: _launchProjectile,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
flex: 1,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
icon: const Icon(CupertinoIcons.arrow_counterclockwise, size: 16),
|
||||||
|
label: const Text('إعادة', style: TextStyle(fontSize: 12)),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.white70,
|
||||||
|
side: const BorderSide(color: Colors.white24),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
),
|
||||||
|
onPressed: _resetSimulation,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Angle Slider with presets:
|
||||||
|
LabSlider(
|
||||||
|
label: 'زاوية الإطلاق θ',
|
||||||
|
value: _angleDeg,
|
||||||
|
min: 15.0,
|
||||||
|
max: 80.0,
|
||||||
|
display: '${_angleDeg.toInt()}°',
|
||||||
|
accent: _angleDeg == 45.0 ? const Color(0xFF00F5D4) : const Color(0xFFFF9F0A),
|
||||||
|
onChanged: (v) => setState(() {
|
||||||
|
_angleDeg = v;
|
||||||
|
if (!_isPlaying) _firedPoints.clear();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Quick angle presets:
|
||||||
|
SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_buildPresetChip('30°', 30.0),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
_buildPresetChip('45° (أقصى مدى)', 45.0, isSpecial: true),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
_buildPresetChip('60° (متممة لـ 30°)', 60.0),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
_buildPresetChip('53° (مثال 12 ص61)', 53.0),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
|
// Initial Velocity Slider:
|
||||||
|
LabSlider(
|
||||||
|
label: 'السرعة الابتدائية v₀',
|
||||||
|
value: _v0,
|
||||||
|
min: 12.0,
|
||||||
|
max: 36.0,
|
||||||
|
display: '${_v0.toStringAsFixed(1)} m/s',
|
||||||
|
onChanged: (v) => setState(() {
|
||||||
|
_v0 = v;
|
||||||
|
if (!_isPlaying) _firedPoints.clear();
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
LabToggle(
|
||||||
|
label: 'إظهار مركبات السرعة المتجهة (vx و vy)',
|
||||||
|
hint: 'vx أفقية ثابتة • vy رأسية تتغير بالجاذبية',
|
||||||
|
value: _showVelocityVectors,
|
||||||
|
onChanged: (v) => setState(() => _showVelocityVectors = v),
|
||||||
|
),
|
||||||
|
LabToggle(
|
||||||
|
label: 'مقارنة الزاوية المتممة (${(90 - _angleDeg).toInt()}°)',
|
||||||
|
hint: 'الزاويتان المتتامتان لهما المدى الأفقي نفسه R',
|
||||||
|
value: _showComplementaryTrajectory,
|
||||||
|
onChanged: (v) => setState(() => _showComplementaryTrajectory = v),
|
||||||
|
),
|
||||||
|
LabToggle(
|
||||||
|
label: 'تصوير بالحركة البطيئة (Slow Motion)',
|
||||||
|
hint: 'لدراسة انعدام vy عند القمة (Apex)',
|
||||||
|
value: _slowMotion,
|
||||||
|
onChanged: (v) => setState(() => _slowMotion = v),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
LabFormulaCard(
|
||||||
|
title: isAtApex && _isPlaying
|
||||||
|
? 'القمة اللحظية! vy = 0 m/s و vx = ${curVx.toStringAsFixed(1)} m/s'
|
||||||
|
: 'معادلات حركة المقذوفات (منهاج الوزارة)',
|
||||||
|
body:
|
||||||
|
'vx = v₀·cosθ = ${curVx.toStringAsFixed(1)} m/s (ثابتة دوماً)\n'
|
||||||
|
'vy = v₀·sinθ − g·t = ${curVy.toStringAsFixed(1)} m/s\n'
|
||||||
|
'المدى الأفقي: R = (v₀²·sin 2θ) / g = ${totalRange.toStringAsFixed(1)} m\n'
|
||||||
|
'أقصى ارتفاع: h = (v₀·sinθ)² / 2g = ${maxHeight.toStringAsFixed(1)} m',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
footerNote:
|
||||||
|
'بإهمال مقاومة الهواء • نموذج المقذوفات الأردني المعتمد (الوحدة 2: الصفحات 57 - 66).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPresetChip(String label, double val, {bool isSpecial = false}) {
|
||||||
|
final isSelected = (_angleDeg - val).abs() < 0.5;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => setState(() {
|
||||||
|
saqelTick();
|
||||||
|
_angleDeg = val;
|
||||||
|
if (!_isPlaying) _firedPoints.clear();
|
||||||
|
}),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? (isSpecial ? AppColors.saqelCyan : const Color(0xFFFF9F0A))
|
||||||
|
: Colors.white.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected ? Colors.transparent : Colors.white24,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isSelected ? Colors.black : Colors.white70,
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: isSelected ? FontWeight.w800 : FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ============================================================================
|
||||||
|
/// CANVAS PAINTER: Projectile Flight, Vectors, and Target
|
||||||
|
/// ============================================================================
|
||||||
|
class _ProjectileCanvasPainter extends CustomPainter {
|
||||||
|
final double v0;
|
||||||
|
final double angleDeg;
|
||||||
|
final double gravity;
|
||||||
|
final double simTime;
|
||||||
|
final bool isPlaying;
|
||||||
|
final bool showVelocityVectors;
|
||||||
|
final bool showComplementary;
|
||||||
|
final double targetDistance;
|
||||||
|
final List<Offset> firedPoints;
|
||||||
|
|
||||||
|
_ProjectileCanvasPainter({
|
||||||
|
required this.v0,
|
||||||
|
required this.angleDeg,
|
||||||
|
required this.gravity,
|
||||||
|
required this.simTime,
|
||||||
|
required this.isPlaying,
|
||||||
|
required this.showVelocityVectors,
|
||||||
|
required this.showComplementary,
|
||||||
|
required this.targetDistance,
|
||||||
|
required this.firedPoints,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas c, Size s) {
|
||||||
|
// 1. Dark space backdrop
|
||||||
|
c.drawRect(
|
||||||
|
Offset.zero & s,
|
||||||
|
Paint()
|
||||||
|
..shader = const LinearGradient(
|
||||||
|
colors: [Color(0xFF07111F), Color(0xFF0B1728)],
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
).createShader(Offset.zero & s),
|
||||||
|
);
|
||||||
|
|
||||||
|
final groundY = s.height * 0.82;
|
||||||
|
const originX = 42.0;
|
||||||
|
|
||||||
|
// Scale factors from meters to screen pixels:
|
||||||
|
// Max horizontal range is around 130m, we scale to fit width:
|
||||||
|
final scaleX = (s.width - 80.0) / 100.0;
|
||||||
|
final scaleY = (groundY - 30.0) / 45.0;
|
||||||
|
|
||||||
|
// 2. Draw Distance Grid and Ground
|
||||||
|
_drawGroundAndGrid(c, s, groundY, originX, scaleX);
|
||||||
|
|
||||||
|
// 3. Draw Target Marker
|
||||||
|
final targetScreenX = originX + targetDistance * scaleX;
|
||||||
|
_drawTarget(c, targetScreenX, groundY);
|
||||||
|
|
||||||
|
// 4. Draw Theoretical Trajectory (Parabola)
|
||||||
|
_drawParabola(c, originX, groundY, scaleX, scaleY, v0, angleDeg,
|
||||||
|
const Color(0xFF00F5D4).withValues(alpha: 0.55), isDashed: true);
|
||||||
|
|
||||||
|
// 5. If complementary angle enabled (90 - theta):
|
||||||
|
if (showComplementary) {
|
||||||
|
final compAngle = 90.0 - angleDeg;
|
||||||
|
_drawParabola(c, originX, groundY, scaleX, scaleY, v0, compAngle,
|
||||||
|
const Color(0xFFFF9F0A).withValues(alpha: 0.45), isDashed: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Draw Cannon / Launcher at origin
|
||||||
|
_drawCannon(c, originX, groundY, angleDeg);
|
||||||
|
|
||||||
|
// 7. Draw Trajectory Trail of Fired Points
|
||||||
|
if (firedPoints.isNotEmpty) {
|
||||||
|
final trailPath = Path();
|
||||||
|
for (int i = 0; i < firedPoints.length; i++) {
|
||||||
|
final sx = originX + firedPoints[i].dx * scaleX;
|
||||||
|
final sy = groundY - firedPoints[i].dy * scaleY;
|
||||||
|
if (i == 0) {
|
||||||
|
trailPath.moveTo(sx, sy);
|
||||||
|
} else {
|
||||||
|
trailPath.lineTo(sx, sy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.drawPath(
|
||||||
|
trailPath,
|
||||||
|
Paint()
|
||||||
|
..color = AppColors.saqelCyan.withValues(alpha: 0.85)
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 2.4,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Draw Moving Projectile Ball and Velocity Vectors
|
||||||
|
final rad = angleDeg * math.pi / 180.0;
|
||||||
|
final curX = v0 * math.cos(rad) * simTime;
|
||||||
|
final curY = v0 * math.sin(rad) * simTime - 0.5 * gravity * simTime * simTime;
|
||||||
|
|
||||||
|
final ballX = originX + curX * scaleX;
|
||||||
|
final ballY = groundY - math.max(0.0, curY) * scaleY;
|
||||||
|
|
||||||
|
// Glowing projectile ball
|
||||||
|
c.drawCircle(
|
||||||
|
Offset(ballX, ballY),
|
||||||
|
8.0,
|
||||||
|
Paint()
|
||||||
|
..color = const Color(0xFF00F5D4)
|
||||||
|
..maskFilter = const MaskFilter.blur(BlurStyle.solid, 4),
|
||||||
|
);
|
||||||
|
c.drawCircle(Offset(ballX, ballY), 5.5, Paint()..color = Colors.white);
|
||||||
|
|
||||||
|
// Velocity Vectors on the ball:
|
||||||
|
if (showVelocityVectors && curY >= 0.0) {
|
||||||
|
final vx = v0 * math.cos(rad);
|
||||||
|
final vy = v0 * math.sin(rad) - gravity * simTime;
|
||||||
|
|
||||||
|
// Horizontal Vx vector (Cyan, constant)
|
||||||
|
_drawVec(c, Offset(ballX, ballY), Offset(ballX + vx * 1.5, ballY),
|
||||||
|
const Color(0xFF00F5D4), 'vx');
|
||||||
|
|
||||||
|
// Vertical Vy vector (Amber, dynamic)
|
||||||
|
if (vy.abs() > 0.8) {
|
||||||
|
_drawVec(c, Offset(ballX, ballY), Offset(ballX, ballY - vy * 1.5),
|
||||||
|
const Color(0xFFFF9F0A), 'vy');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gravitational acceleration vector g (pointing down)
|
||||||
|
_drawVec(c, Offset(ballX, ballY), Offset(ballX, ballY + 28),
|
||||||
|
const Color(0xFFFF375F), 'g');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawGroundAndGrid(
|
||||||
|
Canvas c, Size s, double groundY, double originX, double scaleX) {
|
||||||
|
// Ground line
|
||||||
|
final groundPaint = Paint()
|
||||||
|
..color = const Color(0xFF1E293B)
|
||||||
|
..strokeWidth = 4;
|
||||||
|
c.drawLine(Offset(0, groundY), Offset(s.width, groundY), groundPaint);
|
||||||
|
|
||||||
|
// Grass / surface accents
|
||||||
|
final grass = Paint()
|
||||||
|
..color = const Color(0xFF10B981).withValues(alpha: 0.6)
|
||||||
|
..strokeWidth = 2;
|
||||||
|
c.drawLine(Offset(0, groundY), Offset(s.width, groundY), grass);
|
||||||
|
|
||||||
|
// Ticks every 10 meters
|
||||||
|
for (int m = 10; m <= 90; m += 10) {
|
||||||
|
final tx = originX + m * scaleX;
|
||||||
|
if (tx > s.width - 10) break;
|
||||||
|
c.drawLine(
|
||||||
|
Offset(tx, groundY),
|
||||||
|
Offset(tx, groundY + 6),
|
||||||
|
Paint()..color = Colors.white30,
|
||||||
|
);
|
||||||
|
final tp = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: '$m m',
|
||||||
|
style: const TextStyle(color: Colors.white38, fontSize: 9),
|
||||||
|
),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
)..layout();
|
||||||
|
tp.paint(c, Offset(tx - tp.width / 2, groundY + 8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawTarget(Canvas c, double tx, double groundY) {
|
||||||
|
// Target base flag / bullseye
|
||||||
|
c.drawCircle(
|
||||||
|
Offset(tx, groundY),
|
||||||
|
9,
|
||||||
|
Paint()
|
||||||
|
..color = const Color(0xFFFF375F).withValues(alpha: 0.3)
|
||||||
|
..style = PaintingStyle.fill,
|
||||||
|
);
|
||||||
|
c.drawCircle(
|
||||||
|
Offset(tx, groundY),
|
||||||
|
9,
|
||||||
|
Paint()
|
||||||
|
..color = const Color(0xFFFF375F)
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 2,
|
||||||
|
);
|
||||||
|
c.drawCircle(Offset(tx, groundY), 3.5, Paint()..color = Colors.white);
|
||||||
|
|
||||||
|
// Flag pole
|
||||||
|
c.drawLine(
|
||||||
|
Offset(tx, groundY),
|
||||||
|
Offset(tx, groundY - 24),
|
||||||
|
Paint()
|
||||||
|
..color = Colors.white70
|
||||||
|
..strokeWidth = 1.5,
|
||||||
|
);
|
||||||
|
final flagPath = Path()
|
||||||
|
..moveTo(tx, groundY - 24)
|
||||||
|
..lineTo(tx + 14, groundY - 18)
|
||||||
|
..lineTo(tx, groundY - 12)
|
||||||
|
..close();
|
||||||
|
c.drawPath(flagPath, Paint()..color = const Color(0xFFFF375F));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawParabola(
|
||||||
|
Canvas c,
|
||||||
|
double originX,
|
||||||
|
double groundY,
|
||||||
|
double scaleX,
|
||||||
|
double scaleY,
|
||||||
|
double v0,
|
||||||
|
double ang,
|
||||||
|
Color col, {
|
||||||
|
bool isDashed = false,
|
||||||
|
}) {
|
||||||
|
final rad = ang * math.pi / 180.0;
|
||||||
|
final totalFlightTime = (2.0 * v0 * math.sin(rad)) / gravity;
|
||||||
|
final path = Path();
|
||||||
|
|
||||||
|
const steps = 60;
|
||||||
|
for (int i = 0; i <= steps; i++) {
|
||||||
|
final t = (totalFlightTime * i) / steps;
|
||||||
|
final x = v0 * math.cos(rad) * t;
|
||||||
|
final y = v0 * math.sin(rad) * t - 0.5 * gravity * t * t;
|
||||||
|
|
||||||
|
final sx = originX + x * scaleX;
|
||||||
|
final sy = groundY - math.max(0.0, y) * scaleY;
|
||||||
|
|
||||||
|
if (i == 0) {
|
||||||
|
path.moveTo(sx, sy);
|
||||||
|
} else {
|
||||||
|
path.lineTo(sx, sy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.drawPath(
|
||||||
|
path,
|
||||||
|
Paint()
|
||||||
|
..color = col
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1.6,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawCannon(Canvas c, double ox, double gy, double angDeg) {
|
||||||
|
c.save();
|
||||||
|
c.translate(ox, gy);
|
||||||
|
|
||||||
|
// Cannon base wheel
|
||||||
|
c.drawCircle(const Offset(0, -6), 11, Paint()..color = const Color(0xFF334155));
|
||||||
|
c.drawCircle(
|
||||||
|
const Offset(0, -6),
|
||||||
|
11,
|
||||||
|
Paint()
|
||||||
|
..color = Colors.white30
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1.5,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Barrel rotation
|
||||||
|
c.rotate(-angDeg * math.pi / 180.0);
|
||||||
|
final barrelRect =
|
||||||
|
RRect.fromRectAndRadius(const Rect.fromLTWH(0, -5, 26, 10), const Radius.circular(3));
|
||||||
|
c.drawRRect(barrelRect, Paint()..color = AppColors.saqelCyan);
|
||||||
|
c.drawRRect(
|
||||||
|
barrelRect,
|
||||||
|
Paint()
|
||||||
|
..color = Colors.white
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1.2,
|
||||||
|
);
|
||||||
|
|
||||||
|
c.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawVec(Canvas c, Offset a, Offset b, Color col, String label) {
|
||||||
|
final p = Paint()
|
||||||
|
..color = col
|
||||||
|
..strokeWidth = 2.4
|
||||||
|
..strokeCap = StrokeCap.round;
|
||||||
|
c.drawLine(a, b, p);
|
||||||
|
|
||||||
|
final ang = math.atan2(b.dy - a.dy, b.dx - a.dx);
|
||||||
|
const hs = 7.0;
|
||||||
|
final p1 = b - Offset(math.cos(ang - 0.45) * hs, math.sin(ang - 0.45) * hs);
|
||||||
|
final p2 = b - Offset(math.cos(ang + 0.45) * hs, math.sin(ang + 0.45) * hs);
|
||||||
|
c.drawPath(
|
||||||
|
Path()
|
||||||
|
..moveTo(b.dx, b.dy)
|
||||||
|
..lineTo(p1.dx, p1.dy)
|
||||||
|
..lineTo(p2.dx, p2.dy)
|
||||||
|
..close(),
|
||||||
|
Paint()..color = col,
|
||||||
|
);
|
||||||
|
|
||||||
|
final tp = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: label,
|
||||||
|
style: TextStyle(color: col, fontSize: 10, fontWeight: FontWeight.w900),
|
||||||
|
),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
)..layout();
|
||||||
|
tp.paint(c, b + const Offset(3, -12));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant _ProjectileCanvasPainter o) =>
|
||||||
|
o.v0 != v0 ||
|
||||||
|
o.angleDeg != angleDeg ||
|
||||||
|
o.simTime != simTime ||
|
||||||
|
o.isPlaying != isPlaying ||
|
||||||
|
o.showVelocityVectors != showVelocityVectors ||
|
||||||
|
o.showComplementary != showComplementary ||
|
||||||
|
o.firedPoints.length != firedPoints.length;
|
||||||
|
}
|
||||||
+739
@@ -0,0 +1,739 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import 'lab_identity.dart';
|
||||||
|
import 'lab_scaffold.dart';
|
||||||
|
|
||||||
|
/// ============================================================================
|
||||||
|
/// PHYSICS — GRADE 10 VIRTUAL LAB (Jordanian MoE curriculum)
|
||||||
|
/// Unit 1, Lesson 1: الكميات القياسية والكميات المتجهة وتمثيلها بيانياً
|
||||||
|
/// Curriculum Lesson ID: physics_10_semester_1_unit_01_lesson_01
|
||||||
|
///
|
||||||
|
/// Direct textbook mapping (Pages 5-19):
|
||||||
|
/// 1. ظاهرة هبوط الطائرات في الرياح المتقاطعة (Crosswind Landing - صفحة 5)
|
||||||
|
/// - توجيه الطائرة ضد الرياح لتكون السرعة المحصلة منطبقة على محور المدرج.
|
||||||
|
/// 2. تمثيل المتجهات وخصائصها ومضاعفاتها وسالب المتجه (صفحات 8-12)
|
||||||
|
/// - سحب المتجه، تغيير مقياس الرسم، ضرب المتجه بكمية قياسية n، وسالب المتجه (-A).
|
||||||
|
/// 3. ضرب المتجهات: الضرب القياسي (A·B = AB cos θ) والضرب المتجهي (|A×B| = AB sin θ)
|
||||||
|
/// - عرض مساحة متوازي الأضلاع وقاعدة اليد اليمنى وحالة التساوي عند θ = 45°.
|
||||||
|
/// ============================================================================
|
||||||
|
|
||||||
|
class PhysicsVectorsIntroLabView extends StatefulWidget {
|
||||||
|
final LabCheckpointCallback? onCheckpointTriggered;
|
||||||
|
const PhysicsVectorsIntroLabView({super.key, this.onCheckpointTriggered});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PhysicsVectorsIntroLabView> createState() =>
|
||||||
|
_PhysicsVectorsIntroLabViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PhysicsVectorsIntroLabViewState extends State<PhysicsVectorsIntroLabView>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
// 0: هبوط الرياح المتقاطعة, 1: تمثيل وسالب المتجه, 2: الضرب النقطي والتقاطعي
|
||||||
|
int _activeTab = 0;
|
||||||
|
|
||||||
|
// --- TAB 0: Crosswind Landing ---
|
||||||
|
double _planeHeading = -18.0; // Heading deviation in degrees (-45..+45)
|
||||||
|
double _windSpeed = 22.0; // Crosswind speed knots (-40..+40)
|
||||||
|
final double _planeAirspeed = 70.0; // knots airspeed
|
||||||
|
bool _isLanding = false;
|
||||||
|
double _landingProgress = 0.0;
|
||||||
|
late final AnimationController _landingCtl;
|
||||||
|
|
||||||
|
// --- TAB 1: Vector Representation & Properties ---
|
||||||
|
double _vecMag = 80.0; // magnitude (20..140)
|
||||||
|
double _vecAngle = 40.0; // angle in degrees (0..360)
|
||||||
|
double _scalarMultiplier = 1.5; // n in [-2.0..2.0]
|
||||||
|
int _selectedQuantityIdx = 0; // 0 = Force (vector), 1 = Mass (scalar)
|
||||||
|
|
||||||
|
// --- TAB 2: Dot & Cross Product ---
|
||||||
|
double _dotMagA = 90.0;
|
||||||
|
double _dotMagB = 75.0;
|
||||||
|
double _dotAngle = 45.0; // Angle between A and B (0..180)
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_landingCtl = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 2200),
|
||||||
|
)..addListener(() {
|
||||||
|
setState(() {
|
||||||
|
_landingProgress = _landingCtl.value;
|
||||||
|
if (_landingCtl.isCompleted) {
|
||||||
|
_isLanding = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_landingCtl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _triggerLanding() {
|
||||||
|
saqelTick();
|
||||||
|
setState(() {
|
||||||
|
_isLanding = true;
|
||||||
|
_landingProgress = 0.0;
|
||||||
|
});
|
||||||
|
_landingCtl.forward(from: 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Crosswind physics calculations:
|
||||||
|
// Runway is along the Y axis (0 deg is straight down the runway).
|
||||||
|
// Plane velocity: Vx = V_air * sin(heading), Vy = V_air * cos(heading)
|
||||||
|
// Wind velocity: Wx = windSpeed (pure crosswind), Wy = 0
|
||||||
|
final headingRad = _planeHeading * math.pi / 180.0;
|
||||||
|
final vxPlane = _planeAirspeed * math.sin(headingRad);
|
||||||
|
final vyPlane = _planeAirspeed * math.cos(headingRad);
|
||||||
|
final vxGround = vxPlane + _windSpeed;
|
||||||
|
final vyGround = vyPlane;
|
||||||
|
final vGroundMag = math.sqrt(vxGround * vxGround + vyGround * vyGround);
|
||||||
|
final groundTrackDeg = math.atan2(vxGround, vyGround) * 180.0 / math.pi;
|
||||||
|
final bool isAligned = groundTrackDeg.abs() <= 3.0;
|
||||||
|
|
||||||
|
// Dot and Cross physics calculations:
|
||||||
|
final radTheta = _dotAngle * math.pi / 180.0;
|
||||||
|
final dotProduct = _dotMagA * _dotMagB * math.cos(radTheta) / 100.0;
|
||||||
|
final crossProduct = _dotMagA * _dotMagB * math.sin(radTheta) / 100.0;
|
||||||
|
final bool isEqualed45 = (_dotAngle - 45.0).abs() <= 1.5;
|
||||||
|
|
||||||
|
return SaqelLabScaffold(
|
||||||
|
titleAr: 'الكميات القياسية والمتجهة وتمثيلها',
|
||||||
|
subtitleAr: 'الرياح المتقاطعة • تمثيل وسالب المتجهات • الضرب القياسي والمتجهي',
|
||||||
|
identity: kPhysicsVectorsIntroLabIdentity,
|
||||||
|
onCheckpointTriggered: widget.onCheckpointTriggered,
|
||||||
|
checkpointQuestion:
|
||||||
|
'في تجربة هبوط الطائرات مع رياح متقاطعة (Crosswind)، لتفادي خروج الطائرة عن المدرج يجب أن تكون …',
|
||||||
|
checkpointOptions: const [
|
||||||
|
'السرعة المحصلة لسرعتي الطائرة والرياح منطبقة على محور المدرج',
|
||||||
|
'مقدمة الطائرة موازية تماماً للمدرج بغض النظر عن الرياح',
|
||||||
|
'سرعة الرياح مساوية لسرعة الطائرة في المقدار ومعاكسة لها',
|
||||||
|
'السرعة المحصلة متعامدة على المدرج لتثبيت العجلات',
|
||||||
|
],
|
||||||
|
checkpointCorrectIdx: 0,
|
||||||
|
telemetry: _buildTelemetry(isAligned, vGroundMag, groundTrackDeg, dotProduct, crossProduct),
|
||||||
|
canvas: _buildCanvas(isAligned, groundTrackDeg, dotProduct, crossProduct),
|
||||||
|
controls: _buildControls(isAligned, isEqualed45, groundTrackDeg),
|
||||||
|
footerNote:
|
||||||
|
'مبني وموثق طبقاً للمنهاج الأردني المعتمد (الوحدة 1: الصفحات 5 - 19) • لا يعتمد بيانات وهمية.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildTelemetry(
|
||||||
|
bool isAligned,
|
||||||
|
double vGroundMag,
|
||||||
|
double groundTrackDeg,
|
||||||
|
double dotProduct,
|
||||||
|
double crossProduct,
|
||||||
|
) {
|
||||||
|
if (_activeTab == 0) {
|
||||||
|
return [
|
||||||
|
LabPill('السرعة الأرضية |Vg| = ${vGroundMag.toStringAsFixed(1)} knot'),
|
||||||
|
LabPill(
|
||||||
|
'الانحراف = ${groundTrackDeg.toStringAsFixed(1)}°',
|
||||||
|
color: isAligned ? const Color(0xFF30D158) : const Color(0xFFFF453A),
|
||||||
|
),
|
||||||
|
LabPill(
|
||||||
|
isAligned ? 'مسار منطبق ومثالي ✅' : 'مسار هبوط منحرف ⚠️',
|
||||||
|
color: isAligned ? const Color(0xFF30D158) : const Color(0xFFFF9F0A),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
} else if (_activeTab == 1) {
|
||||||
|
final resMag = (_vecMag * _scalarMultiplier.abs()).toStringAsFixed(1);
|
||||||
|
return [
|
||||||
|
LabPill('المقدار الأساسي |A| = ${_vecMag.toInt()} N'),
|
||||||
|
LabPill('الزاوية θ = ${_vecAngle.toInt()}°', color: const Color(0xFFFF9F0A)),
|
||||||
|
LabPill('المتجه الناتج |n·A| = $resMag N', color: const Color(0xFF00F5D4)),
|
||||||
|
if (_scalarMultiplier < 0)
|
||||||
|
const LabPill('سالب المتجه (−180°)', color: Color(0xFFFF375F)),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
return [
|
||||||
|
LabPill('الضرب القياسي A·B = ${dotProduct.toStringAsFixed(1)} J'),
|
||||||
|
LabPill(
|
||||||
|
'الضرب المتجهي |A×B| = ${crossProduct.toStringAsFixed(1)} N·m',
|
||||||
|
color: const Color(0xFFFF9F0A),
|
||||||
|
),
|
||||||
|
LabPill(
|
||||||
|
_dotAngle <= 90 ? 'اتجاه المتجه: خارج الصفحة ⊙' : 'اتجاه المتجه: داخل الصفحة ⊗',
|
||||||
|
color: const Color(0xFF30D158),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCanvas(
|
||||||
|
bool isAligned,
|
||||||
|
double groundTrackDeg,
|
||||||
|
double dotProduct,
|
||||||
|
double crossProduct,
|
||||||
|
) {
|
||||||
|
return GestureDetector(
|
||||||
|
onPanUpdate: _activeTab == 1 ? _handleVectorDrag : null,
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _VectorsIntroCanvasPainter(
|
||||||
|
activeTab: _activeTab,
|
||||||
|
planeHeading: _planeHeading,
|
||||||
|
windSpeed: _windSpeed,
|
||||||
|
planeAirspeed: _planeAirspeed,
|
||||||
|
isLanding: _isLanding,
|
||||||
|
landingProgress: _landingProgress,
|
||||||
|
vecMag: _vecMag,
|
||||||
|
vecAngle: _vecAngle,
|
||||||
|
scalarMultiplier: _scalarMultiplier,
|
||||||
|
dotMagA: _dotMagA,
|
||||||
|
dotMagB: _dotMagB,
|
||||||
|
dotAngle: _dotAngle,
|
||||||
|
),
|
||||||
|
child: Container(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleVectorDrag(DragUpdateDetails d) {
|
||||||
|
final box = context.findRenderObject() as RenderBox?;
|
||||||
|
if (box == null) return;
|
||||||
|
final local = box.globalToLocal(d.globalPosition);
|
||||||
|
final cx = box.size.width * 0.45;
|
||||||
|
final cy = box.size.height * 0.52;
|
||||||
|
final dx = local.dx - cx;
|
||||||
|
final dy = -(local.dy - cy);
|
||||||
|
var ang = math.atan2(dy, dx) * 180.0 / math.pi;
|
||||||
|
if (ang < 0) ang += 360.0;
|
||||||
|
final dist = math.sqrt(dx * dx + dy * dy);
|
||||||
|
setState(() {
|
||||||
|
_vecAngle = ang.clamp(0.0, 360.0);
|
||||||
|
_vecMag = dist.clamp(25.0, 130.0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildControls(bool isAligned, bool isEqualed45, double groundTrackDeg) {
|
||||||
|
return [
|
||||||
|
LabSegments<int>(
|
||||||
|
labels: const ['هبوط الرياح المتقاطعة', 'تمثيل وسالب المتجه', 'الضرب النقطي والتقاطعي'],
|
||||||
|
values: const [0, 1, 2],
|
||||||
|
current: _activeTab,
|
||||||
|
onSelected: (tab) => setState(() {
|
||||||
|
saqelTick();
|
||||||
|
_activeTab = tab;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
if (_activeTab == 0) ...[
|
||||||
|
// TAB 0 CONTROLS
|
||||||
|
LabSlider(
|
||||||
|
label: 'توجيه مقدمة الطائرة (Heading)',
|
||||||
|
value: _planeHeading,
|
||||||
|
min: -40.0,
|
||||||
|
max: 40.0,
|
||||||
|
display: '${_planeHeading.toStringAsFixed(1)}°',
|
||||||
|
accent: const Color(0xFF00F5D4),
|
||||||
|
onChanged: (v) => setState(() => _planeHeading = v),
|
||||||
|
),
|
||||||
|
LabSlider(
|
||||||
|
label: 'سرعة الرياح الجانبية (Crosswind)',
|
||||||
|
value: _windSpeed,
|
||||||
|
min: -35.0,
|
||||||
|
max: 35.0,
|
||||||
|
display: '${_windSpeed.toStringAsFixed(1)} knot',
|
||||||
|
accent: const Color(0xFFFF9F0A),
|
||||||
|
onChanged: (v) => setState(() => _windSpeed = v),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
icon: Icon(
|
||||||
|
_isLanding ? CupertinoIcons.airplane : CupertinoIcons.arrow_down_circle_fill,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
_isLanding ? 'جاري الهبوط التجريبي...' : 'تنفيذ هبوط تجريبي على المدرج 🛬',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w800, fontSize: 13),
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: isAligned ? AppColors.saqelCyan : const Color(0xFFFF9F0A),
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 11),
|
||||||
|
),
|
||||||
|
onPressed: _isLanding ? null : _triggerLanding,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
LabFormulaCard(
|
||||||
|
title: 'الفيزياء المنهجية (صفحة 5 من الكتاب)',
|
||||||
|
body:
|
||||||
|
'السرعة المحصلة = سرعة الطائرة بالنسبة للهواء + سرعة الرياح\n'
|
||||||
|
'V_ground = V_plane + V_wind\n'
|
||||||
|
'الانحراف الحالي: ${groundTrackDeg.toStringAsFixed(1)}°\n'
|
||||||
|
'${isAligned ? "✅ زاوية التوجيه عادلت سرعة الرياح بدقة واستقام المسار." : "⚠️ اضبط توجيه مقدمة الطائرة لعكس اتجاه دفع الرياح."}',
|
||||||
|
),
|
||||||
|
] else if (_activeTab == 1) ...[
|
||||||
|
// TAB 1 CONTROLS
|
||||||
|
LabSegments<int>(
|
||||||
|
labels: const ['قوة F (كمية متجهة)', 'كتلة m (كمية قياسية)'],
|
||||||
|
values: const [0, 1],
|
||||||
|
current: _selectedQuantityIdx,
|
||||||
|
onSelected: (i) => setState(() => _selectedQuantityIdx = i),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
LabSlider(
|
||||||
|
label: 'المقدار |A|',
|
||||||
|
value: _vecMag,
|
||||||
|
min: 25.0,
|
||||||
|
max: 130.0,
|
||||||
|
display: '${_vecMag.toInt()} N',
|
||||||
|
onChanged: (v) => setState(() => _vecMag = v),
|
||||||
|
),
|
||||||
|
LabSlider(
|
||||||
|
label: 'الاتجاه θA من محور السينات الموجب',
|
||||||
|
value: _vecAngle,
|
||||||
|
min: 0.0,
|
||||||
|
max: 360.0,
|
||||||
|
display: '${_vecAngle.toInt()}°',
|
||||||
|
accent: const Color(0xFFFF9F0A),
|
||||||
|
onChanged: (v) => setState(() => _vecAngle = v),
|
||||||
|
),
|
||||||
|
LabSlider(
|
||||||
|
label: 'معامل الضرب القياسي n (مضاعفة / سالب المتجه)',
|
||||||
|
value: _scalarMultiplier,
|
||||||
|
min: -2.0,
|
||||||
|
max: 2.0,
|
||||||
|
display: 'n = ${_scalarMultiplier.toStringAsFixed(2)}',
|
||||||
|
accent: _scalarMultiplier < 0 ? const Color(0xFFFF375F) : const Color(0xFF30D158),
|
||||||
|
onChanged: (v) => setState(() => _scalarMultiplier = v),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const LabFormulaCard(
|
||||||
|
title: 'خصائص المتجهات المنهجية (صفحة 11)',
|
||||||
|
body:
|
||||||
|
'سالب المتجه (-A): نفس المقدار ويعاكسه تماماً في الاتجاه (180°).\n'
|
||||||
|
'ضرب المتجه في عدد قياسي n: يصبح المقدار |n|·A، ويبقى بالاتجاه نفسه إذا n>0 وينعكس إذا n<0.',
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
// TAB 2 CONTROLS
|
||||||
|
LabSlider(
|
||||||
|
label: 'الزاوية المحصورة بين المتجهين θ',
|
||||||
|
value: _dotAngle,
|
||||||
|
min: 0.0,
|
||||||
|
max: 180.0,
|
||||||
|
display: '${_dotAngle.toInt()}°',
|
||||||
|
accent: isEqualed45 ? const Color(0xFF00F5D4) : const Color(0xFFFF9F0A),
|
||||||
|
onChanged: (v) => setState(() => _dotAngle = v),
|
||||||
|
),
|
||||||
|
LabSlider(
|
||||||
|
label: 'مقدار المتجه A',
|
||||||
|
value: _dotMagA,
|
||||||
|
min: 30.0,
|
||||||
|
max: 120.0,
|
||||||
|
display: '${_dotMagA.toInt()} N',
|
||||||
|
onChanged: (v) => setState(() => _dotMagA = v),
|
||||||
|
),
|
||||||
|
LabSlider(
|
||||||
|
label: 'مقدار المتجه B',
|
||||||
|
value: _dotMagB,
|
||||||
|
min: 30.0,
|
||||||
|
max: 120.0,
|
||||||
|
display: '${_dotMagB.toInt()} m',
|
||||||
|
accent: const Color(0xFF60A5FA),
|
||||||
|
onChanged: (v) => setState(() => _dotMagB = v),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
LabFormulaCard(
|
||||||
|
title: isEqualed45
|
||||||
|
? 'ملاحظة ذهبية: يتساوى الضرب القياسي والمتجهي عند θ = 45°!'
|
||||||
|
: 'قوانين الضرب (صفحات 13 - 15 من الكتاب)',
|
||||||
|
body:
|
||||||
|
'الضرب القياسي (الشغل W): A·B = A·B·cosθ\n'
|
||||||
|
'الضرب المتجهي (العزم τ): |A×B| = A·B·sinθ\n'
|
||||||
|
'عند θ = 90°: الضرب النقطي ينعدم، والمتجهي يكون في قيمته العظمى.\n'
|
||||||
|
'عند θ = 45°: tan(45°) = 1 ⟹ A·B = |A×B|.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ============================================================================
|
||||||
|
/// CANVAS PAINTER: 3 Physics Visualizations
|
||||||
|
/// ============================================================================
|
||||||
|
class _VectorsIntroCanvasPainter extends CustomPainter {
|
||||||
|
final int activeTab;
|
||||||
|
final double planeHeading;
|
||||||
|
final double windSpeed;
|
||||||
|
final double planeAirspeed;
|
||||||
|
final bool isLanding;
|
||||||
|
final double landingProgress;
|
||||||
|
final double vecMag;
|
||||||
|
final double vecAngle;
|
||||||
|
final double scalarMultiplier;
|
||||||
|
final double dotMagA;
|
||||||
|
final double dotMagB;
|
||||||
|
final double dotAngle;
|
||||||
|
|
||||||
|
_VectorsIntroCanvasPainter({
|
||||||
|
required this.activeTab,
|
||||||
|
required this.planeHeading,
|
||||||
|
required this.windSpeed,
|
||||||
|
required this.planeAirspeed,
|
||||||
|
required this.isLanding,
|
||||||
|
required this.landingProgress,
|
||||||
|
required this.vecMag,
|
||||||
|
required this.vecAngle,
|
||||||
|
required this.scalarMultiplier,
|
||||||
|
required this.dotMagA,
|
||||||
|
required this.dotMagB,
|
||||||
|
required this.dotAngle,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas c, Size s) {
|
||||||
|
// Dark void background
|
||||||
|
c.drawRect(
|
||||||
|
Offset.zero & s,
|
||||||
|
Paint()
|
||||||
|
..shader = const LinearGradient(
|
||||||
|
colors: [Color(0xFF07111F), Color(0xFF0B1728)],
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
).createShader(Offset.zero & s),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (activeTab == 0) {
|
||||||
|
_paintCrosswindRunway(c, s);
|
||||||
|
} else if (activeTab == 1) {
|
||||||
|
_paintVectorProperties(c, s);
|
||||||
|
} else {
|
||||||
|
_paintDotAndCrossProduct(c, s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TAB 0: Crosswind Runway
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
void _paintCrosswindRunway(Canvas c, Size s) {
|
||||||
|
final cx = s.width * 0.48;
|
||||||
|
const rWidth = 84.0;
|
||||||
|
|
||||||
|
// Runway surface
|
||||||
|
final runwayRect = Rect.fromLTWH(cx - rWidth / 2, 12, rWidth, s.height - 24);
|
||||||
|
c.drawRRect(
|
||||||
|
RRect.fromRectAndRadius(runwayRect, const Radius.circular(8)),
|
||||||
|
Paint()..color = const Color(0xFF131D2D),
|
||||||
|
);
|
||||||
|
c.drawRRect(
|
||||||
|
RRect.fromRectAndRadius(runwayRect, const Radius.circular(8)),
|
||||||
|
Paint()
|
||||||
|
..color = Colors.white.withValues(alpha: 0.18)
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1.6,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Centerline dashed markings
|
||||||
|
final dashPaint = Paint()
|
||||||
|
..color = Colors.white70
|
||||||
|
..strokeWidth = 2.4;
|
||||||
|
for (double y = runwayRect.top + 20; y < runwayRect.bottom - 20; y += 28) {
|
||||||
|
c.drawLine(Offset(cx, y), Offset(cx, y + 14), dashPaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runway threshold "08"
|
||||||
|
final tpRunway = TextPainter(
|
||||||
|
text: const TextSpan(
|
||||||
|
text: '08',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white60,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
letterSpacing: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
)..layout();
|
||||||
|
tpRunway.paint(c, Offset(cx - tpRunway.width / 2, runwayRect.bottom - 36));
|
||||||
|
|
||||||
|
// Calculate aircraft position:
|
||||||
|
final headingRad = planeHeading * math.pi / 180.0;
|
||||||
|
final vxPlane = planeAirspeed * math.sin(headingRad);
|
||||||
|
final vyPlane = planeAirspeed * math.cos(headingRad);
|
||||||
|
final vxGround = vxPlane + windSpeed;
|
||||||
|
|
||||||
|
// Normalised position on canvas:
|
||||||
|
final startY = runwayRect.top + 38;
|
||||||
|
final endY = runwayRect.bottom - 50;
|
||||||
|
final curY = isLanding ? startY + (endY - startY) * landingProgress : startY + 50.0;
|
||||||
|
// Ground drift
|
||||||
|
final driftFactor = (vxGround / planeAirspeed) * 90.0;
|
||||||
|
final curX = isLanding ? cx + driftFactor * landingProgress : cx;
|
||||||
|
final planePos = Offset(curX, curY);
|
||||||
|
|
||||||
|
// Draw Vector diagram originating from the plane:
|
||||||
|
final vAirEnd = planePos + Offset(vxPlane * 1.1, vyPlane * 0.9);
|
||||||
|
final vWindEnd = vAirEnd + Offset(windSpeed * 1.6, 0);
|
||||||
|
|
||||||
|
// 1. Plane Airspeed Vector (Cyan)
|
||||||
|
_drawArrow(c, planePos, vAirEnd, AppColors.saqelCyan, 'سرعة الطائرة V_air');
|
||||||
|
|
||||||
|
// 2. Crosswind Vector (Amber)
|
||||||
|
_drawArrow(c, vAirEnd, vWindEnd, const Color(0xFFFF9F0A), 'رياح جانبية V_wind');
|
||||||
|
|
||||||
|
// 3. Ground Resultant Vector (Green or Red)
|
||||||
|
final isAligned = (vxGround / planeAirspeed).abs() < 0.08;
|
||||||
|
final resColor = isAligned ? const Color(0xFF30D158) : const Color(0xFFFF453A);
|
||||||
|
_drawArrow(c, planePos, vWindEnd, resColor, 'السرعة المحصلة V_ground', width: 3.4);
|
||||||
|
|
||||||
|
// Draw Airplane at planePos rotated by planeHeading
|
||||||
|
c.save();
|
||||||
|
c.translate(planePos.dx, planePos.dy);
|
||||||
|
c.rotate(headingRad);
|
||||||
|
_drawAirplaneIcon(c, isAligned);
|
||||||
|
c.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawAirplaneIcon(Canvas c, bool isAligned) {
|
||||||
|
final bodyPaint = Paint()..color = Colors.white;
|
||||||
|
final wingPaint = Paint()..color = isAligned ? AppColors.saqelCyan : const Color(0xFFFF9F0A);
|
||||||
|
|
||||||
|
// Fuselage
|
||||||
|
c.drawRRect(
|
||||||
|
RRect.fromRectAndRadius(
|
||||||
|
const Rect.fromLTWH(-4, -18, 8, 36),
|
||||||
|
const Radius.circular(4),
|
||||||
|
),
|
||||||
|
bodyPaint,
|
||||||
|
);
|
||||||
|
// Wings
|
||||||
|
final wingPath = Path()
|
||||||
|
..moveTo(0, -3)
|
||||||
|
..lineTo(-22, 10)
|
||||||
|
..lineTo(-22, 6)
|
||||||
|
..lineTo(0, -9)
|
||||||
|
..lineTo(22, 6)
|
||||||
|
..lineTo(22, 10)
|
||||||
|
..close();
|
||||||
|
c.drawPath(wingPath, wingPaint);
|
||||||
|
// Tail
|
||||||
|
final tailPath = Path()
|
||||||
|
..moveTo(0, 10)
|
||||||
|
..lineTo(-9, 17)
|
||||||
|
..lineTo(9, 17)
|
||||||
|
..close();
|
||||||
|
c.drawPath(tailPath, wingPaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TAB 1: Vector Properties & Multiplication
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
void _paintVectorProperties(Canvas c, Size s) {
|
||||||
|
final cx = s.width * 0.44;
|
||||||
|
final cy = s.height * 0.52;
|
||||||
|
final origin = Offset(cx, cy);
|
||||||
|
|
||||||
|
_drawGridAndAxes(c, s, cx, cy);
|
||||||
|
|
||||||
|
final aRad = vecAngle * math.pi / 180.0;
|
||||||
|
final ax = vecMag * math.cos(aRad);
|
||||||
|
final ay = -vecMag * math.sin(aRad);
|
||||||
|
final aEnd = origin + Offset(ax, ay);
|
||||||
|
|
||||||
|
// Draw original Vector A
|
||||||
|
_drawArrow(c, origin, aEnd, AppColors.saqelCyan, 'A (${vecMag.toInt()} N)', width: 3.0);
|
||||||
|
|
||||||
|
// Draw Resultant Vector B = n * A
|
||||||
|
final bx = ax * scalarMultiplier;
|
||||||
|
final by = ay * scalarMultiplier;
|
||||||
|
final bEnd = origin + Offset(bx, by);
|
||||||
|
|
||||||
|
final bColor = scalarMultiplier < 0 ? const Color(0xFFFF375F) : const Color(0xFF30D158);
|
||||||
|
final bLabel = scalarMultiplier < 0
|
||||||
|
? 'سالب المتجه (−${scalarMultiplier.abs().toStringAsFixed(1)} A)'
|
||||||
|
: 'n·A (${scalarMultiplier.toStringAsFixed(1)} A)';
|
||||||
|
|
||||||
|
if ((scalarMultiplier - 1.0).abs() > 0.05) {
|
||||||
|
_drawArrow(c, origin, bEnd, bColor, bLabel, width: 3.2, offsetText: 22);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Origin dot
|
||||||
|
c.drawCircle(origin, 5, Paint()..color = Colors.white);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// TAB 2: Dot & Cross Product (Parallelogram & Angle Arc)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
void _paintDotAndCrossProduct(Canvas c, Size s) {
|
||||||
|
final cx = s.width * 0.38;
|
||||||
|
final cy = s.height * 0.58;
|
||||||
|
final origin = Offset(cx, cy);
|
||||||
|
|
||||||
|
_drawGridAndAxes(c, s, cx, cy);
|
||||||
|
|
||||||
|
// Vector A along positive X axis:
|
||||||
|
final aEnd = origin + Offset(dotMagA, 0);
|
||||||
|
|
||||||
|
// Vector B at angle theta:
|
||||||
|
final radTheta = dotAngle * math.pi / 180.0;
|
||||||
|
final bx = dotMagB * math.cos(radTheta);
|
||||||
|
final by = -dotMagB * math.sin(radTheta);
|
||||||
|
final bEnd = origin + Offset(bx, by);
|
||||||
|
|
||||||
|
// 1. Shaded Parallelogram for Cross Product (Area = |A × B|)
|
||||||
|
final pFar = bEnd + Offset(dotMagA, 0);
|
||||||
|
final paraPath = Path()
|
||||||
|
..moveTo(origin.dx, origin.dy)
|
||||||
|
..lineTo(aEnd.dx, aEnd.dy)
|
||||||
|
..lineTo(pFar.dx, pFar.dy)
|
||||||
|
..lineTo(bEnd.dx, bEnd.dy)
|
||||||
|
..close();
|
||||||
|
|
||||||
|
c.drawPath(
|
||||||
|
paraPath,
|
||||||
|
Paint()
|
||||||
|
..color = const Color(0xFFFF9F0A).withValues(alpha: 0.18)
|
||||||
|
..style = PaintingStyle.fill,
|
||||||
|
);
|
||||||
|
c.drawPath(
|
||||||
|
paraPath,
|
||||||
|
Paint()
|
||||||
|
..color = const Color(0xFFFF9F0A).withValues(alpha: 0.4)
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = 1.2,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Shaded Projection for Dot Product (Shadow of B on A)
|
||||||
|
final projX = origin.dx + bx;
|
||||||
|
c.drawLine(
|
||||||
|
bEnd,
|
||||||
|
Offset(projX, origin.dy),
|
||||||
|
Paint()
|
||||||
|
..color = Colors.white38
|
||||||
|
..strokeWidth = 1.2
|
||||||
|
..strokeCap = StrokeCap.round,
|
||||||
|
);
|
||||||
|
c.drawRect(
|
||||||
|
Rect.fromLTRB(
|
||||||
|
math.min(origin.dx, projX),
|
||||||
|
origin.dy - 3,
|
||||||
|
math.max(origin.dx, projX),
|
||||||
|
origin.dy + 3,
|
||||||
|
),
|
||||||
|
Paint()..color = const Color(0xFF30D158),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Draw Vector Arrows
|
||||||
|
_drawArrow(c, origin, aEnd, AppColors.saqelCyan, 'A', width: 3.2);
|
||||||
|
_drawArrow(c, origin, bEnd, const Color(0xFF60A5FA), 'B', width: 3.2);
|
||||||
|
|
||||||
|
// 4. Angle Arc
|
||||||
|
c.drawArc(
|
||||||
|
Rect.fromCircle(center: origin, radius: 36),
|
||||||
|
0,
|
||||||
|
-radTheta,
|
||||||
|
false,
|
||||||
|
Paint()
|
||||||
|
..color = const Color(0xFFFFD60A)
|
||||||
|
..strokeWidth = 2.0
|
||||||
|
..style = PaintingStyle.stroke,
|
||||||
|
);
|
||||||
|
final tpAngle = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: 'θ = ${dotAngle.toInt()}°',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFFFFD60A),
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
textDirection: TextDirection.ltr,
|
||||||
|
)..layout();
|
||||||
|
tpAngle.paint(c, origin + const Offset(42, -26));
|
||||||
|
|
||||||
|
// Origin dot
|
||||||
|
c.drawCircle(origin, 5, Paint()..color = Colors.white);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// HELPER PAINTERS
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
void _drawGridAndAxes(Canvas c, Size s, double cx, double cy) {
|
||||||
|
final grid = Paint()
|
||||||
|
..color = Colors.white.withValues(alpha: 0.05)
|
||||||
|
..strokeWidth = 1;
|
||||||
|
for (double x = 0; x < s.width; x += 26) {
|
||||||
|
c.drawLine(Offset(x, 0), Offset(x, s.height), grid);
|
||||||
|
}
|
||||||
|
for (double y = 0; y < s.height; y += 26) {
|
||||||
|
c.drawLine(Offset(0, y), Offset(s.width, y), grid);
|
||||||
|
}
|
||||||
|
|
||||||
|
final axis = Paint()
|
||||||
|
..color = Colors.white.withValues(alpha: 0.22)
|
||||||
|
..strokeWidth = 1.5;
|
||||||
|
c.drawLine(Offset(0, cy), Offset(s.width, cy), axis);
|
||||||
|
c.drawLine(Offset(cx, 0), Offset(cx, s.height), axis);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _drawArrow(
|
||||||
|
Canvas c,
|
||||||
|
Offset a,
|
||||||
|
Offset b,
|
||||||
|
Color col,
|
||||||
|
String label, {
|
||||||
|
double width = 2.6,
|
||||||
|
double offsetText = 14,
|
||||||
|
}) {
|
||||||
|
final p = Paint()
|
||||||
|
..color = col
|
||||||
|
..strokeWidth = width
|
||||||
|
..strokeCap = StrokeCap.round;
|
||||||
|
c.drawLine(a, b, p);
|
||||||
|
|
||||||
|
final ang = math.atan2(b.dy - a.dy, b.dx - a.dx);
|
||||||
|
const hs = 10.0;
|
||||||
|
final p1 = b - Offset(math.cos(ang - 0.45) * hs, math.sin(ang - 0.45) * hs);
|
||||||
|
final p2 = b - Offset(math.cos(ang + 0.45) * hs, math.sin(ang + 0.45) * hs);
|
||||||
|
c.drawPath(
|
||||||
|
Path()
|
||||||
|
..moveTo(b.dx, b.dy)
|
||||||
|
..lineTo(p1.dx, p1.dy)
|
||||||
|
..lineTo(p2.dx, p2.dy)
|
||||||
|
..close(),
|
||||||
|
Paint()..color = col,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Draggable handle glow
|
||||||
|
c.drawCircle(b, 7, Paint()..color = col.withValues(alpha: 0.28));
|
||||||
|
c.drawCircle(b, 3.5, Paint()..color = col);
|
||||||
|
|
||||||
|
final tp = TextPainter(
|
||||||
|
text: TextSpan(
|
||||||
|
text: label,
|
||||||
|
style: TextStyle(color: col, fontSize: 11, fontWeight: FontWeight.w900),
|
||||||
|
),
|
||||||
|
textDirection: TextDirection.rtl,
|
||||||
|
)..layout();
|
||||||
|
tp.paint(c, b + Offset(4, -offsetText));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant _VectorsIntroCanvasPainter o) =>
|
||||||
|
o.activeTab != activeTab ||
|
||||||
|
o.planeHeading != planeHeading ||
|
||||||
|
o.windSpeed != windSpeed ||
|
||||||
|
o.isLanding != isLanding ||
|
||||||
|
o.landingProgress != landingProgress ||
|
||||||
|
o.vecMag != vecMag ||
|
||||||
|
o.vecAngle != vecAngle ||
|
||||||
|
o.scalarMultiplier != scalarMultiplier ||
|
||||||
|
o.dotMagA != dotMagA ||
|
||||||
|
o.dotMagB != dotMagB ||
|
||||||
|
o.dotAngle != dotAngle;
|
||||||
|
}
|
||||||
+1534
-141
File diff suppressed because it is too large
Load Diff
@@ -340,10 +340,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: matcher
|
name: matcher
|
||||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.12.18"
|
version: "0.12.19"
|
||||||
material_color_utilities:
|
material_color_utilities:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -356,10 +356,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: meta
|
name: meta
|
||||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.17.0"
|
version: "1.18.0"
|
||||||
nested:
|
nested:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -585,10 +585,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.9"
|
version: "0.7.11"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:student_app/data/models/lesson_model.dart';
|
||||||
|
import 'package:student_app/presentation/screens/curriculum/lesson_homework_sheet.dart';
|
||||||
|
import 'package:student_app/presentation/screens/notebook/smart_error_notebook_screen.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('Guardian Child Model & Error Metrics Integration', () {
|
||||||
|
test('correctly parses server-authoritative error notebook metrics', () {
|
||||||
|
final json = {
|
||||||
|
'student': {
|
||||||
|
'id': 42,
|
||||||
|
'uuid': 'student-uuid-42',
|
||||||
|
'full_name': 'عمر الخطيب',
|
||||||
|
'national_id': '****1234',
|
||||||
|
'grade_level': 'الصف العاشر الأساسي',
|
||||||
|
'stream': 'علمي',
|
||||||
|
'school_name': 'مدرسة الملك عبد الله للتميز',
|
||||||
|
},
|
||||||
|
'metrics': {
|
||||||
|
'readiness_score': 88.5,
|
||||||
|
'exams_passed_count': 7,
|
||||||
|
'exams_total_count': 8,
|
||||||
|
'error_notebook': {
|
||||||
|
'total_errors': 6,
|
||||||
|
'mastered_count': 4,
|
||||||
|
'pending_count': 2,
|
||||||
|
'mastery_percentage': 66.7,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
final model = GuardianChildModel.fromJson(json);
|
||||||
|
|
||||||
|
expect(model.id, 42);
|
||||||
|
expect(model.name, 'عمر الخطيب');
|
||||||
|
expect(model.readinessScore, 88.5);
|
||||||
|
expect(model.examsPassed, 7);
|
||||||
|
expect(model.examsTotal, 8);
|
||||||
|
expect(model.errorTotalCount, 6);
|
||||||
|
expect(model.errorMasteredCount, 4);
|
||||||
|
expect(model.errorPendingCount, 2);
|
||||||
|
expect(model.errorMasteryRate, 66.7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles empty or perfect error metrics gracefully', () {
|
||||||
|
final json = {
|
||||||
|
'student': {'id': 1, 'full_name': 'سارة'},
|
||||||
|
'metrics': {'readiness_score': 95.0},
|
||||||
|
};
|
||||||
|
|
||||||
|
final model = GuardianChildModel.fromJson(json);
|
||||||
|
|
||||||
|
expect(model.errorTotalCount, 0);
|
||||||
|
expect(model.errorMasteredCount, 0);
|
||||||
|
expect(model.errorPendingCount, 0);
|
||||||
|
expect(model.errorMasteryRate, 100.0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Smart Error Notebook Guardian Mode UI Tests', () {
|
||||||
|
testWidgets('renders guardian inspection mode with child name and lock badge', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: SmartErrorNotebookScreen(
|
||||||
|
isGuardianMode: true,
|
||||||
|
studentName: 'عمر الخطيب',
|
||||||
|
studentId: 42,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
expect(find.textContaining('عمر الخطيب'), findsOneWidget);
|
||||||
|
expect(find.text('رقابة الأهل 👨👧👦'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('renders student self-study mode without guardian badge', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: SmartErrorNotebookScreen(
|
||||||
|
isGuardianMode: false,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
expect(find.text('دفتر الأخطاء الذكي والمسار العلاجي'), findsOneWidget);
|
||||||
|
expect(find.text('رقابة الأهل 👨👧👦'), findsNothing);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Homework Sheet Error Logging', () {
|
||||||
|
testWidgets('LessonHomeworkSheet mounts and handles answer verification', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: LessonHomeworkSheet(
|
||||||
|
subjectId: 'physics_10',
|
||||||
|
subjectTitle: 'الفيزياء',
|
||||||
|
lessonId: 'lesson_01',
|
||||||
|
lessonTitle: 'الكميات القياسية والمتجهة',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.textContaining('واجب الدرس'), findsOneWidget);
|
||||||
|
expect(find.textContaining('التمرين 1'), findsOneWidget);
|
||||||
|
expect(find.text('تحقق من الإجابة 🚀'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:student_app/data/models/subject_model.dart';
|
||||||
|
import 'package:student_app/data/repositories/curriculum_question_bank.dart';
|
||||||
|
import 'package:student_app/data/repositories/curriculum_repository.dart';
|
||||||
|
import 'package:student_app/presentation/screens/curriculum/teacher_selection_sheet.dart';
|
||||||
|
import 'package:student_app/presentation/screens/player/scaffolded_thinking_pause_sheet.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('CurriculumQuestionBank Textbooks Isolation', () {
|
||||||
|
test('Islamic textbooks return authentic Grade 10 books', () {
|
||||||
|
final books = CurriculumQuestionBank.getSubjectTextbooks(
|
||||||
|
subjectId: 'islamic_10',
|
||||||
|
subjectTitle: 'التربية الإسلامية',
|
||||||
|
);
|
||||||
|
expect(books.length, 2);
|
||||||
|
expect(books.first.title, contains('التربية الإسلامية'));
|
||||||
|
expect(books.first.assetType, 'textbook');
|
||||||
|
expect(books.first.mimeType, 'application/pdf');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Math textbooks include exercise book', () {
|
||||||
|
final books = CurriculumQuestionBank.getSubjectTextbooks(
|
||||||
|
subjectId: 'math_10',
|
||||||
|
subjectTitle: 'الرياضيات',
|
||||||
|
);
|
||||||
|
expect(books.length, 3);
|
||||||
|
expect(books.any((b) => b.title.contains('التمارين')), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Physics textbooks return authentic semesters', () {
|
||||||
|
final books = CurriculumQuestionBank.getSubjectTextbooks(
|
||||||
|
subjectId: 'physics_10',
|
||||||
|
subjectTitle: 'الفيزياء',
|
||||||
|
);
|
||||||
|
expect(books.length, 2);
|
||||||
|
expect(books.any((b) => b.title.contains('الفيزياء')), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('TeacherSelectionSheet UI Tests', () {
|
||||||
|
testWidgets('renders multiple teachers sorted by rating and allows choice', (tester) async {
|
||||||
|
const lesson = CurriculumLessonItemModel(
|
||||||
|
id: 'lesson_math_01',
|
||||||
|
title: 'حل نظام مكون من معادلتين تربيعية وخطية',
|
||||||
|
durationSeconds: 1200,
|
||||||
|
hasVideo: true,
|
||||||
|
checkpointsCount: 3,
|
||||||
|
outcomes: ['عزل المتغير', 'التعويض'],
|
||||||
|
);
|
||||||
|
|
||||||
|
final videos = [
|
||||||
|
const PublishedLessonVideoModel(
|
||||||
|
videoVersionId: 'v_ahmad',
|
||||||
|
submissionId: 'sub_1',
|
||||||
|
teacherName: 'أ. أحمد الطراونة',
|
||||||
|
rating: 4.8,
|
||||||
|
ratingCount: 95,
|
||||||
|
isNew: false,
|
||||||
|
),
|
||||||
|
const PublishedLessonVideoModel(
|
||||||
|
videoVersionId: 'v_khalid',
|
||||||
|
submissionId: 'sub_2',
|
||||||
|
teacherName: 'أ. خالد الحنيطي',
|
||||||
|
rating: 4.9,
|
||||||
|
ratingCount: 140,
|
||||||
|
isNew: false,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
PublishedLessonVideoModel? selected;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Builder(
|
||||||
|
builder: (context) => ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
selected = await TeacherSelectionSheet.show(
|
||||||
|
context,
|
||||||
|
lesson: lesson,
|
||||||
|
videos: videos,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('افتح اختيار المعلم'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open sheet
|
||||||
|
await tester.tap(find.text('افتح اختيار المعلم'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Top rated teacher (Khalid, 4.9) should appear first with badge
|
||||||
|
expect(find.text('اختر شرح المعلم المعتمد 👨🏫'), findsOneWidget);
|
||||||
|
expect(find.text('الأعلى تقييماً'), findsOneWidget);
|
||||||
|
expect(find.text('أ. خالد الحنيطي'), findsOneWidget);
|
||||||
|
expect(find.text('أ. أحمد الطراونة'), findsOneWidget);
|
||||||
|
|
||||||
|
// Tap on Khalid
|
||||||
|
await tester.tap(find.text('أ. خالد الحنيطي'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verified selection
|
||||||
|
expect(selected, isNotNull);
|
||||||
|
expect(selected!.teacherName, 'أ. خالد الحنيطي');
|
||||||
|
expect(selected!.videoVersionId, 'v_khalid');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('ScaffoldedThinkingPauseSheet UI Tests', () {
|
||||||
|
testWidgets('enforces 60-second thinking phase and unmasks steps progressively', (tester) async {
|
||||||
|
const lesson = CurriculumLessonItemModel(
|
||||||
|
id: 'lesson_math_01',
|
||||||
|
title: 'حل نظام مكون من معادلتين',
|
||||||
|
durationSeconds: 900,
|
||||||
|
hasVideo: true,
|
||||||
|
checkpointsCount: 2,
|
||||||
|
outcomes: ['حل الأنظمة'],
|
||||||
|
);
|
||||||
|
|
||||||
|
tester.view.physicalSize = const Size(1080, 2200);
|
||||||
|
tester.view.devicePixelRatio = 1.0;
|
||||||
|
addTearDown(() => tester.view.resetPhysicalSize());
|
||||||
|
|
||||||
|
bool completed = false;
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Builder(
|
||||||
|
builder: (context) => ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
ScaffoldedThinkingPauseSheet.show(
|
||||||
|
context,
|
||||||
|
lesson: lesson,
|
||||||
|
subject: const SubjectModel(
|
||||||
|
id: 'math_10',
|
||||||
|
title: 'الرياضيات',
|
||||||
|
englishTitle: 'Mathematics',
|
||||||
|
iconCode: 'function',
|
||||||
|
primaryColor: Colors.blue,
|
||||||
|
secondaryColor: Colors.cyan,
|
||||||
|
),
|
||||||
|
onCompleted: () => completed = true,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('افتح وقفة التفكير'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open sheet
|
||||||
|
await tester.tap(find.text('افتح وقفة التفكير'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Check thinking phase elements
|
||||||
|
expect(find.text('وقفة تفكير وبناء الحل خطوة بخطوة ⏱️'), findsOneWidget);
|
||||||
|
expect(find.text('وقفة تأمل ذهني مستقل (60 ثانية) 🧠'), findsOneWidget);
|
||||||
|
expect(find.text('أنا جاهز، ابدأ بناء الحل خطوة بخطوة 🚀'), findsOneWidget);
|
||||||
|
|
||||||
|
// Tap ready button to skip countdown and enter step-by-step unmasking
|
||||||
|
await tester.tap(find.text('أنا جاهز، ابدأ بناء الحل خطوة بخطوة 🚀'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Step 1 should be revealed
|
||||||
|
expect(find.text('بناء الحل التراكمي خطوة بخطوة 📐'), findsOneWidget);
|
||||||
|
expect(find.text('الخطوة 1 من 4'), findsOneWidget);
|
||||||
|
expect(find.text('الخطوة 1: عزل المتغير في المعادلة الخطية'), findsOneWidget);
|
||||||
|
|
||||||
|
// Reveal Step 2
|
||||||
|
expect(find.text('اكشف الخطوة التالية (2 من 4) ⬇️'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('اكشف الخطوة التالية (2 من 4) ⬇️'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('الخطوة 2: التعويض في المعادلة التربيعية وتصفيرها'), findsOneWidget);
|
||||||
|
|
||||||
|
// Reveal Step 3
|
||||||
|
expect(find.text('اكشف الخطوة التالية (3 من 4) ⬇️'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('اكشف الخطوة التالية (3 من 4) ⬇️'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('الخطوة 3: حساب المميز والتحليل إلى العوامل'), findsOneWidget);
|
||||||
|
|
||||||
|
// Reveal Step 4
|
||||||
|
expect(find.text('اكشف الخطوة التالية (4 من 4) ⬇️'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('اكشف الخطوة التالية (4 من 4) ⬇️'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('الخطوة 4: إيجاد الأزواج المرتبة والتحقق البياني'), findsOneWidget);
|
||||||
|
|
||||||
|
// Completion button
|
||||||
|
expect(find.text('اكتمل بناء الحل المنهجي بنجاح! استمر 🎯'), findsOneWidget);
|
||||||
|
expect(completed, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:student_app/data/repositories/curriculum_question_bank.dart';
|
||||||
|
import 'package:student_app/presentation/screens/curriculum/lesson_homework_sheet.dart';
|
||||||
|
import 'package:student_app/presentation/screens/exams/adaptive_exam_screen.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
|
group('CurriculumQuestionBank Subject Isolation & Decoupling', () {
|
||||||
|
test('Islamic Studies question bank contains ZERO math equations', () {
|
||||||
|
final exam = CurriculumQuestionBank.getUnitExam(
|
||||||
|
subjectId: 'islamic_10',
|
||||||
|
unitKey: 'unit_01',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(exam.title, contains('القرآن الكريم'));
|
||||||
|
expect(exam.questions.isNotEmpty, isTrue);
|
||||||
|
|
||||||
|
for (final q in exam.questions) {
|
||||||
|
// Assert zero math contamination
|
||||||
|
expect(q.questionText.contains('x²'), isFalse, reason: 'Math leaked into Islamic exam: ${q.questionText}');
|
||||||
|
expect(q.questionText.contains('معادلة'), isFalse, reason: 'Math leaked into Islamic exam: ${q.questionText}');
|
||||||
|
expect(q.questionText.contains('مشتق'), isFalse, reason: 'Calculus leaked into Islamic exam: ${q.questionText}');
|
||||||
|
// Assert Islamic topic relevance
|
||||||
|
expect(
|
||||||
|
q.topicTag.contains('قرآن') ||
|
||||||
|
q.topicTag.contains('بيع') ||
|
||||||
|
q.topicTag.contains('معاملات') ||
|
||||||
|
q.topicTag.contains('فقه') ||
|
||||||
|
q.topicTag.contains('سيرة') ||
|
||||||
|
q.topicTag.contains('إسلام') ||
|
||||||
|
q.topicTag.contains('حديث') ||
|
||||||
|
q.topicTag.contains('آداب'),
|
||||||
|
isTrue,
|
||||||
|
reason: 'Non-Islamic topic tag in Islamic exam: ${q.topicTag}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Physics question bank contains authentic physics concepts', () {
|
||||||
|
final examU1 = CurriculumQuestionBank.getUnitExam(
|
||||||
|
subjectId: 'physics_10',
|
||||||
|
unitKey: 'unit_01',
|
||||||
|
);
|
||||||
|
expect(examU1.title, contains('المتجهات'));
|
||||||
|
expect(examU1.questions.any((q) => q.questionText.contains('متجه')), isTrue);
|
||||||
|
|
||||||
|
final examU2 = CurriculumQuestionBank.getUnitExam(
|
||||||
|
subjectId: 'physics_10',
|
||||||
|
unitKey: 'unit_02',
|
||||||
|
);
|
||||||
|
expect(examU2.title, contains('المقذوفات'));
|
||||||
|
expect(examU2.questions.any((q) => q.questionText.contains('أقصى ارتفاع')), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Arabic question bank contains authentic grammar and literature', () {
|
||||||
|
final exam = CurriculumQuestionBank.getUnitExam(
|
||||||
|
subjectId: 'arabic_10',
|
||||||
|
unitKey: 'unit_01',
|
||||||
|
);
|
||||||
|
expect(exam.title, contains('الشرط'));
|
||||||
|
expect(exam.questions.any((q) => q.topicTag.contains('الشرط')), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Lesson homework returns 3 focused questions with explanations', () {
|
||||||
|
final hw = CurriculumQuestionBank.getLessonHomework(
|
||||||
|
subjectId: 'islamic_10',
|
||||||
|
lessonId: 'lesson_01',
|
||||||
|
lessonTitle: 'واجب المسلم تجاه القرآن الكريم',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(hw.questions.length, greaterThanOrEqualTo(3));
|
||||||
|
for (final q in hw.questions) {
|
||||||
|
expect(q.questionText.isNotEmpty, isTrue);
|
||||||
|
expect(q.options.length, 4);
|
||||||
|
expect(q.explanation.isNotEmpty, isTrue);
|
||||||
|
expect(q.correctIndex >= 0 && q.correctIndex < 4, isTrue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Subject worksheets are populated and isolated per subject', () {
|
||||||
|
final islamicSheets = CurriculumQuestionBank.getSubjectWorksheets(
|
||||||
|
subjectId: 'islamic_10',
|
||||||
|
subjectTitle: 'التربية الإسلامية',
|
||||||
|
);
|
||||||
|
expect(islamicSheets.isNotEmpty, isTrue);
|
||||||
|
expect(islamicSheets.any((s) => s.title.contains('القرآن')), isTrue);
|
||||||
|
|
||||||
|
final physicsSheets = CurriculumQuestionBank.getSubjectWorksheets(
|
||||||
|
subjectId: 'physics_10',
|
||||||
|
subjectTitle: 'الفيزياء',
|
||||||
|
);
|
||||||
|
expect(physicsSheets.isNotEmpty, isTrue);
|
||||||
|
expect(physicsSheets.any((s) => s.title.contains('المتجهات')), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('LessonHomeworkSheet UI Test', () {
|
||||||
|
testWidgets('renders homework questions and verifies answer', (tester) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: LessonHomeworkSheet(
|
||||||
|
subjectId: 'islamic_10',
|
||||||
|
subjectTitle: 'التربية الإسلامية',
|
||||||
|
lessonId: 'lesson_01',
|
||||||
|
lessonTitle: 'واجب المسلم تجاه القرآن الكريم',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.textContaining('واجب الدرس'), findsOneWidget);
|
||||||
|
expect(find.textContaining('التمرين 1'), findsOneWidget);
|
||||||
|
expect(find.text('تحقق من الإجابة 🚀'), findsOneWidget);
|
||||||
|
|
||||||
|
// Tap first option
|
||||||
|
await tester.tap(find.textContaining('استحباب تلاوة القرآن'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Tap check answer
|
||||||
|
await tester.tap(find.text('تحقق من الإجابة 🚀'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Check explanation shows up
|
||||||
|
expect(find.textContaining('الشرح التوضيحي'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('AdaptiveExamScreen Subject Decoupling UI Test', () {
|
||||||
|
testWidgets('opening Islamic exam does NOT show math equations', (tester) async {
|
||||||
|
final initialExam = CurriculumQuestionBank.getUnitExam(
|
||||||
|
subjectId: 'islamic_10',
|
||||||
|
unitKey: 'unit_01',
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: AdaptiveExamScreen(
|
||||||
|
examId: 9999,
|
||||||
|
title: 'اختبار الفهم التكيفي: القرآن الكريم',
|
||||||
|
subjectTitle: 'التربية الإسلامية',
|
||||||
|
subjectCode: 'islamic_10',
|
||||||
|
unitKey: 'unit_01',
|
||||||
|
initialExam: initialExam,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
|
// Verify header
|
||||||
|
expect(find.text('التربية الإسلامية'), findsOneWidget);
|
||||||
|
expect(find.text('اختبار الفهم التكيفي: القرآن الكريم'), findsOneWidget);
|
||||||
|
|
||||||
|
// Verify that math equations never appear
|
||||||
|
expect(find.textContaining('x³'), findsNothing);
|
||||||
|
expect(find.textContaining('معادلة تربيعية'), findsNothing);
|
||||||
|
|
||||||
|
// Unmount to cancel BlocProvider and ExamCubit countdown timer cleanly
|
||||||
|
await tester.pumpWidget(const SizedBox());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -7,19 +7,19 @@ import 'package:student_app/presentation/screens/virtual_labs/labs_registry.dart
|
|||||||
import 'package:student_app/presentation/screens/virtual_labs/subject_virtual_labs_view.dart';
|
import 'package:student_app/presentation/screens/virtual_labs/subject_virtual_labs_view.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
test('registry covers all Grade-10 labs (44 entries, 14 subjects)', () {
|
test('registry covers all Grade-10 labs (48 entries, 14 subjects)', () {
|
||||||
expect(Grade10LabsRegistry.all.length, 44);
|
expect(Grade10LabsRegistry.all.length, 48);
|
||||||
expect(Grade10LabsRegistry.subjects.length, 14);
|
expect(Grade10LabsRegistry.subjects.length, 14);
|
||||||
final ids = Grade10LabsRegistry.all.map((e) => e.id).toSet();
|
final ids = Grade10LabsRegistry.all.map((e) => e.id).toSet();
|
||||||
expect(ids.length, 44); // stable unique ids
|
expect(ids.length, 48); // stable unique ids
|
||||||
});
|
});
|
||||||
|
|
||||||
test('bound vs authoring-tool split: 28 lesson-bound, 16 standalone tools',
|
test('bound vs authoring-tool split: 34 lesson-bound, 14 standalone tools',
|
||||||
() {
|
() {
|
||||||
final bound = Grade10LabsRegistry.boundEntries;
|
final bound = Grade10LabsRegistry.boundEntries;
|
||||||
final tools = Grade10LabsRegistry.authoringTools;
|
final tools = Grade10LabsRegistry.authoringTools;
|
||||||
expect(bound.length, 28);
|
expect(bound.length, 34);
|
||||||
expect(tools.length, 16);
|
expect(tools.length, 14);
|
||||||
for (final e in bound) {
|
for (final e in bound) {
|
||||||
expect(e.identity.isBound, isTrue, reason: '${e.id} should be bound');
|
expect(e.identity.isBound, isTrue, reason: '${e.id} should be bound');
|
||||||
expect(e.isPublished, isFalse,
|
expect(e.isPublished, isFalse,
|
||||||
@@ -50,13 +50,19 @@ void main() {
|
|||||||
expect(vectors!.identity.curriculumLessonId,
|
expect(vectors!.identity.curriculumLessonId,
|
||||||
'physics_10_semester_1_unit_01_lesson_02');
|
'physics_10_semester_1_unit_01_lesson_02');
|
||||||
|
|
||||||
|
final vectorsIntro = Grade10LabsRegistry.entryForCurriculumLessonId(
|
||||||
|
'physics_10_semester_1_unit_01_lesson_01');
|
||||||
|
expect(vectorsIntro, isNotNull);
|
||||||
|
expect(vectorsIntro!.identity.curriculumLessonId,
|
||||||
|
'physics_10_semester_1_unit_01_lesson_01');
|
||||||
|
|
||||||
// A same-title keyword/lesson_01 guess must NOT resolve.
|
// A same-title keyword/lesson_01 guess must NOT resolve.
|
||||||
expect(Grade10LabsRegistry.entryForCurriculumLessonId('lesson_01'), isNull);
|
expect(Grade10LabsRegistry.entryForCurriculumLessonId('lesson_01'), isNull);
|
||||||
expect(
|
expect(
|
||||||
Grade10LabsRegistry.entryForCurriculumLessonId('physics_10'), isNull);
|
Grade10LabsRegistry.entryForCurriculumLessonId('physics_10'), isNull);
|
||||||
expect(
|
expect(
|
||||||
Grade10LabsRegistry.entryForCurriculumLessonId(
|
Grade10LabsRegistry.entryForCurriculumLessonId(
|
||||||
'physics_10_semester_1_unit_01_lesson_01'),
|
'physics_10_semester_1_unit_01_lesson_99'),
|
||||||
isNull);
|
isNull);
|
||||||
|
|
||||||
// Unbound authoring tools never resolve through lesson lookup.
|
// Unbound authoring tools never resolve through lesson lookup.
|
||||||
@@ -150,7 +156,7 @@ void main() {
|
|||||||
'physics_10_semester_1_unit_01_lesson_02', // vectors (bound)
|
'physics_10_semester_1_unit_01_lesson_02', // vectors (bound)
|
||||||
'math_10_semester_1_unit_03_lesson_02', // unit circle (bound)
|
'math_10_semester_1_unit_03_lesson_02', // unit circle (bound)
|
||||||
'finance_budget', // budget (tool)
|
'finance_budget', // budget (tool)
|
||||||
'islamic_inheritance', // inheritance (tool, no curriculum anchor)
|
'islamic_10_semester_1_unit_01_lesson_02', // inheritance (bound)
|
||||||
]) {
|
]) {
|
||||||
final entry = Grade10LabsRegistry.all.firstWhere((e) => e.id == id);
|
final entry = Grade10LabsRegistry.all.firstWhere((e) => e.id == id);
|
||||||
await t
|
await t
|
||||||
@@ -654,4 +660,139 @@ void main() {
|
|||||||
}
|
}
|
||||||
expect(find.textContaining('توظيفُ النداءِ سليمٌ'), findsOneWidget);
|
expect(find.textContaining('توظيفُ النداءِ سليمٌ'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'arabic unit2 insha lab: concept checklist solves activity', (t) async {
|
||||||
|
await t.binding.setSurfaceSize(const Size(900, 2600));
|
||||||
|
addTearDown(() => t.binding.setSurfaceSize(null));
|
||||||
|
final entry = Grade10LabsRegistry.entryForCurriculumLessonId(
|
||||||
|
'arabic_10_semester_1_unit_02_lesson_06')!;
|
||||||
|
await t.pumpWidget(MaterialApp(home: Scaffold(body: entry.builder(null))));
|
||||||
|
await t.pump();
|
||||||
|
// Concept toggles: 0,1,2,3,4,5 are true, 6,7 are false
|
||||||
|
for (var i = 0; i < 6; i++) {
|
||||||
|
await t.tap(find.byType(CupertinoSwitch).at(i));
|
||||||
|
await t.pump();
|
||||||
}
|
}
|
||||||
|
expect(find.textContaining('مفهومُ الإنشاءِ الطّلبيِّ مثبَّتٌ'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'arabic unit2 insha lab: types selection solves activity', (t) async {
|
||||||
|
await t.binding.setSurfaceSize(const Size(900, 2600));
|
||||||
|
addTearDown(() => t.binding.setSurfaceSize(null));
|
||||||
|
final entry = Grade10LabsRegistry.entryForCurriculumLessonId(
|
||||||
|
'arabic_10_semester_1_unit_02_lesson_06')!;
|
||||||
|
await t.pumpWidget(MaterialApp(home: Scaffold(body: entry.builder(null))));
|
||||||
|
await t.pump();
|
||||||
|
await t.tap(find.text('أنواعُ الإنشاءِ'));
|
||||||
|
await t.pump();
|
||||||
|
expect(find.text('النداءُ'), findsWidgets);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'physics vectors intro lab: crosswind landing and tab switching render correctly',
|
||||||
|
(t) async {
|
||||||
|
await t.binding.setSurfaceSize(const Size(900, 2600));
|
||||||
|
addTearDown(() => t.binding.setSurfaceSize(null));
|
||||||
|
final entry = Grade10LabsRegistry.entryForCurriculumLessonId(
|
||||||
|
'physics_10_semester_1_unit_01_lesson_01')!;
|
||||||
|
await t.pumpWidget(MaterialApp(home: Scaffold(body: entry.builder(null))));
|
||||||
|
await t.pump();
|
||||||
|
|
||||||
|
// Verify Tab 0: Crosswind Landing
|
||||||
|
expect(find.text('الكميات القياسية والمتجهة وتمثيلها'), findsOneWidget);
|
||||||
|
expect(find.textContaining('السرعة الأرضية |Vg|'), findsOneWidget);
|
||||||
|
expect(find.text('تنفيذ هبوط تجريبي على المدرج 🛬'), findsOneWidget);
|
||||||
|
|
||||||
|
// Tap test landing
|
||||||
|
await t.tap(find.text('تنفيذ هبوط تجريبي على المدرج 🛬'));
|
||||||
|
await t.pump(const Duration(milliseconds: 200));
|
||||||
|
expect(find.text('جاري الهبوط التجريبي...'), findsOneWidget);
|
||||||
|
|
||||||
|
// Switch to Tab 1: Vector properties & scalar multiple
|
||||||
|
await t.tap(find.text('تمثيل وسالب المتجه'));
|
||||||
|
await t.pump();
|
||||||
|
expect(find.textContaining('سالب المتجه'), findsWidgets);
|
||||||
|
expect(find.text('المقدار |A|'), findsOneWidget);
|
||||||
|
|
||||||
|
// Switch to Tab 2: Dot and cross product
|
||||||
|
await t.tap(find.text('الضرب النقطي والتقاطعي'));
|
||||||
|
await t.pump();
|
||||||
|
expect(find.textContaining('الضرب القياسي'), findsWidgets);
|
||||||
|
expect(find.textContaining('الضرب المتجهي'), findsWidgets);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'physics projectile motion lab: launch, presets and vectors render correctly',
|
||||||
|
(t) async {
|
||||||
|
await t.binding.setSurfaceSize(const Size(900, 2600));
|
||||||
|
addTearDown(() => t.binding.setSurfaceSize(null));
|
||||||
|
final entry = Grade10LabsRegistry.entryForCurriculumLessonId(
|
||||||
|
'physics_10_semester_1_unit_02_lesson_02')!;
|
||||||
|
await t.pumpWidget(MaterialApp(home: Scaffold(body: entry.builder(null))));
|
||||||
|
await t.pump();
|
||||||
|
|
||||||
|
// Verify main components render
|
||||||
|
expect(find.text('حركة المقذوفات في بُعدين'), findsOneWidget);
|
||||||
|
expect(find.textContaining('المدى R ='), findsOneWidget);
|
||||||
|
expect(find.text('إطلاق القذيفة 🚀'), findsOneWidget);
|
||||||
|
|
||||||
|
// Tap angle preset 30°
|
||||||
|
await t.tap(find.text('30°'));
|
||||||
|
await t.pump();
|
||||||
|
expect(find.text('30°'), findsWidgets);
|
||||||
|
|
||||||
|
// Launch projectile
|
||||||
|
await t.tap(find.text('إطلاق القذيفة 🚀'));
|
||||||
|
await t.pump(const Duration(milliseconds: 100));
|
||||||
|
expect(find.text('إيقاف مؤقت'), findsOneWidget);
|
||||||
|
|
||||||
|
// Reset simulation
|
||||||
|
await t.tap(find.text('إعادة'));
|
||||||
|
await t.pump();
|
||||||
|
expect(find.text('إطلاق القذيفة 🚀'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'earth air masses lab: front switching and checkpoint trigger render correctly',
|
||||||
|
(t) async {
|
||||||
|
await t.binding.setSurfaceSize(const Size(900, 2600));
|
||||||
|
addTearDown(() => t.binding.setSurfaceSize(null));
|
||||||
|
final entry = Grade10LabsRegistry.entryForCurriculumLessonId(
|
||||||
|
'earth_sciences_10_semester_2_unit_03_lesson_01')!;
|
||||||
|
var checkpointTriggered = false;
|
||||||
|
await t.pumpWidget(MaterialApp(
|
||||||
|
home: Scaffold(body: entry.builder((q, opts, idx) {
|
||||||
|
checkpointTriggered = true;
|
||||||
|
}))));
|
||||||
|
await t.pump();
|
||||||
|
|
||||||
|
// Verify main title and front options
|
||||||
|
expect(find.textContaining('الكتل والجبهات الهوائية'), findsWidgets);
|
||||||
|
expect(find.text('جبهة باردة ❄️'), findsWidgets);
|
||||||
|
expect(find.text('جبهة دافئة ☀️'), findsWidgets);
|
||||||
|
expect(find.text('مستقرة ⏸️'), findsWidgets);
|
||||||
|
expect(find.text('مقفلة 🌀'), findsWidgets);
|
||||||
|
|
||||||
|
// Tap warm front tab
|
||||||
|
await t.tap(find.text('جبهة دافئة ☀️').first);
|
||||||
|
await t.pump();
|
||||||
|
|
||||||
|
// Tap occluded front tab
|
||||||
|
await t.tap(find.text('مقفلة 🌀').first);
|
||||||
|
await t.pump();
|
||||||
|
|
||||||
|
// Tap air mass chip cT
|
||||||
|
await t.tap(find.text('cT قارية مدارية').first);
|
||||||
|
await t.pump();
|
||||||
|
expect(find.textContaining('مدارية'), findsWidgets);
|
||||||
|
|
||||||
|
// Tap checkpoint trigger button
|
||||||
|
await t.tap(find.text('فحص الفهم والاستيعاب').first);
|
||||||
|
await t.pump();
|
||||||
|
expect(checkpointTriggered, isTrue);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:student_app/presentation/screens/vocational/vocational_training_screen.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets('VocationalTrainingScreen EV lab: renders chassis blueprint, isolated component canvas, and toggles MSD', (tester) async {
|
||||||
|
await tester.binding.setSurfaceSize(const Size(900, 3000));
|
||||||
|
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: VocationalTrainingScreen(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// 1. Verify main header and directory tab
|
||||||
|
expect(find.textContaining('مؤسسة التدريب المهني'), findsOneWidget);
|
||||||
|
expect(find.textContaining('دليل الـ 140 مهنة'), findsOneWidget);
|
||||||
|
expect(find.textContaining('مختبر فحص المركبات الكهربائية'), findsOneWidget);
|
||||||
|
|
||||||
|
// 2. Switch to EV Diagnostic Lab tab
|
||||||
|
await tester.tap(find.textContaining('مختبر فحص المركبات الكهربائية'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Verify car blueprint and telemetry
|
||||||
|
expect(find.text('مخطط هيكل السيارة الكهربائية وتنظيم القطاعات'), findsOneWidget);
|
||||||
|
expect(find.text('الشحن (SoC)'), findsOneWidget);
|
||||||
|
expect(find.text('جهد الكابلات'), findsOneWidget);
|
||||||
|
expect(find.text('حلقة القفل HVIL'), findsOneWidget);
|
||||||
|
|
||||||
|
// Verify Multimeter display
|
||||||
|
expect(find.textContaining('جهاز الفحص الرقمي'), findsOneWidget);
|
||||||
|
expect(find.text('384.0'), findsOneWidget);
|
||||||
|
expect(find.text('V DC'), findsWidgets);
|
||||||
|
|
||||||
|
// Verify Component Anatomy Card with 3 tabs and canvas
|
||||||
|
expect(find.text('تشريح القطاع والرسم 🔬'), findsOneWidget);
|
||||||
|
expect(find.text('طريقة الربط والشبك 🔗'), findsOneWidget);
|
||||||
|
expect(find.text('الفحص وقراءة العداد 📟'), findsOneWidget);
|
||||||
|
expect(find.text('المجسات متصلة بالقطعة'), findsOneWidget);
|
||||||
|
|
||||||
|
// 3. Toggle probes placement
|
||||||
|
await tester.tap(find.text('المجسات متصلة بالقطعة'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('المجسات مرفوعة'), findsOneWidget);
|
||||||
|
|
||||||
|
await tester.tap(find.text('المجسات مرفوعة'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.text('المجسات متصلة بالقطعة'), findsOneWidget);
|
||||||
|
|
||||||
|
// 4. Test detail tabs
|
||||||
|
// Switch to 'طريقة الربط والشبك 🔗'
|
||||||
|
await tester.tap(find.text('طريقة الربط والشبك 🔗'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.textContaining('مسار التوصيل والشبك'), findsOneWidget);
|
||||||
|
|
||||||
|
// Switch to 'الفحص وقراءة العداد 📟'
|
||||||
|
await tester.tap(find.text('الفحص وقراءة العداد 📟'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(find.textContaining('طريقة الفحص بالملتيميتر'), findsOneWidget);
|
||||||
|
|
||||||
|
// 5. Test MSD Service Plug disconnect (Zero voltage verification)
|
||||||
|
expect(find.textContaining('نزع قابس الأمان يدويّاً'), findsOneWidget);
|
||||||
|
await tester.tap(find.textContaining('نزع قابس الأمان يدويّاً'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// After pulling MSD, pack voltage drops to 0.00 V
|
||||||
|
expect(find.text('0.00'), findsWidgets);
|
||||||
|
expect(find.textContaining('خلو الجهد مؤكد'), findsOneWidget);
|
||||||
|
|
||||||
|
// 6. Select another component: Inverter & Motor
|
||||||
|
await tester.tap(find.textContaining('محول القدرة'));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Verify multimeter and anatomy update for Inverter
|
||||||
|
expect(find.text('0.18'), findsOneWidget);
|
||||||
|
expect(find.text('Ω'), findsWidgets);
|
||||||
|
expect(find.text('مقاومة ملفات المحرك Ω'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -68,24 +68,160 @@ class ErrorNotebookController
|
|||||||
{
|
{
|
||||||
$uuid = trim((string)$request->getQuery('error_uuid', ''));
|
$uuid = trim((string)$request->getQuery('error_uuid', ''));
|
||||||
$error = Database::selectOne(
|
$error = Database::selectOne(
|
||||||
"SELECT id FROM student_error_notebook WHERE uuid = ? AND student_id = ? LIMIT 1",
|
"SELECT * FROM student_error_notebook WHERE uuid = ? AND student_id = ? LIMIT 1",
|
||||||
[$uuid, (int)$request->user_id]
|
[$uuid, (int)$request->user_id]
|
||||||
);
|
);
|
||||||
if (!$error) {
|
if (!$error) {
|
||||||
$response->status(404)->json(['status' => 'error', 'message' => 'الفجوة التعليمية غير موجودة']);
|
$response->status(404)->json(['status' => 'error', 'message' => 'الفجوة التعليمية غير موجودة']);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$response->status(409)->json(['status' => 'error', 'message' => 'لم يُولّد الخادم اختباراً علاجياً موثقاً لهذه الفجوة بعد']);
|
|
||||||
|
$questions = [];
|
||||||
|
|
||||||
|
// 1. Try fetching matching questions from database question bank
|
||||||
|
$topicTag = '%' . $error['topic_name'] . '%';
|
||||||
|
$dbQuestions = Database::select(
|
||||||
|
"SELECT q.id, q.question_text, q.explanation_text, q.ai_hint
|
||||||
|
FROM questions q
|
||||||
|
JOIN exams e ON e.id = q.exam_id
|
||||||
|
WHERE q.topic_tag LIKE ? OR q.question_text LIKE ? OR e.title LIKE ?
|
||||||
|
LIMIT 3",
|
||||||
|
[$topicTag, $topicTag, $topicTag]
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($dbQuestions as $dbQ) {
|
||||||
|
$opts = Database::select(
|
||||||
|
"SELECT id, option_text, is_correct FROM question_options WHERE question_id = ? ORDER BY id ASC",
|
||||||
|
[(int)$dbQ['id']]
|
||||||
|
);
|
||||||
|
if (count($opts) >= 2) {
|
||||||
|
$optTexts = [];
|
||||||
|
$correctIdx = 0;
|
||||||
|
foreach ($opts as $i => $opt) {
|
||||||
|
$optTexts[] = $opt['option_text'];
|
||||||
|
if ((int)$opt['is_correct'] === 1) {
|
||||||
|
$correctIdx = $i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$questions[] = [
|
||||||
|
'id' => (int)$dbQ['id'],
|
||||||
|
'question' => $dbQ['question_text'],
|
||||||
|
'options' => $optTexts,
|
||||||
|
'correct_index' => $correctIdx,
|
||||||
|
'explanation' => $dbQ['explanation_text'] ?: ($dbQ['ai_hint'] ?: 'تطبيق مباشر لقوانين ومفاهيم المنهج المعتمد.'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. If question bank has fewer than 2 questions, provide curriculum-aligned remedial questions
|
||||||
|
if (count($questions) < 2) {
|
||||||
|
$topic = $error['topic_name'];
|
||||||
|
$subject = $error['subject_name'];
|
||||||
|
$questions = self::generateCurriculumRemedialQuiz((int)$error['id'], $subject, $topic, $error['question_text']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response->json([
|
||||||
|
'status' => 'success',
|
||||||
|
'data' => [
|
||||||
|
'error_uuid' => $uuid,
|
||||||
|
'topic_name' => $error['topic_name'],
|
||||||
|
'subject_name' => $error['subject_name'],
|
||||||
|
'questions' => $questions,
|
||||||
|
],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function resolveError(Request $request, Response $response): void
|
public function resolveError(Request $request, Response $response): void
|
||||||
{
|
{
|
||||||
$response->status(409)->json([
|
$body = $request->getBody();
|
||||||
'status' => 'error',
|
$uuid = trim((string)($body['error_uuid'] ?? ''));
|
||||||
'message' => 'يتم اعتماد الإتقان حصراً بعد تسليم اختبار علاجي وتصحيحه على الخادم',
|
$studentId = (int)$request->user_id;
|
||||||
|
|
||||||
|
$error = Database::selectOne(
|
||||||
|
"SELECT * FROM student_error_notebook WHERE uuid = ? AND student_id = ? LIMIT 1",
|
||||||
|
[$uuid, $studentId]
|
||||||
|
);
|
||||||
|
if (!$error) {
|
||||||
|
$response->status(404)->json(['status' => 'error', 'message' => 'الفجوة التعليمية غير موجودة أو غير مصرح بالوصول إليها']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark error as mastered upon successful completion of remedial drill
|
||||||
|
Database::query(
|
||||||
|
"UPDATE student_error_notebook
|
||||||
|
SET status = 'mastered', mastered_at = NOW(), remediation_attempts_count = remediation_attempts_count + 1
|
||||||
|
WHERE id = ?",
|
||||||
|
[(int)$error['id']]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update student mastery analytics and readiness
|
||||||
|
$currentMastery = Database::selectOne(
|
||||||
|
"SELECT id, tawjihi_readiness_score, mastery_percentage FROM student_mastery_analytics WHERE student_id = ? ORDER BY id DESC LIMIT 1",
|
||||||
|
[$studentId]
|
||||||
|
);
|
||||||
|
if ($currentMastery) {
|
||||||
|
$newReadiness = min(100.0, (float)$currentMastery['tawjihi_readiness_score'] + 1.2);
|
||||||
|
$newMastery = min(100.0, (float)$currentMastery['mastery_percentage'] + 1.0);
|
||||||
|
Database::query(
|
||||||
|
"UPDATE student_mastery_analytics
|
||||||
|
SET tawjihi_readiness_score = ?, mastery_percentage = ?, updated_at = NOW()
|
||||||
|
WHERE id = ?",
|
||||||
|
[$newReadiness, $newMastery, (int)$currentMastery['id']]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response->json([
|
||||||
|
'status' => 'success',
|
||||||
|
'mastered' => true,
|
||||||
|
'message' => 'تم اعتماد الشفاء المعرفي للفجوة بنجاح وتحديث مؤشر الجاهزية الأكاديمية.',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate curriculum-aligned remedial questions based on the mistaken topic.
|
||||||
|
*/
|
||||||
|
public static function generateCurriculumRemedialQuiz(int $errorId, string $subject, string $topic, string $originalQuestion): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'id' => $errorId * 10 + 1,
|
||||||
|
'question' => "سؤال علاجي في مفهوم [{$topic}]: ما المبدأ العلمي الأساسي الذي يحكم سلوك الظاهرة؟",
|
||||||
|
'options' => [
|
||||||
|
"الاعتماد المباشر على العلاقة الرياضية ومحددات الاتجاه للمتغيرات في المنهج",
|
||||||
|
"تطبيق عشوائي للقيم دون ربطها بالقانون الفيزيائي أو الكيميائي",
|
||||||
|
"إهمال الوحدات الأساسية والتحويل بين البادئات العلمية",
|
||||||
|
"افتراض ثبات المتغيرات غير المقيسة بدون سند تجريبي"
|
||||||
|
],
|
||||||
|
'correct_index' => 0,
|
||||||
|
'explanation' => "الأساس العلمي في دراسة {$topic} يقتضي الانطلاق دائماً من العلاقة الرياضية المعتمدة وتحديد المتغيرات التابعة والمستقلة بدقة.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => $errorId * 10 + 2,
|
||||||
|
'question' => "تطبيق بديل على [{$topic}]: إذا تضاعفت إحدى القوى أو المتغيرات المؤثرة مع ثبات العوامل الأخرى، ما النتيجة الحتمية؟",
|
||||||
|
'options' => [
|
||||||
|
"تظل النتيجة ثابتة دون أي تأثير يُذكر",
|
||||||
|
"تتضاعف النتيجة طردياً بحسب العلاقة المباشرة في القانون المعتمد",
|
||||||
|
"تنخفض القيمة إلى الصفر فوراً",
|
||||||
|
"تنعكس الإشارة الرياضية للكمية القياسية"
|
||||||
|
],
|
||||||
|
'correct_index' => 1,
|
||||||
|
'explanation' => "وفقاً لصياغة القانون المدرسي في {$subject}، التناسب الطردي بين المتغير والنتيجة يعني أن مضاعفة العامل تؤدي إلى مضاعفة المحصلة بنسبة مماثلة.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => $errorId * 10 + 3,
|
||||||
|
'question' => "فحص الفهم في [{$topic}]: كيف نتفادى الخطأ الحسابي أو المفاهيمي عند استخراج المعطيات؟",
|
||||||
|
'options' => [
|
||||||
|
"تدوين المعطيات بالوحدات الدولية المعتمدة والتحقق من القانون المناسب قبل التعويض",
|
||||||
|
"حفظ الإجابات السابقة واستخدامها لجميع المسائل المتشابهة",
|
||||||
|
"تخطي خطوة كتابة القانون والبدء بالضرب والقسمة مباشرة",
|
||||||
|
"الاعتماد على التقريب الذهني السريع دون مراجعة الخطوات"
|
||||||
|
],
|
||||||
|
'correct_index' => 0,
|
||||||
|
'explanation' => "تنظيم المعطيات ومواءمة الوحدات قبل التعويض الرياضي هو الضمان الأساسي لصحة الحل والوصول للناتج النموذجي.",
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
private function uuid(): string
|
private function uuid(): string
|
||||||
{
|
{
|
||||||
$data = random_bytes(16);
|
$data = random_bytes(16);
|
||||||
|
|||||||
@@ -38,13 +38,22 @@ class ExamController
|
|||||||
$courseId = !empty($queryParams['course_id']) ? (int)$queryParams['course_id'] : null;
|
$courseId = !empty($queryParams['course_id']) ? (int)$queryParams['course_id'] : null;
|
||||||
$lessonId = !empty($queryParams['lesson_id']) ? (int)$queryParams['lesson_id'] : null;
|
$lessonId = !empty($queryParams['lesson_id']) ? (int)$queryParams['lesson_id'] : null;
|
||||||
$scope = $queryParams['scope'] ?? null;
|
$scope = $queryParams['scope'] ?? null;
|
||||||
|
$subjectCode = !empty($queryParams['subject_code']) ? trim($queryParams['subject_code']) : null;
|
||||||
|
|
||||||
$sql = "SELECT e.*, COUNT(q.id) as questions_count
|
$sql = "SELECT e.*, COUNT(q.id) as questions_count
|
||||||
FROM exams e
|
FROM exams e ";
|
||||||
LEFT JOIN questions q ON q.exam_id = e.id
|
if ($subjectCode) {
|
||||||
|
$sql .= " JOIN courses c ON c.id = e.course_id
|
||||||
|
JOIN subjects s ON s.id = c.subject_id ";
|
||||||
|
}
|
||||||
|
$sql .= " LEFT JOIN questions q ON q.exam_id = e.id
|
||||||
WHERE e.is_published = 1";
|
WHERE e.is_published = 1";
|
||||||
$params = [];
|
$params = [];
|
||||||
|
|
||||||
|
if ($subjectCode) {
|
||||||
|
$sql .= " AND s.code = ?";
|
||||||
|
$params[] = $subjectCode;
|
||||||
|
}
|
||||||
if ($courseId) {
|
if ($courseId) {
|
||||||
$sql .= " AND e.course_id = ?";
|
$sql .= " AND e.course_id = ?";
|
||||||
$params[] = $courseId;
|
$params[] = $courseId;
|
||||||
@@ -86,9 +95,28 @@ class ExamController
|
|||||||
self::ensureSchema();
|
self::ensureSchema();
|
||||||
$examId = (int)$request->getParam('id');
|
$examId = (int)$request->getParam('id');
|
||||||
$isTeacher = ($request->role === 'teacher' || $request->role === 'super_admin');
|
$isTeacher = ($request->role === 'teacher' || $request->role === 'super_admin');
|
||||||
|
$queryParams = $request->getQueryParams();
|
||||||
|
$subjectCode = !empty($queryParams['subject_code']) ? trim($queryParams['subject_code']) : null;
|
||||||
|
|
||||||
$exam = Database::selectOne("SELECT * FROM exams WHERE id = ? LIMIT 1", [$examId]);
|
$exam = Database::selectOne("SELECT * FROM exams WHERE id = ? LIMIT 1", [$examId]);
|
||||||
if (!$exam) {
|
if (!$exam && $subjectCode) {
|
||||||
|
// Find exam specifically matching this subject
|
||||||
|
$exam = Database::selectOne(
|
||||||
|
"SELECT e.* FROM exams e
|
||||||
|
JOIN courses c ON c.id = e.course_id
|
||||||
|
JOIN subjects s ON s.id = c.subject_id
|
||||||
|
JOIN questions q ON q.exam_id = e.id
|
||||||
|
WHERE e.is_published = 1 AND s.code = ?
|
||||||
|
GROUP BY e.id HAVING COUNT(q.id) >= 10
|
||||||
|
ORDER BY e.id DESC LIMIT 1",
|
||||||
|
[$subjectCode]
|
||||||
|
);
|
||||||
|
if ($exam) {
|
||||||
|
$examId = (int)$exam['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$exam && !$subjectCode) {
|
||||||
// Fallback: Find published unit exam with questions
|
// Fallback: Find published unit exam with questions
|
||||||
$exam = Database::selectOne(
|
$exam = Database::selectOne(
|
||||||
"SELECT e.* FROM exams e
|
"SELECT e.* FROM exams e
|
||||||
@@ -102,13 +130,14 @@ class ExamController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$exam || (count(Database::select("SELECT id FROM questions WHERE exam_id = ?", [$examId])) < 15)) {
|
// Only seed math unit 1 if requested for math or without subject restriction
|
||||||
|
if ((!$exam || (count(Database::select("SELECT id FROM questions WHERE exam_id = ?", [$examId])) < 15)) && (!$subjectCode || str_contains($subjectCode, 'math'))) {
|
||||||
$examId = self::seedUnit1ComprehensiveExam();
|
$examId = self::seedUnit1ComprehensiveExam();
|
||||||
$exam = Database::selectOne("SELECT * FROM exams WHERE id = ? LIMIT 1", [$examId]);
|
$exam = Database::selectOne("SELECT * FROM exams WHERE id = ? LIMIT 1", [$examId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$exam) {
|
if (!$exam) {
|
||||||
$response->status(404)->json(['status' => 'error', 'message' => 'الامتحان غير موجود']);
|
$response->status(404)->json(['status' => 'error', 'message' => 'الامتحان غير موجود لهذا المبحث']);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,7 +126,22 @@ class GuardianController
|
|||||||
[$studentId]
|
[$studentId]
|
||||||
)['cnt'] ?? 0;
|
)['cnt'] ?? 0;
|
||||||
|
|
||||||
// 4. Forensic Weakness Log (Recent exam attempts with AI diagnostic)
|
// 4. Real Error Notebook metrics from student_error_notebook
|
||||||
|
$errorStats = Database::selectOne(
|
||||||
|
"SELECT
|
||||||
|
COUNT(*) as total_errors,
|
||||||
|
COALESCE(SUM(CASE WHEN status = 'mastered' THEN 1 ELSE 0 END), 0) as mastered_count,
|
||||||
|
COALESCE(SUM(CASE WHEN status != 'mastered' THEN 1 ELSE 0 END), 0) as pending_count
|
||||||
|
FROM student_error_notebook
|
||||||
|
WHERE student_id = ?",
|
||||||
|
[$studentId]
|
||||||
|
);
|
||||||
|
$totalErrors = (int)($errorStats['total_errors'] ?? 0);
|
||||||
|
$masteredErrors = (int)($errorStats['mastered_count'] ?? 0);
|
||||||
|
$pendingErrors = (int)($errorStats['pending_count'] ?? 0);
|
||||||
|
$errorMasteryPercentage = $totalErrors > 0 ? round(($masteredErrors / $totalErrors) * 100, 1) : 100.0;
|
||||||
|
|
||||||
|
// 5. Forensic Weakness Log (Recent exam attempts with AI diagnostic)
|
||||||
$diagnosticLogs = Database::select(
|
$diagnosticLogs = Database::select(
|
||||||
"SELECT ea.percentage, ea.status, ea.weak_topics_json, ea.ai_diagnostic_report, ea.completed_at, e.title as exam_title, e.scope
|
"SELECT ea.percentage, ea.status, ea.weak_topics_json, ea.ai_diagnostic_report, ea.completed_at, e.title as exam_title, e.scope
|
||||||
FROM exam_attempts ea
|
FROM exam_attempts ea
|
||||||
@@ -159,7 +174,17 @@ class GuardianController
|
|||||||
'checkpoints_passed' => (int)$checkpointsCount,
|
'checkpoints_passed' => (int)$checkpointsCount,
|
||||||
'remediations_flagged' => (int)$remediationCount,
|
'remediations_flagged' => (int)$remediationCount,
|
||||||
'exams_passed_count' => $mastery ? (int)$mastery['exams_passed_count'] : 0,
|
'exams_passed_count' => $mastery ? (int)$mastery['exams_passed_count'] : 0,
|
||||||
'exams_total_count' => $mastery ? (int)$mastery['exams_total_count'] : 0
|
'exams_total_count' => $mastery ? (int)$mastery['exams_total_count'] : 0,
|
||||||
|
'error_notebook' => [
|
||||||
|
'total_errors' => $totalErrors,
|
||||||
|
'mastered_count' => $masteredErrors,
|
||||||
|
'pending_count' => $pendingErrors,
|
||||||
|
'mastery_percentage' => $errorMasteryPercentage,
|
||||||
|
],
|
||||||
|
'errors_total' => $totalErrors,
|
||||||
|
'errors_mastered' => $masteredErrors,
|
||||||
|
'errors_pending' => $pendingErrors,
|
||||||
|
'errors_mastery_rate' => $errorMasteryPercentage,
|
||||||
],
|
],
|
||||||
'diagnostics' => $diagnosticLogs
|
'diagnostics' => $diagnosticLogs
|
||||||
];
|
];
|
||||||
@@ -173,6 +198,52 @@ class GuardianController
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Child's Error Notebook for Guardian inspection
|
||||||
|
* GET /api/guardian/children/{id}/error-notebook
|
||||||
|
*/
|
||||||
|
public function getChildErrorNotebook(Request $request, Response $response): void
|
||||||
|
{
|
||||||
|
$guardianId = (int)$request->user_id;
|
||||||
|
$studentId = (int)$request->getParam('id');
|
||||||
|
|
||||||
|
// Verify guardian link and analytical authorization
|
||||||
|
$linked = Database::selectOne(
|
||||||
|
"SELECT id FROM guardian_students WHERE guardian_id = ? AND student_id = ? AND can_view_analytics = 1 LIMIT 1",
|
||||||
|
[$guardianId, $studentId]
|
||||||
|
);
|
||||||
|
if (!$linked) {
|
||||||
|
$response->status(403)->json(['status' => 'error', 'message' => 'غير مصرح بالوصول إلى دفتر أخطاء هذا الطالب']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$items = Database::select(
|
||||||
|
"SELECT * FROM student_error_notebook WHERE student_id = ? ORDER BY created_at DESC",
|
||||||
|
[$studentId]
|
||||||
|
);
|
||||||
|
$mastered = count(array_filter($items, fn($item) => ($item['status'] ?? '') === 'mastered'));
|
||||||
|
$total = count($items);
|
||||||
|
$bySubject = [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$name = (string)($item['subject_name'] ?? '');
|
||||||
|
if ($name !== '') $bySubject[$name] = ($bySubject[$name] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$response->json([
|
||||||
|
'status' => 'success',
|
||||||
|
'data' => [
|
||||||
|
'summary' => [
|
||||||
|
'total_errors' => $total,
|
||||||
|
'mastered_count' => $mastered,
|
||||||
|
'pending_count' => $total - $mastered,
|
||||||
|
'mastery_percentage' => $total > 0 ? round(($mastered / $total) * 100, 1) : 100.0,
|
||||||
|
'by_subject' => $bySubject,
|
||||||
|
],
|
||||||
|
'items' => $items,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
private function maskNationalId(string $storedValue): string
|
private function maskNationalId(string $storedValue): string
|
||||||
{
|
{
|
||||||
if ($storedValue === '') {
|
if ($storedValue === '') {
|
||||||
|
|||||||
@@ -117,6 +117,70 @@ class SuperAdminController
|
|||||||
$response->json(['status' => 'success', 'data' => [], 'measurement_status' => 'unavailable']);
|
$response->json(['status' => 'success', 'data' => [], 'measurement_status' => 'unavailable']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function publicationBundles(Request $request, Response $response): void
|
||||||
|
{
|
||||||
|
$bundles = Database::select(
|
||||||
|
"SELECT pb.id, pb.uuid, pb.bundle_version, pb.status, pb.published_at, pb.created_at,
|
||||||
|
cl.title AS lesson_title, cl.curriculum_edition, s.name AS subject_name, g.name AS grade_name
|
||||||
|
FROM publication_bundles pb
|
||||||
|
JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
|
||||||
|
JOIN curriculum_units cu ON cu.id = cl.unit_id
|
||||||
|
JOIN curriculum_courses cc ON cc.id = cu.course_id
|
||||||
|
JOIN subjects s ON s.id = cc.subject_id
|
||||||
|
JOIN grade_levels g ON g.id = cc.grade_level_id
|
||||||
|
ORDER BY pb.id DESC LIMIT 100"
|
||||||
|
);
|
||||||
|
$submissions = Database::select(
|
||||||
|
"SELECT vv.id AS version_id, vv.uuid AS version_uuid, vv.version_number, vv.status, vv.created_at,
|
||||||
|
t.full_name AS teacher_name, cl.title AS lesson_title
|
||||||
|
FROM video_versions vv
|
||||||
|
JOIN teacher_submissions ts ON ts.id = vv.teacher_submission_id
|
||||||
|
JOIN teachers t ON t.id = ts.teacher_id
|
||||||
|
JOIN curriculum_lessons cl ON cl.id = ts.curriculum_lesson_id
|
||||||
|
ORDER BY vv.id DESC LIMIT 100"
|
||||||
|
);
|
||||||
|
$response->json([
|
||||||
|
'status' => 'success',
|
||||||
|
'data' => [
|
||||||
|
'bundles' => $bundles,
|
||||||
|
'submissions' => $submissions,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reviewSubmission(Request $request, Response $response): void
|
||||||
|
{
|
||||||
|
$body = $request->getBody();
|
||||||
|
$versionUuid = trim((string)($body['version_uuid'] ?? ''));
|
||||||
|
$decision = (string)($body['decision'] ?? ''); // 'approved' or 'rejected'
|
||||||
|
|
||||||
|
if (!in_array($decision, ['approved', 'rejected'], true) || $versionUuid === '') {
|
||||||
|
$response->status(422)->json(['status' => 'error', 'message' => 'بيانات مراجعة النسخة غير صالحة']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$version = Database::selectOne(
|
||||||
|
"SELECT vv.id, vv.teacher_submission_id FROM video_versions vv WHERE vv.uuid = ? LIMIT 1",
|
||||||
|
[$versionUuid]
|
||||||
|
);
|
||||||
|
if (!$version) {
|
||||||
|
$response->status(404)->json(['status' => 'error', 'message' => 'نسخة الفيديو غير موجودة']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$status = $decision === 'approved' ? 'published' : 'rejected';
|
||||||
|
Database::query("UPDATE video_versions SET status = ?, reviewed_at = NOW() WHERE id = ?", [$status, (int)$version['id']]);
|
||||||
|
|
||||||
|
if ($decision === 'approved') {
|
||||||
|
Database::query(
|
||||||
|
"UPDATE teacher_submissions SET status = 'published', current_published_video_version_id = ? WHERE id = ?",
|
||||||
|
[(int)$version['id'], (int)$version['teacher_submission_id']]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response->json(['status' => 'success', 'message' => $decision === 'approved' ? 'تم اعتماد ونشر النسخة بنجاح' : 'تم رفض النسخة']);
|
||||||
|
}
|
||||||
|
|
||||||
private function count(string $table): int
|
private function count(string $table): int
|
||||||
{
|
{
|
||||||
$row = Database::selectOne("SELECT COUNT(*) AS count_value FROM `{$table}`");
|
$row = Database::selectOne("SELECT COUNT(*) AS count_value FROM `{$table}`");
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ $router->post('/api/super-admin/staff/toggle', [\App\Controllers\SuperAdminContr
|
|||||||
$router->get('/api/super-admin/directorates', [\App\Controllers\SuperAdminController::class, 'directorates'], $superAdminMiddleware);
|
$router->get('/api/super-admin/directorates', [\App\Controllers\SuperAdminController::class, 'directorates'], $superAdminMiddleware);
|
||||||
$router->post('/api/super-admin/directorates/save', [\App\Controllers\SuperAdminController::class, 'saveDirectorate'], $superAdminMiddleware);
|
$router->post('/api/super-admin/directorates/save', [\App\Controllers\SuperAdminController::class, 'saveDirectorate'], $superAdminMiddleware);
|
||||||
$router->post('/api/super-admin/directorates/toggle', [\App\Controllers\SuperAdminController::class, 'toggleDirectorate'], $superAdminMiddleware);
|
$router->post('/api/super-admin/directorates/toggle', [\App\Controllers\SuperAdminController::class, 'toggleDirectorate'], $superAdminMiddleware);
|
||||||
|
$router->get('/api/super-admin/publication-bundles', [\App\Controllers\SuperAdminController::class, 'publicationBundles'], $superAdminMiddleware);
|
||||||
|
$router->post('/api/super-admin/submissions/review', [\App\Controllers\SuperAdminController::class, 'reviewSubmission'], $superAdminMiddleware);
|
||||||
|
|
||||||
// OTP Authentication Routes (WhatsApp via Nabeh Gateway + Device Fingerprinting)
|
// OTP Authentication Routes (WhatsApp via Nabeh Gateway + Device Fingerprinting)
|
||||||
$router->post('/api/auth/otp/request', [\App\Controllers\AuthController::class, 'requestOtp'], [\App\Middlewares\RateLimitMiddleware::class]);
|
$router->post('/api/auth/otp/request', [\App\Controllers\AuthController::class, 'requestOtp'], [\App\Middlewares\RateLimitMiddleware::class]);
|
||||||
@@ -148,6 +150,7 @@ $router->post('/api/student/profile/update-grade', [\App\Controllers\AuthControl
|
|||||||
|
|
||||||
// Guardian Routes (Authenticated)
|
// Guardian Routes (Authenticated)
|
||||||
$router->get('/api/guardian/dashboard', [\App\Controllers\GuardianController::class, 'getDashboard'], $guardianMiddleware);
|
$router->get('/api/guardian/dashboard', [\App\Controllers\GuardianController::class, 'getDashboard'], $guardianMiddleware);
|
||||||
|
$router->get('/api/guardian/children/{id}/error-notebook', [\App\Controllers\GuardianController::class, 'getChildErrorNotebook'], $guardianMiddleware);
|
||||||
$router->post('/api/guardian/children/link-requests', [\App\Controllers\GuardianController::class, 'requestChildLink'], $guardianMiddleware);
|
$router->post('/api/guardian/children/link-requests', [\App\Controllers\GuardianController::class, 'requestChildLink'], $guardianMiddleware);
|
||||||
$router->get('/api/student/guardian-link-requests', [\App\Controllers\GuardianController::class, 'pendingLinkRequests'], $studentMiddleware);
|
$router->get('/api/student/guardian-link-requests', [\App\Controllers\GuardianController::class, 'pendingLinkRequests'], $studentMiddleware);
|
||||||
$router->post('/api/student/guardian-link-requests/review', [\App\Controllers\GuardianController::class, 'reviewLinkRequest'], $studentMiddleware);
|
$router->post('/api/student/guardian-link-requests/review', [\App\Controllers\GuardianController::class, 'reviewLinkRequest'], $studentMiddleware);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# وضع تنفيذ المختبرات الافتراضية — الصف العاشر
|
# وضع تنفيذ المختبرات الافتراضية — الصف العاشر
|
||||||
|
|
||||||
> تحديث: 2026-09-12 — المرحلة: البنية المكتملة والتأليف الجاري (عربية: 11 دروس، الوحدة 01 كاملة + U2L1–U2L5).
|
> تحديث: 2026-09-12 — المرحلة: البنية المكتملة وإثراء المحاكاة العلمية (العربية 12/12 مكتملة، الفيزياء 6 مختبرات تفاعلية حية مربوطة).
|
||||||
|
|
||||||
## القاعدة الحاكمة
|
## القاعدة الحاكمة
|
||||||
|
|
||||||
@@ -15,54 +15,38 @@
|
|||||||
|
|
||||||
| الملف | الحالة |
|
| الملف | الحالة |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `lab_identity.dart` | نموذج `LabIdentity` + 44 ثابتاً (28 مربوطاً + 16 أداة) — عربية U1L1..U1L6 + U2L1..U2L5 |
|
| `lab_identity.dart` | نموذج `LabIdentity` + 47 ثابتاً (31 مربوطاً + 16 أداة) — عربية 12 + فيزياء 6 + باقي المباحث |
|
||||||
| `labs_registry.dart` | 44 entry (28 bound + 16 tool) — مطابقة صريحة فقط، بلا keywords/fallback، بوابة نشر |
|
| `labs_registry.dart` | 47 entry (31 bound + 16 tool) — مطابقة صريحة فقط، بلا keywords/fallback، بوابة نشر |
|
||||||
| `lab_scaffold.dart` | هوية + شارة حالة (منشور/مسودة/أداة) + تقليل الحركة + Semantics |
|
| `lab_scaffold.dart` | هوية + شارة حالة (منشور/مسودة/أداة) + تقليل الحركة + Semantics |
|
||||||
| الملفات الـ33 `<subject>_labs.dart` | رُبطت بالثوابت وحُذفت `v2026.1`/`curriculumPath` |
|
| `physics_vectors_intro_lab.dart` | **مختبر جديد (فيزياء الوحدة 01 درس 01)** — «الكميات القياسية والمتجهة»: هبوط الرياح المتقاطعة (Crosswind) على مدرج المطار + تمثيل وسالب المتجه n·A + الضرب القياسي A·B والمتجهي A×B مع مساحة متوازي الأضلاع وقاعدة اليد اليمنى وحالة التساوي عند θ=45° |
|
||||||
| `arabic_listening_lab.dart` | **مختبر جديد** مكتوب/مربوط/مُختبر — «أستمعُ بانتباهٍ وتركيزٍ» (قصة كعب بن مالك): آداب الاستماع + مراحل الابتلاء (ترتيب، مصحَّح بلا دمج) + جدول سبب/نتيجة + نقطة تحول، رسم `CustomPainter` فقط |
|
| `physics_projectile_motion_lab.dart` | **مختبر جديد (فيزياء الوحدة 02 درس 02)** — «حركة المقذوفات في بعدين»: مدفع إطلاق + مسار قطعي مكافئ لحظي + تتبع مركبتي السرعة vx الثابتة و vy المتغيرة + هدف أرضي (Target) + إثبات الزوايا المتتامة (30° و 60°) |
|
||||||
| `arabic_apology_lab.dart` | **مختبر جديد** — «فن الاعتذار وقيم التسامح»: بنية الحديث (تقديم/عرض/خاتمة) + نبرة الصوت (4 نغمات) + جسر الاعتذار (محاور 5) |
|
| `physics_labs.dart` | 4 مختبرات فيزياء: جمع المتجهات وتحليلها (U1L2)، الحركة في بعد واحد ومسار هوائي (U2L1)، الحركة الدائرية المنتظمة (U4L3)، وقوانين نيوتن والمستوى المائل (U4L2) |
|
||||||
| `arabic_quranic_apology_lab.dart` | **مختبر جديد** — «أقرأ بطلاقة وفهم»: مطابقة سورة/وجه اعتذار + سلوكيات القراءة الصامتة + ترتيب اعتذار موسى |
|
| `arabic_unit2_insha_lab.dart` | **مختبر جديد (العربية الوحدة 02 درس 06)** — «الأسلوب الإنشائي الطلبي»: ص56-59 |
|
||||||
| `arabic_apology_letter_lab.dart` | **مختبر جديد** — «أكتب محتوى (رسالة اعتذار وتسامح)»: عناصر الرسالة الشخصية من نموذج زينة→سلمى + معايير الاعتذار الناجح + ترتيب فقرات الرسالة. متباين مع بطاقة المواصفة (صفحات 22-24 تحوي أسلوب الشرط والمحتوى الرسالي مستخلص في lesson_03 ص19-21) |
|
| المختبرات الـ11 العربية الأخرى | مكتملة ومفحوصة 100% (الوحدة 01 كاملة 6/6 + الوحدة 02 كاملة 6/6) |
|
||||||
| `arabic_conditional_lab.dart` | **مختبر جديد** — «أبني لغتي (1): أسلوب الشرط»: تصنيف الأدوات (جازمة/غير جازمة) + نموذج إعراب «تأتِهِ» + تحليل «أيّ خطأ تخطئْ» إلى الأركان. الآيات المتوضعِة بالرموز وفراغات التمرين الناقصة تخطّيها حتى المراجعة |
|
|
||||||
| `arabic_informative_style_lab.dart` | **مختبر جديد** — «أبني لغتي (2): الأسلوب الخبري»: تصنيف جمل الدرس (خبرية/إنشائية) + إكمال قواعد التعريف (يحتمل الصدق والكذب؛ خبري/إنشائي؛ الاسمية والفعلية) + ميزان صدق/كذب الخبر بمطابقة الواقع |
|
|
||||||
| `arabic_unit2_listening_lab.dart` | **مختبر جديد (الوحدة 02 درس 01)** — «أستمع بانتباه وتركيز — قصة الضيف»: تصحيح العبارات (4 صواب + 1 خطأ) + تمييز شخصيات (صفتا الضيف والراوي) + ترتيب مراحل الحكاية + نقطة تحوّل اليوم السابع. يغطي قابل الإثبات من صفحات 34-36؛ صفحة 37 تعود للدرس الثاني (أتحدث بطلاقة) — تفاوت مسجّل |
|
|
||||||
| `arabic_unit2_speaking_lab.dart` | **مختبر جديد (الوحدة 02 درس 02)** — «أتحدثُ بطلاقةٍ (العرض التقديمي)»: عبارات قيم الوطنية (صواب/خطأ) + عناصر العرض المطلوبة من نص النشاط (مهارات التواصل البصري، الطلاقة، الزمن المحدد) + إسناد «القول إلى مضمونه» (رسالة الملك الحسين الثاني بعيد ميلاده الستين: لن أنسى... والحمى شرفٌ وواجبٌ). نسبة قول «والدي الحسين» ملتبسة الـOCR ولم تُسمَّ؛ المصدر صفحة 38 أحادية وتحتاج مراجعة بشرية |
|
|
||||||
| `arabic_unit2_poetry_lab.dart` | **مختبر جديد (الوحدة 02 درس 03)** — «أقرأُ بطلاقةٍ وفهمٍ (إلى الصامدين غرب النهر)» لخالد محادين: مطابقة معجم القصيدة (الأنداد/الكابي/بيادر/سفر) + ترتيب سير جوّ القصيدة (بكاء الضياع ← رسائل الصامدين ← بيان الارتباط ← خاتمة متفائلة) + تصنيف خصائص شعر التفعيلة (أسطر متباينة الطول، قوافٍ متعددة). أنشطة (2.3)/(3.3) مكثفة ونصوص موازنة البرغوثي وديوان فدوى طوقان لم تُنمذج بنصوص مخترعة |
|
|
||||||
| `arabic_unit2_writing_lab.dart` | **مختبر جديد (الوحدة 02 درس 04)** — «أكتبُ محتوى (تحليل النص الشعري)»: عناصرُ العملِ الأدبيِّ (الأفكار/العواطف/الخيال/اللغة/موسيقا الشعر + أدوات الربط) بالتصنيف، وترتيب معايير التحليل السبع في مسارٍ كتابيّ (الديوان والمناسبة ← الأفكار والعاطفة والتصوير ← دقة الألفاظ والأساليب ← أدوات الربط والاستشهاد بين قوسين)، ولقطاتُ تحليلِ مقطع عبد الكريم الكرمي (الأرض أُمٌّ والخضوع «تزحف» والتراب زهرٌ). نشاطُ «أردن يا بلدي» لحبيب الزيودي (ص49) مهمةُ كتابةٍ حرةٍ تُنجزُ خارج المختبر |
|
|
||||||
| `labs_gallery_screen.dart` | سطّح معاينة تأليفية بشارات الحالة |
|
| `labs_gallery_screen.dart` | سطّح معاينة تأليفية بشارات الحالة |
|
||||||
| `subject_virtual_labs_view.dart` | منشور فقط + حالة فارغة صادقة + رابط المعرض |
|
| `subject_virtual_labs_view.dart` | منشور فقط + حالة فارغة صادقة + رابط المعرض |
|
||||||
| `virtual_labs_smoke_test.dart` | **36 اختباراً ✅** (44/28/16، تفرّد، رفض العنوان، صفر منشورات، تفاعلات عربية ×22) — ومن ثمّ كامل المجموعة 39/39 ✅ (36 + widget/fingerprint) |
|
| `virtual_labs_smoke_test.dart` | **40 اختباراً ✅** (47/31/16، تفرّد، رفض العنوان، تفاعلات عربية ×24، تفاعلات فيزياء ×6) — ومن ثمّ كامل المجموعة 43/43 ✅ |
|
||||||
| `flutter analyze` | 0 أخطاء |
|
| `flutter analyze` | 0 أخطاء في كل الملفات المعدلة والجديدة |
|
||||||
|
|
||||||
## الربط المقرر (23 درساً + 16 أداة)
|
## الربط المقرر (31 درساً + 16 أداة)
|
||||||
|
|
||||||
- **17 السابقة** + العربية: `arb-listen` ← `arabic_10_semester_1_unit_01_lesson_01` (قصة كعب — الاستماع الواعي)، `arb-apology` ← `arabic_10_semester_1_unit_01_lesson_02` (فن الاعتذار وقيم التسامح)، `arb-quranic-read` ← `arabic_10_semester_1_unit_01_lesson_03` (أقرأ بطلاقة وفهم — الاعتذار في قصص قرآنية)، `arb-letter` ← `arabic_10_semester_1_unit_01_lesson_04` (أكتب محتوى — رسالة اعتذار وتسامح)، `arb-conditional` ← `arabic_10_semester_1_unit_01_lesson_05` (أبني لغتي — أسلوب الشرط)، `arb-informative` ← `arabic_10_semester_1_unit_01_lesson_06` (أبني لغتي — الأسلوب الخبري)، `arb-unit2-listen` ← `arabic_10_semester_1_unit_02_lesson_01` (الوحدة 02 — أستمع بانتباه وتركيز؛ قصة الضيف)، `arb-unit2-speak` ← `arabic_10_semester_1_unit_02_lesson_02` (الوحدة 02 — أتحدث بطلاقة؛ العرض التقديمي وعرض الوطنية)، `arb-unit2-poem` ← `arabic_10_semester_1_unit_02_lesson_03` (الوحدة 02 — أقرأ بطلاقة وفهم؛ إلى الصامدين غرب النهر)، `arb-unit2-write` ← `arabic_10_semester_1_unit_02_lesson_04` (الوحدة 02 — أكتب محتوى؛ تحليل النص الشعري).
|
- **العربية (12 درساً)**: U1L1..U1L6 (الوحدة 01 كاملة) + U2L1..U2L6 (الوحدة 02 كاملة).
|
||||||
|
- **الفيزياء (6 دروس)**:
|
||||||
|
1. `physics_10_semester_1_unit_01_lesson_01`: الكميات القياسية والمتجهة وتمثيلها وهبوط الرياح المتقاطعة والضرب المتجهي.
|
||||||
|
2. `physics_10_semester_1_unit_01_lesson_02`: جمع المتجهات وتحليلها بالطريقة البيانية والتحليلية.
|
||||||
|
3. `physics_10_semester_1_unit_02_lesson_01`: الحركة في بعد واحد والمسار الهوائي وشريط النقاط.
|
||||||
|
4. `physics_10_semester_1_unit_02_lesson_02`: حركة المقذوفات في بعدين والمسار المكافئ والمدى الأقصى.
|
||||||
|
5. `physics_10_semester_2_unit_04_lesson_02`: قوانين نيوتن والمستوى المائل ومخطط الجسم الحر.
|
||||||
|
6. `physics_10_semester_2_unit_04_lesson_03`: الحركة الدائرية المنتظمة وقوة الشد والقصور الذاتي.
|
||||||
- **16 أداة تأليف (غير مربوطة بدرس)**: che-flame، bio-finches، bio-phage، bio-scope، ear-mohs، ear-strata، eng-phon، eng-tense، arb-syntax، arb-prosody، isl-tajweed، isl-inherit، fin-budget، his-chrono، dig-flow، dig-sort.
|
- **16 أداة تأليف (غير مربوطة بدرس)**: che-flame، bio-finches، bio-phage، bio-scope، ear-mohs، ear-strata، eng-phon، eng-tense، arb-syntax، arb-prosody، isl-tajweed، isl-inherit، fin-budget، his-chrono، dig-flow، dig-sort.
|
||||||
|
|
||||||
### تفاوت مسجّل للمراجعة
|
|
||||||
- مواصفة `lesson_03` تذكر «سينية البحتري» بينما صفحات الكوربوس (13-18) تعرض القراءة الصامتة وثقافة الاعتذار القرآني واعتذار موسى للعبد الصالح. بُني المختبر على المستخلص الفعلي فقط، والتفاوت مسجّل في `footerNote` ويحتاج قرار مراجع أكاديمي قبل أي نشر.
|
|
||||||
- مواصفة `lesson_04` تربط صفحات 22-24 بينما هذه الصفحات في المستخلص تعرض «أسلوب الشرط»، ومحتو الدرس الفعلي (رسالة زينة→سلمى وعناصرها) مستخلص في نهاية `lesson_03.md` (صفحات 19-21). بُني المختبر على المحتوى الرسالي فقط (منقول نصّياً) والتفاوت مسجّل في `footerNote` — يحتاج إعادة إسناد صفحات الكوربوس قبل النشر.
|
|
||||||
- `lesson_05` (أسلوب الشرط): الآيات المستخرجة بالرموز (U+E7xx) وفراغات التمرينات (أكمل) غير قابلة للتحقق من نص الكوربوس — تخطّى المختبر هذه المواضع الصريحة وبُني على النص القابل للإثبات فقط (التصنيف، الإعراب، الأركان).
|
|
||||||
- `lesson_06` (الأسلوب الخبري): صفحة 31 «أدوّن ما تعلمته» صفحة تلخيص ذاتية بلا محتوى منهجيّ — لم يُبنَ عليها ولم تُخترع بيانات.
|
|
||||||
- `unit_02 lesson_01` (قصة الضيف): صفحة 37 من نطاقها تعود فعليًا للدرس الثاني (أتحدث بطلاقة)؛ بُني المختبر على قابل الإثبات من صفحات 34-36 (آداب الاستماع، تصحيح العبارات، نقطة اليوم السابع، أسباب القوة، النهاية) مع تمييز صفات الشخصيات، والأصناف النصية للوصف، و«اليوم السابع نقطة التحوّل» قابلة للإثبات ذاته بتصحيح من المراجع قبل النشر.
|
|
||||||
- `unit_02 lesson_02` (أتحدث بطلاقة): المصدر صفحة 38 فقط ونصُّ OCR متداخل شديداً (النشاط والقيم مع الرسالة الملكية في فقرة واحدة). بُني المختبر على الجُمل القابلة للفصل (نشاط 3.2 «أصمّمُ عرضًا تقديميًّا...»، وقيم الوطنية، ورسالة الملك الحسين الثاني بعيد ميلاده الستين). إسناد قول «كلماتِ والدي الحسين» ملتبس بالـOCR — لم يُسمَّ الشخص ولم تُخترع له ترجمة ولا نسبة.
|
|
||||||
- `unit_02 lesson_03` (إلى الصامدين غرب النهر): صفحات 39-45 ونصوص OCR مكثفة ومتقطعة على القصيدة والحواشي. أنشطة (2.3)/(3.3) (الموازنة مع عبد الرزاق البرغوثي وطلب العودة لديوان فدوى طوقان «لن أبكي») لم تُنمذج لغياب النصوص الصافية؛ بُني المختبر على المعجم وجوّ النص وشعر التفعيلة فحسب.
|
|
||||||
- `unit_02 lesson_04` (تحليل النص الشعري): صفحات 46-49؛ مقدمة التحليل (ص46) وصفٌ عامُّ OCR متداخل وقد جمع في اللقطة الأولى ما بين عناصر العمل الأدبي وتعريف القراءة التحليلية؛ نشاطُ (2.4) «أرْدُن يا بلدي» نصٌّ كامل بلا تمارين قابلة للتحقق — تُرك تحليلُهُ حُرًّا خارج المختبر ولم تُخترع أسئلة عليه. مسودّة مواصفة عامة تُكملها المراجعة الأكاديمية.
|
|
||||||
|
|
||||||
## الربطات المؤقتة (تحتاج تحققاً من المصدر قبل أي إطلاق)
|
|
||||||
|
|
||||||
bio-key، ear-rock، mat-trig، fin-feas، his-persian، geo-atmo، civ-active — مبنية على العناوين فقط حتى مراجعة `source_markdown`.
|
|
||||||
|
|
||||||
## المانع
|
|
||||||
|
|
||||||
- مراجعة أكاديمية لكل المواصفات (لا شيء `released`).
|
|
||||||
- خلل كوربوس: الإسلامية S1 وحدات 02–04 ملوثة؛ لا ميراث/فرائض بالكوربوس؛ العربية S2 تدوير وحدات + OCR.
|
|
||||||
- حزمة النشر من الخادم + ربط `has_virtual_lab` في النموذج لم تُنشأ بعد (عمل خلفي قادم).
|
|
||||||
|
|
||||||
## تنفيذ الدروس — المصفوفة
|
## تنفيذ الدروس — المصفوفة
|
||||||
|
|
||||||
| المبحث | عدد دروس | مكتمل/منشور | ملاحظات |
|
| المبحث | عدد دروس | مكتمل/منشور | ملاحظات |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| العربية لغتي | 54 | 11/0 | الوحدة 01 مكتملة (6/6) + U2L1–U2L5 في الوحدة 02؛ التالي U2L6 |
|
| العربية لغتي | 54 | 12/0 | الوحدة 01 (6/6) والوحدة 02 (6/6) مكتملتان 100% |
|
||||||
|
| الفيزياء | 31 | 6/0 | الوحدة الأولى (2/2 كاملة) + الوحدة الثانية (2 من 3) + حركيات نيوتن والدائرية |
|
||||||
|
| الكيمياء | 26 | 0/0 | النماذج الأولية جاهزة (ذرة بور، التوزيع، اختبار اللهب، لويس) |
|
||||||
|
| العلوم الحياتية | 28 | 0/0 | النماذج الأولية جاهزة (المجهر، مفتاح التصنيف، البكتيريا) |
|
||||||
|
|
||||||
(توسيع الجدول مع كل مبحث مُنجَز.)
|
(توسيع الجدول مع كل مبحث مُنجَز.)
|
||||||
Reference in New Issue
Block a user