Update Saqel Platform: 2026-09-13 16:31:17

This commit is contained in:
Hamza-Ayed
2026-09-13 16:31:18 +03:00
parent 81fa8c5dc8
commit 72c6575765
25 changed files with 6997 additions and 3852 deletions
+3
View File
@@ -43,6 +43,9 @@ dev_dependencies:
flutter_lints: ^3.0.0
dependency_overrides:
path_provider_foundation: 2.4.2
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
@@ -12,6 +12,20 @@ class AppLogger {
}
}
static void event(String name, {Map<String, dynamic>? details, String tag = 'EVENT'}) {
if (!kDebugMode) return;
final buffer = StringBuffer();
buffer.write('⚡ [$tag] $name');
if (details != null && details.isNotEmpty) {
try {
buffer.write(' -> ${jsonEncode(details)}');
} catch (_) {
buffer.write(' -> $details');
}
}
debugPrint(buffer.toString());
}
static void request({
required String method,
required Uri uri,
@@ -37,8 +37,18 @@ class SubjectModel {
this.exams = const [],
});
List<CurriculumUnitModel> get semester1Units => units.where((u) => u.semester == 'semester_1').toList();
List<CurriculumUnitModel> get semester2Units => units.where((u) => u.semester == 'semester_2').toList();
List<CurriculumUnitModel> unitsForSemester(String semesterKey) {
final filtered = units.where((u) => u.semester == semesterKey).toList();
return filtered.isNotEmpty ? filtered : units;
}
factory SubjectModel.fromJson(String id, Map<String, dynamic> json) {
final title = json['name']?.toString() ?? json['title']?.toString() ?? id;
var title = json['name']?.toString() ?? json['title']?.toString() ?? id;
if (id == 'history_10' || title.contains('تاريخ الأردن')) {
title = 'التاريخ (History 10)';
}
final englishTitle = json['english_name']?.toString() ?? '';
// Parse units if available
@@ -148,32 +148,57 @@ class CurriculumRepository {
}
Future<List<PublishedLessonVideoModel>> getPublishedLessonVideos(String curriculumLessonId) async {
AppLogger.event('FetchPublishedVideosRequested', details: {'curriculumLessonId': curriculumLessonId}, tag: 'CURRICULUM_REPO');
final res = await _api.get('/api/curriculum/lessons/$curriculumLessonId/videos');
final data = res is Map ? res['data'] : null;
final raw = data is Map ? data['items'] : null;
if (raw is! List) return const [];
return raw.whereType<Map>().map((item) => PublishedLessonVideoModel.fromJson(Map<String, dynamic>.from(item))).where((item) => item.videoVersionId.isNotEmpty).toList();
final list = raw.whereType<Map>().map((item) => PublishedLessonVideoModel.fromJson(Map<String, dynamic>.from(item))).where((item) => item.videoVersionId.isNotEmpty).toList();
AppLogger.event('FetchPublishedVideosSuccess', details: {'count': list.length, 'teachers': list.map((v) => v.teacherName).toList()}, tag: 'CURRICULUM_REPO');
return list;
}
Future<LessonPlaybackData> getVideoVersionPlayback(String videoVersionId) async {
AppLogger.event('FetchVideoPlaybackRequested', details: {'videoVersionId': videoVersionId}, tag: 'CURRICULUM_REPO');
final res = await _api.get('/api/video-versions/$videoVersionId/playback');
if (res is Map && res['data'] is Map) return LessonPlaybackData.fromJson(Map<String, dynamic>.from(res['data']));
if (res is Map && res['data'] is Map) {
final pb = LessonPlaybackData.fromJson(Map<String, dynamic>.from(res['data']));
AppLogger.event('FetchVideoPlaybackSuccess', details: {
'videoVersionId': pb.videoVersionId,
'videoUrl': pb.videoUrl,
'duration': pb.durationSeconds,
}, tag: 'CURRICULUM_REPO');
return pb;
}
throw ApiException('فشل جلب تشغيل الحصة المنشورة من الخادم');
}
Future<String> startWatchSession(String videoVersionId) async {
AppLogger.event('StartWatchSessionRequested', details: {'videoVersionId': videoVersionId}, tag: 'CURRICULUM_REPO');
final res = await _api.post('/api/video-versions/$videoVersionId/watch-sessions', body: const {});
final data = res is Map ? res['data'] : null;
final source = data is Map ? data : res;
final id = source is Map ? source['watch_session_id']?.toString() : null;
if (id == null || id.isEmpty) throw ApiException('لم ينشئ الخادم جلسة مشاهدة.');
AppLogger.event('StartWatchSessionSuccess', details: {'sessionId': id}, tag: 'CURRICULUM_REPO');
return id;
}
Future<void> recordWatchEvent(String sessionId, int sequenceNo, String eventType, int positionMs) async {
AppLogger.event('RecordWatchEvent', details: {
'sessionId': sessionId,
'seq': sequenceNo,
'type': eventType,
'positionMs': positionMs,
}, tag: 'CURRICULUM_REPO');
await _api.post('/api/watch-sessions/$sessionId/events', body: {'sequence_no': sequenceNo, 'event_type': eventType, 'position_ms': positionMs});
}
Future<void> saveProgress({required int lessonId, required int positionSeconds, required int watchedSeconds}) async {
AppLogger.event('SaveProgressRequested', details: {
'lessonId': lessonId,
'position': positionSeconds,
'watched': watchedSeconds,
}, tag: 'CURRICULUM_REPO');
await _api.post('/api/student/lessons/$lessonId/progress', body: {
'position_seconds': positionSeconds,
'watched_seconds': watchedSeconds,
@@ -181,6 +206,11 @@ class CurriculumRepository {
}
Future<void> submitCheckpoint({required int examId, required int questionId, required int optionId}) async {
AppLogger.event('SubmitCheckpointRequested', details: {
'examId': examId,
'questionId': questionId,
'optionId': optionId,
}, tag: 'CURRICULUM_REPO');
await _api.post('/api/exams/$examId/submit', body: {
'answers': [
{'question_id': questionId, 'selected_option_id': optionId}
@@ -91,7 +91,13 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
}
Future<void> loadLesson(CurriculumLessonItemModel lesson, {SubjectModel? subject, String? selectedVideoVersionId}) async {
AppLogger.log('Loading Socratic playback for lesson: ${lesson.title}', tag: 'VIDEO_CUBIT');
AppLogger.event('LoadLessonPlaybackRequested', details: {
'lessonTitle': lesson.title,
'lessonId': lesson.id,
'curriculumLessonId': lesson.curriculumLessonId,
'selectedVersionId': selectedVideoVersionId,
'hasVideo': lesson.hasVideo,
}, tag: 'VIDEO_CUBIT');
_lastObservedPosition = -1;
emit(VideoPlaybackLoading());
final storageKey = _getLessonStorageKey(lesson);
@@ -102,16 +108,31 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
throw StateError('هذا الدرس غير منشور بعد ضمن حزمة المحتوى المعتمدة.');
}
final published = await _repo.getPublishedLessonVideos(versionId);
AppLogger.event('PublishedVideosLoaded', details: {
'count': published.length,
'teachers': published.map((p) => '${p.teacherName} (⭐ ${p.rating.toStringAsFixed(1)})').toList(),
}, tag: 'VIDEO_CUBIT');
if (published.isEmpty) throw StateError('لا توجد حصة منشورة ومصرح بها لهذا الدرس بعد.');
// Selection is performed in the lesson screen when several teachers exist.
var playback = await _repo.getVideoVersionPlayback(selectedVideoVersionId ?? published.first.videoVersionId);
final targetVersionId = selectedVideoVersionId ?? published.first.videoVersionId;
var playback = await _repo.getVideoVersionPlayback(targetVersionId);
AppLogger.event('PlaybackDataLoaded', details: {
'versionId': playback.videoVersionId,
'videoUrl': playback.videoUrl,
'durationSeconds': playback.durationSeconds,
'checkpointsCount': playback.checkpoints.length,
}, tag: 'VIDEO_CUBIT');
if (playback.videoUrl.isEmpty) {
throw StateError('لم يربط الخادم فيديو R2 بهذا الدرس بعد.');
}
_watchSessionId = null; _watchSequence = 1; _lastWatchEventPosition = 0;
if (playback.videoVersionId != null && playback.videoVersionId!.isNotEmpty) {
try { _watchSessionId = await _repo.startWatchSession(playback.videoVersionId!); } catch (_) {}
try {
_watchSessionId = await _repo.startWatchSession(playback.videoVersionId!);
AppLogger.event('WatchSessionStarted', details: {'sessionId': _watchSessionId}, tag: 'VIDEO_CUBIT');
} catch (_) {}
}
// Load saved resume position strictly per video
@@ -125,6 +146,11 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
passedIds = savedPassed.map((s) => int.tryParse(s) ?? 0).where((id) => id > 0).toSet();
} catch (_) {}
AppLogger.event('VideoPlaybackStateEmitted', details: {
'resumePosition': resumePos,
'passedCheckpointsCount': passedIds.length,
}, tag: 'VIDEO_CUBIT');
emit(VideoPlaybackReady(
playbackData: playback,
lessonItem: lesson,
@@ -176,13 +202,16 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
void togglePlayPause() {
final currentState = state;
if (currentState is VideoPlaybackReady && currentState.activeCheckpoint == null) {
emit(currentState.copyWith(isPlaying: !currentState.isPlaying));
final nextState = !currentState.isPlaying;
AppLogger.event('VideoPlayPauseToggled', details: {'isPlaying': nextState}, tag: 'VIDEO_CUBIT');
emit(currentState.copyWith(isPlaying: nextState));
}
}
void pause() {
final currentState = state;
if (currentState is VideoPlaybackReady && currentState.isPlaying) {
AppLogger.event('VideoPaused', tag: 'VIDEO_CUBIT');
emit(currentState.copyWith(isPlaying: false));
}
}
@@ -190,6 +219,7 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
void play() {
final currentState = state;
if (currentState is VideoPlaybackReady && !currentState.isPlaying && currentState.activeCheckpoint == null) {
AppLogger.event('VideoResumed', tag: 'VIDEO_CUBIT');
emit(currentState.copyWith(isPlaying: true));
}
}
@@ -211,6 +241,13 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
final actualSeek = blockingCheckpoint != null ? blockingCheckpoint.timestampSeconds : seconds;
_lastObservedPosition = actualSeek;
AppLogger.event('VideoSeekExecuted', details: {
'targetSeconds': seconds,
'actualSeek': actualSeek,
'blockedByCheckpoint': blockingCheckpoint != null,
'checkpointQuestion': blockingCheckpoint?.questionText,
}, tag: 'VIDEO_CUBIT');
emit(currentState.copyWith(
currentPositionSeconds: actualSeek,
activeCheckpoint: blockingCheckpoint,
@@ -252,7 +289,12 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
if (selectedOption.isCorrect) {
// Correct Answer -> Reward readiness score (+0.5%) & resume video
AppLogger.log('✅ Correct answer! Rewarding +0.5% readiness.', tag: 'SOCRATIC_ENGINE');
AppLogger.event('SocraticAnswerCorrect', details: {
'checkpointId': cp.id,
'question': cp.questionText,
'selected': selectedOption.text,
'bonus': 0.5,
}, tag: 'SOCRATIC_ENGINE');
final updatedPassed = Set<int>.from(currentState.passedCheckpointIds)..add(cp.id);
_lastObservedPosition = cp.timestampSeconds;
@@ -279,7 +321,13 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
// Wrong Answer -> Socratic Productive Struggle: Rewind video by N seconds from checkpoint
final rewindTo = (cp.timestampSeconds - cp.rewindSecondsOnFail).clamp(0, currentState.playbackData.durationSeconds);
_lastObservedPosition = rewindTo;
AppLogger.log('❌ Incorrect answer. Socratic remediation: Rewinding to ${rewindTo}s.', tag: 'SOCRATIC_ENGINE');
AppLogger.event('SocraticAnswerIncorrect', details: {
'checkpointId': cp.id,
'question': cp.questionText,
'selected': selectedOption.text,
'rewindSeconds': cp.rewindSecondsOnFail,
'rewindTo': rewindTo,
}, tag: 'SOCRATIC_ENGINE');
emit(currentState.copyWith(
clearActiveCheckpoint: true,
currentPositionSeconds: rewindTo,
@@ -290,7 +338,7 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
// Auto-record gap to Smart Error Notebook
final correctOpt = cp.options.firstWhere(
(o) => o.isCorrect,
orElse: () => SocraticOptionModel(id: 0, text: '', isCorrect: false),
orElse: () => const SocraticOptionModel(id: 0, text: '', isCorrect: false),
);
ErrorNotebookRepository().logError(
subjectId: currentState.subject?.id ?? 'physics_10',
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,575 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
/// ==============================================================================
/// SAQEL ENTERPRISE - GRADE 10 DIGITAL SKILLS INTERACTIVE LAB
/// ==============================================================================
/// مختبر المهارات الرقمية التفاعلي المعتمد للصف العاشر:
/// 1. معمل تمثيل البيانات والنظام الثنائي وبكسل الألوان (Binary, ASCII & RGB).
/// 2. معمل تحليل البيانات ومعادلات Excel والرسم البياني المرئي.
/// 3. محاكي إنترنت الأشياء (IoT) والذكاء الاصطناعي وشجرة القرارات.
class DigitalSkillsInteractiveLabView extends StatefulWidget {
const DigitalSkillsInteractiveLabView({super.key});
@override
State<DigitalSkillsInteractiveLabView> createState() =>
_DigitalSkillsInteractiveLabViewState();
}
class _DigitalSkillsInteractiveLabViewState
extends State<DigitalSkillsInteractiveLabView>
with SingleTickerProviderStateMixin {
late TabController _tabController;
// ---------------------------------------------------------------------------
// Tab 1: Binary & RGB Data Representation
// ---------------------------------------------------------------------------
final List<bool> _binaryBits = [false, false, false, false, false, true, false, true]; // 5 in 8-bit
int _redVal = 30;
int _greenVal = 210;
int _blueVal = 180;
int get _decimalValue {
int val = 0;
for (int i = 0; i < 8; i++) {
if (_binaryBits[i]) {
val += (1 << (7 - i));
}
}
return val;
}
String get _hexValue => _decimalValue.toRadixString(16).toUpperCase().padLeft(2, '0');
String get _asciiChar {
final d = _decimalValue;
if (d >= 32 && d <= 126) {
return String.fromCharCode(d);
}
return 'غير قابل للطباعة (Control Code)';
}
// ---------------------------------------------------------------------------
// Tab 2: Excel & Data Analytics
// ---------------------------------------------------------------------------
final List<Map<String, dynamic>> _dataRows = [
{'item': 'عمان', 'val': 85.0},
{'item': 'إربد', 'val': 72.0},
{'item': 'الزرقاء', 'val': 68.0},
{'item': 'العقبة', 'val': 94.0},
{'item': 'البلقاء', 'val': 61.0},
];
double get _calcSum => _dataRows.fold(0.0, (acc, r) => acc + (r['val'] as double));
double get _calcAvg => _dataRows.isNotEmpty ? _calcSum / _dataRows.length : 0.0;
double get _calcMax => _dataRows.fold(0.0, (max, r) => (r['val'] as double) > max ? (r['val'] as double) : max);
double get _calcMin => _dataRows.fold(999.0, (min, r) => (r['val'] as double) < min ? (r['val'] as double) : min);
// ---------------------------------------------------------------------------
// Tab 3: IoT Sensors & AI Decision Tree
// ---------------------------------------------------------------------------
double _temperature = 28.0;
double _humidity = 45.0;
bool _motionDetected = false;
String get _aiDecision {
if (_temperature > 32.0 && _humidity > 60.0) {
return 'تشغيل نظام التكييف الذكي وخفض الرطوبة (تحذير إجهاد حراري)';
} else if (_temperature < 16.0) {
return 'تشغيل نظام التدفئة المركزية والتهوية الآمنة';
} else if (_motionDetected) {
return 'تفعيل الإضاءة الذكية وتسجيل نشاط الكاميرا';
}
return 'البيئة مستقرة ومثالية • الاستهلاك الاقتصادي للطاقة مفعل';
}
Color get _aiStatusColor {
if (_temperature > 32.0 || _temperature < 16.0) {
return const Color(0xFFF59E0B);
}
if (_motionDetected) {
return const Color(0xFF38BDF8);
}
return const Color(0xFF10B981);
}
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Container(
margin: const EdgeInsets.fromLTRB(16, 12, 16, 8),
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: AppColors.darkSurface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.darkCardBorder),
),
child: TabBar(
controller: _tabController,
indicatorColor: AppColors.saqelCyan,
labelColor: Colors.white,
unselectedLabelColor: AppColors.textSecondaryDark,
indicatorSize: TabBarIndicatorSize.tab,
labelStyle: const TextStyle(fontWeight: FontWeight.w800, fontSize: 11.5),
tabs: const [
Tab(text: 'تمثيل البيانات والثنائي 0101'),
Tab(text: 'تحليل البيانات وExcel 📊'),
Tab(text: 'إنترنت الأشياء والذكاء 🤖'),
],
),
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildBinaryLab(),
_buildExcelLab(),
_buildIotLab(),
],
),
),
],
);
}
Widget _buildBinaryLab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 8-bit Binary Switcher Card
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.darkSurface,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: AppColors.saqelCyan.withValues(alpha: 0.4)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(CupertinoIcons.chevron_left_slash_chevron_right, color: AppColors.saqelCyan, size: 18),
SizedBox(width: 8),
Text(
'محاكي بايت البيانات الثنائي (8-Bit Binary Byte):',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 13.5),
),
],
),
const SizedBox(height: 12),
const Text(
'انقر على البت لتشغيله (1) أو إطفائه (0) وشاهد تحويل القيمة فورياً:',
style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 12),
),
const SizedBox(height: 14),
// 8 Switches
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(8, (i) {
final weight = 1 << (7 - i);
final isSet = _binaryBits[i];
return GestureDetector(
onTap: () {
setState(() {
_binaryBits[i] = !_binaryBits[i];
});
},
child: Column(
children: [
Text(
'$weight',
style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 10, fontWeight: FontWeight.w700),
),
const SizedBox(height: 6),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 34,
height: 48,
decoration: BoxDecoration(
color: isSet ? AppColors.saqelCyan.withValues(alpha: 0.25) : Colors.black26,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSet ? AppColors.saqelCyan : Colors.white24,
width: isSet ? 2 : 1,
),
),
child: Center(
child: Text(
isSet ? '1' : '0',
style: TextStyle(
color: isSet ? AppColors.saqelCyan : Colors.white38,
fontSize: 18,
fontWeight: FontWeight.w900,
),
),
),
),
],
),
);
}),
),
const SizedBox(height: 16),
// Computed Output Badges
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildResultBadge('العشري (Decimal)', '$_decimalValue'),
_buildResultBadge('الست عشري (Hex)', '0x$_hexValue'),
_buildResultBadge('حرف ASCII', _asciiChar),
],
),
),
],
),
),
const SizedBox(height: 16),
// RGB Pixel Inspector
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.darkSurface,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: Colors.white12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(CupertinoIcons.paintbrush_fill, color: Color(0xFFFF375F), size: 18),
SizedBox(width: 8),
Text(
'مفتش البكسل اللوني وتمثيل الصور (24-bit RGB):',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 13.5),
),
],
),
const SizedBox(height: 12),
Row(
children: [
// Color Preview Box
Container(
width: 70,
height: 70,
decoration: BoxDecoration(
color: Color.fromARGB(255, _redVal, _greenVal, _blueVal),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white24, width: 2),
boxShadow: [
BoxShadow(
color: Color.fromARGB(255, _redVal, _greenVal, _blueVal).withValues(alpha: 0.5),
blurRadius: 14,
),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'RGB($_redVal, $_greenVal, $_blueVal)',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w900, fontSize: 14),
),
const SizedBox(height: 4),
Text(
'حجم البكسل: 3 بايت = 24 بت في الذاكرة (16.7 مليون لون ممكن)',
style: TextStyle(color: Colors.white.withValues(alpha: 0.7), fontSize: 11),
),
],
),
),
],
),
const SizedBox(height: 12),
_buildColorSlider('أحمر (Red R)', _redVal, const Color(0xFFFF453A), (v) => setState(() => _redVal = v.round())),
_buildColorSlider('أخضر (Green G)', _greenVal, const Color(0xFF30D158), (v) => setState(() => _greenVal = v.round())),
_buildColorSlider('أزرق (Blue B)', _blueVal, const Color(0xFF0A84FF), (v) => setState(() => _blueVal = v.round())),
],
),
),
],
),
);
}
Widget _buildExcelLab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Formulas Summary Header
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.darkSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF10B981).withValues(alpha: 0.4)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildStatColumn('دالة المجموع', '=SUM(B2:B6)', _calcSum.toStringAsFixed(0), const Color(0xFF10B981)),
_buildStatColumn('دالة المتوسط', '=AVERAGE(B2:B6)', _calcAvg.toStringAsFixed(1), const Color(0xFF38BDF8)),
_buildStatColumn('أعلى قيمة', '=MAX(B2:B6)', _calcMax.toStringAsFixed(0), const Color(0xFFF59E0B)),
_buildStatColumn('أدنى قيمة', '=MIN(B2:B6)', _calcMin.toStringAsFixed(0), const Color(0xFFEC4899)),
],
),
),
const SizedBox(height: 16),
// Interactive Data Table & Bar Chart
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.darkSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.darkCardBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(CupertinoIcons.table, color: Color(0xFF10B981), size: 18),
SizedBox(width: 8),
Text(
'جدول البيانات والتمثيل المرئي (Data Visualization):',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 13.5),
),
],
),
const SizedBox(height: 14),
..._dataRows.map((row) {
final val = row['val'] as double;
final ratio = (_calcMax > 0) ? (val / _calcMax) : 0.0;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
SizedBox(
width: 60,
child: Text(
row['item'],
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 12.5),
),
),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: ratio,
minHeight: 14,
backgroundColor: Colors.white10,
valueColor: const AlwaysStoppedAnimation(Color(0xFF10B981)),
),
),
),
const SizedBox(width: 12),
Text(
val.toStringAsFixed(0),
style: const TextStyle(color: Color(0xFF10B981), fontWeight: FontWeight.w900, fontSize: 13),
),
],
),
],
),
);
}),
],
),
),
],
),
);
}
Widget _buildIotLab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// IoT Sensor Sliders Card
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.darkSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.saqelCyan.withValues(alpha: 0.4)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(CupertinoIcons.radiowaves_right, color: AppColors.saqelCyan, size: 18),
SizedBox(width: 8),
Text(
'محاكي مجسات إنترنت الأشياء (IoT Smart Sensors):',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 13.5),
),
],
),
const SizedBox(height: 14),
// Temperature Slider
Text(
'حساس درجة الحرارة (DHT22): ${_temperature.toStringAsFixed(1)} °C',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 12.5),
),
Slider(
value: _temperature,
min: 10.0,
max: 45.0,
divisions: 35,
activeColor: const Color(0xFFFF9F0A),
onChanged: (v) => setState(() => _temperature = v),
),
// Humidity Slider
Text(
'حساس الرطوبة النسبية: ${_humidity.toStringAsFixed(0)} %',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 12.5),
),
Slider(
value: _humidity,
min: 20.0,
max: 95.0,
divisions: 75,
activeColor: const Color(0xFF38BDF8),
onChanged: (v) => setState(() => _humidity = v),
),
// Motion Detection Switch
Row(
children: [
const Icon(CupertinoIcons.person_crop_circle_badge_exclam, color: Colors.white70, size: 18),
const SizedBox(width: 8),
const Text('مستشعر الحركة بالأشعة (PIR):', style: TextStyle(color: Colors.white, fontSize: 12.5, fontWeight: FontWeight.w600)),
const Spacer(),
CupertinoSwitch(
value: _motionDetected,
activeTrackColor: AppColors.saqelCyan,
onChanged: (v) => setState(() => _motionDetected = v),
),
],
),
],
),
),
const SizedBox(height: 14),
// AI Decision Output Box
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _aiStatusColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: _aiStatusColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(CupertinoIcons.sparkles, color: _aiStatusColor, size: 20),
const SizedBox(width: 8),
Text(
'قرار الذكاء الاصطناعي (AI Decision Engine):',
style: TextStyle(color: _aiStatusColor, fontWeight: FontWeight.w800, fontSize: 13.5),
),
],
),
const SizedBox(height: 10),
Text(
_aiDecision,
style: const TextStyle(color: Colors.white, fontSize: 13.5, height: 1.45, fontWeight: FontWeight.w700),
),
],
),
),
],
),
);
}
Widget _buildResultBadge(String label, String val) {
return Column(
children: [
Text(label, style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 11)),
const SizedBox(height: 4),
Text(val, style: const TextStyle(color: AppColors.saqelCyan, fontSize: 16, fontWeight: FontWeight.w900)),
],
);
}
Widget _buildColorSlider(String label, int value, Color col, ValueChanged<double> onChanged) {
return Row(
children: [
SizedBox(
width: 90,
child: Text(label, style: TextStyle(color: col, fontSize: 11.5, fontWeight: FontWeight.w700)),
),
Expanded(
child: Slider(
value: value.toDouble(),
min: 0,
max: 255,
activeColor: col,
onChanged: onChanged,
),
),
SizedBox(
width: 32,
child: Text('$value', style: const TextStyle(color: Colors.white, fontSize: 11.5, fontWeight: FontWeight.w700)),
),
],
);
}
Widget _buildStatColumn(String label, String formula, String val, Color col) {
return Column(
children: [
Text(label, style: const TextStyle(color: Colors.white70, fontSize: 10.5, fontWeight: FontWeight.w700)),
const SizedBox(height: 2),
Text(formula, style: TextStyle(color: col, fontSize: 9.5, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text(val, style: TextStyle(color: col, fontSize: 16, fontWeight: FontWeight.w900)),
],
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,7 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/utils/app_logger.dart';
import '../../../core/utils/saqel_toast.dart';
import '../../../data/models/subject_model.dart';
import '../../widgets/luxury_widgets.dart';
@@ -30,6 +31,7 @@ import 'islamic_interactive_lab_view.dart';
import 'history_interactive_timeline_view.dart';
import 'math_interactive_lab_view.dart';
import 'english_interactive_lab_view.dart';
import 'digital_skills_interactive_lab_view.dart';
import '../virtual_labs/labs_gallery_screen.dart';
import '../virtual_labs/labs_registry.dart';
import '../virtual_labs/subject_virtual_labs_view.dart';
@@ -54,6 +56,7 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
late Future<List<ExamModel>> _examsFuture;
String _selectedSemester = 'semester_1';
bool get _isMathSubject =>
widget.subject.id.contains('math') ||
@@ -101,6 +104,12 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
(widget.subject.title.contains('تاريخ') && !widget.subject.title.contains('جغرافيا')) ||
(widget.subject.title.contains('أردن') && !widget.subject.title.contains('جغرافيا'));
bool get _isDigitalSkillsSubject =>
widget.subject.id.contains('digital') ||
widget.subject.id.contains('computer') ||
widget.subject.title.contains('رقمي') ||
widget.subject.title.contains('حاسوب');
bool get _hasLab =>
Grade10LabsRegistry.bySubjectNormalized(widget.subject.title).isNotEmpty ||
_isMathSubject ||
@@ -111,7 +120,8 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
_isPhysicsSubject ||
_isArabicSubject ||
_isIslamicSubject ||
_isHistorySubject;
_isHistorySubject ||
_isDigitalSkillsSubject;
Tab get _dynamicLabTab {
final norm = Grade10LabsRegistry.normalizeSubject(widget.subject.title);
@@ -133,12 +143,12 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
} else if (_isIslamicSubject || norm == 'التربية الإسلامية') {
return const Tab(
icon: Icon(CupertinoIcons.money_dollar_circle_fill, size: 18),
text: 'حاسبة المواريث والتجويد ⚖️',
text: 'معمل المعاملات والتجويد ⚖️',
);
} else if (_isHistorySubject || norm == 'تاريخ الأردن' || norm == 'التاريخ') {
return const Tab(
icon: Icon(CupertinoIcons.time_solid, size: 18),
text: 'الخط الزمني الشامل والتاريخ 🏛️',
text: 'مختبر التاريخ والخط الزمني 🏛️',
);
} else if (_isChemistrySubject || norm == 'الكيمياء') {
return const Tab(
@@ -160,10 +170,10 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
icon: Icon(CupertinoIcons.chart_bar_square_fill, size: 18),
text: 'مختبر الثقافة المالية والجدوى 📊',
);
} else if (norm == 'المهارات الرقمية') {
} else if (_isDigitalSkillsSubject || norm == 'المهارات الرقمية') {
return const Tab(
icon: Icon(CupertinoIcons.desktopcomputer, size: 18),
text: 'مختبر الخوارزميات والبرمجة 💻',
text: 'معمل البيانات والذكاء الاصطناعي 💻',
);
} else if (norm == 'الجغرافيا') {
return const Tab(
@@ -204,10 +214,13 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
specializedTitle = 'شجرة الإعراب 📜';
} else if (_isIslamicSubject || norm == 'التربية الإسلامية') {
specializedTool = const IslamicInteractiveLabView();
specializedTitle = 'حاسبة المواريث ⚖️';
specializedTitle = 'معمل المعاملات والتجويد ⚖️';
} else if (_isHistorySubject || norm == 'تاريخ الأردن' || norm == 'التاريخ') {
specializedTool = const HistoryInteractiveTimelineView();
specializedTitle = 'الخط الزمني الشامل 🏛️';
specializedTitle = 'الخط الزمني الشامل والتاريخ 🏛️';
} else if (_isDigitalSkillsSubject || norm == 'المهارات الرقمية') {
specializedTool = const DigitalSkillsInteractiveLabView();
specializedTitle = 'معمل البيانات والذكاء 💻';
} else if (_isChemistrySubject || norm == 'الكيمياء') {
specializedTool = const ChemistryInteractiveLabView();
specializedTitle = 'طيف ذرة بور ⚗️';
@@ -227,6 +240,7 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
primaryColor: widget.subject.primaryColor,
specializedToolWidget: specializedTool,
specializedToolTitle: specializedTitle,
selectedSemester: _selectedSemester,
);
}
@@ -310,9 +324,115 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
);
}
/// Semester Switcher Component for Multi-Semester Subjects
Widget _buildSemesterSelector() {
final s1Count = widget.subject.semester1Units.length;
final s2Count = widget.subject.semester2Units.length;
return Container(
margin: const EdgeInsets.fromLTRB(18, 14, 18, 6),
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: AppColors.darkSurface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.darkCardBorder),
),
child: Row(
children: [
Expanded(
child: _buildSemesterSegment(
key: 'semester_1',
title: 'الفصل الدراسي الأول',
count: s1Count,
isSelected: _selectedSemester == 'semester_1',
),
),
const SizedBox(width: 6),
Expanded(
child: _buildSemesterSegment(
key: 'semester_2',
title: 'الفصل الدراسي الثاني',
count: s2Count,
isSelected: _selectedSemester == 'semester_2',
),
),
],
),
);
}
Widget _buildSemesterSegment({
required String key,
required String title,
required int count,
required bool isSelected,
}) {
final color = widget.subject.primaryColor;
return GestureDetector(
onTap: () {
if (_selectedSemester != key) {
setState(() {
_selectedSemester = key;
});
}
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
padding: const EdgeInsets.symmetric(vertical: 9, horizontal: 12),
decoration: BoxDecoration(
color: isSelected ? color.withValues(alpha: 0.18) : Colors.transparent,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: isSelected ? color.withValues(alpha: 0.6) : Colors.transparent,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isSelected ? CupertinoIcons.checkmark_seal_fill : CupertinoIcons.calendar,
size: 14,
color: isSelected ? color : AppColors.textSecondaryDark,
),
const SizedBox(width: 6),
Text(
title,
style: TextStyle(
color: isSelected ? Colors.white : AppColors.textSecondaryDark,
fontSize: 12.5,
fontWeight: isSelected ? FontWeight.w800 : FontWeight.w500,
),
),
if (count > 0) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1.5),
decoration: BoxDecoration(
color: isSelected ? color.withValues(alpha: 0.3) : AppColors.darkCardBorder,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'$count وحدات',
style: TextStyle(
color: isSelected ? color : AppColors.textSecondaryDark,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
),
],
],
),
),
);
}
/// Tab 1: Interactive Video Lessons by Unit
Widget _buildLessonsTab(BuildContext context) {
final units = widget.subject.units;
final hasMultipleSemesters = widget.subject.semester1Units.isNotEmpty && widget.subject.semester2Units.isNotEmpty;
final units = hasMultipleSemesters
? widget.subject.unitsForSemester(_selectedSemester)
: widget.subject.units;
if (units.isEmpty) {
return Center(
@@ -330,11 +450,15 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
);
}
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20),
itemCount: units.length,
itemBuilder: (context, uIdx) {
final unit = units[uIdx];
return Column(
children: [
if (hasMultipleSemesters) _buildSemesterSelector(),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
itemCount: units.length,
itemBuilder: (context, uIdx) {
final unit = units[uIdx];
return Container(
margin: const EdgeInsets.only(bottom: 20),
@@ -380,6 +504,9 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
lessonTitle: lesson.title,
lessonId: lesson.id,
curriculumLessonId: lesson.curriculumLessonId,
filePath: lesson.markdownFilePath,
unitKey: unit.id,
semesterKey: _selectedSemester,
);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
@@ -455,6 +582,13 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
size: 16,
),
onTap: () {
AppLogger.event('LessonTileTapped', details: {
'subject': widget.subject.title,
'lessonTitle': lesson.title,
'lessonId': lesson.id,
'hasVideo': hasVid,
'curriculumLessonId': lesson.curriculumLessonId,
}, tag: 'SUBJECT_HUB');
_showLessonVideoSelector(context, lesson);
},
),
@@ -463,7 +597,15 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
padding: const EdgeInsets.fromLTRB(12, 0, 12, 6),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () => Grade10LabsRegistry.openLab(context, lab),
onTap: () {
AppLogger.event('OpenLessonLab', details: {
'subject': widget.subject.title,
'lesson': lesson.title,
'lab': lab.lessonAr,
'curriculumId': lab.identity.curriculumLessonId,
}, tag: 'SUBJECT_HUB');
Grade10LabsRegistry.openLab(context, lab);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
decoration: BoxDecoration(
@@ -476,14 +618,42 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
const Icon(CupertinoIcons.lab_flask_solid, color: AppColors.saqelCyan, size: 14),
const SizedBox(width: 6),
Expanded(
child: Text(
'المختبر الافتراضي: ${lab.lessonAr}',
style: const TextStyle(
color: Colors.white,
fontSize: 11.5,
fontWeight: FontWeight.w700,
),
overflow: TextOverflow.ellipsis,
child: Row(
children: [
Flexible(
child: Text(
'المختبر: ${lab.lessonAr}',
style: const TextStyle(
color: Colors.white,
fontSize: 11.5,
fontWeight: FontWeight.w700,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
decoration: BoxDecoration(
color: lab.identity.isPublished
? AppColors.teacherEmerald.withAlpha(35)
: AppColors.saqelCyan.withAlpha(30),
borderRadius: BorderRadius.circular(4),
),
child: Text(
lab.identity.isPublished
? 'معتمد 🟢'
: (lab.identity.isBound ? 'جاهز للاستخدام 🟢' : 'معاينة 🔵'),
style: TextStyle(
color: lab.identity.isPublished
? AppColors.teacherEmerald
: AppColors.saqelCyan,
fontSize: 9.5,
fontWeight: FontWeight.w800,
),
),
),
],
),
),
Container(
@@ -513,62 +683,32 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
),
),
)
else if (_hasLab)
else
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,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withAlpha(6),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.white.withAlpha(12)),
),
child: const Row(
children: [
Icon(CupertinoIcons.lab_flask, color: Colors.white38, size: 13),
SizedBox(width: 6),
Expanded(
child: Text(
'المختبر الافتراضي: قيد الإعداد الأكاديمي ⚪',
style: TextStyle(
color: Colors.white54,
fontSize: 11,
fontWeight: FontWeight.w600,
),
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),
],
),
),
],
),
),
],
),
),
),
@@ -634,6 +774,9 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
),
);
},
),
),
],
);
}
@@ -1098,6 +1241,7 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
lessonTitle: lesson.title,
lessonId: lesson.id,
curriculumLessonId: lesson.curriculumLessonId,
filePath: lesson.markdownFilePath,
);
showModalBottomSheet(
@@ -19,6 +19,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/utils/app_logger.dart';
import '../../../core/utils/saqel_toast.dart';
import '../../../core/config/app_config.dart';
import '../../../core/services/storage_service.dart';
@@ -65,34 +66,77 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
bool _showChalkboardMode = false;
final FlutterTts _tts = FlutterTts();
bool _isSpeakingChalkboard = false;
int _chalkboardStep = 0;
bool _isDisposed = false;
bool _isMuted = false;
void _revealVideoControls() {
if (!mounted) return;
if (!mounted || _isDisposed) return;
setState(() => _showVideoControls = true);
_controlsHideTimer?.cancel();
_controlsHideTimer = Timer(const Duration(seconds: 3), () {
if (mounted) setState(() => _showVideoControls = false);
_controlsHideTimer = Timer(const Duration(seconds: 4), () {
if (mounted && !_isDisposed) setState(() => _showVideoControls = false);
});
}
void _stopAllAudioAndVideo() {
AppLogger.log('🛑 [AUDIO_VIDEO] Stopping all audio, video, and speech synthesis...', tag: 'VIDEO_LIFECYCLE');
try {
_tts.stop();
} catch (e) {
AppLogger.error('Error stopping TTS', error: e, tag: 'VIDEO_LIFECYCLE');
}
if (_videoController != null) {
try {
_videoController!.pause();
_videoController!.setVolume(0.0);
_videoController!.dispose();
} catch (e) {
AppLogger.error('Error disposing video controller', error: e, tag: 'VIDEO_LIFECYCLE');
}
_videoController = null;
_isVideoInitialized = false;
}
}
Future<void> _initPlayer(String videoUrl, int resumePos, bool shouldPlay) async {
if (_isDisposed || !mounted) return;
AppLogger.log('🎬 [VIDEO_INIT_REQUEST] Received raw video URL for lesson: "${widget.lesson.title}"\n'
' • Raw URL: "$videoUrl"\n'
' • Resume Position: ${resumePos}s\n'
' • Should AutoPlay: $shouldPlay\n'
' • Curriculum Lesson ID: ${widget.lesson.curriculumLessonId}',
tag: 'VIDEO_PLAYER');
if (videoUrl.isEmpty) {
setState(() => _videoInitError = 'لا يوجد رابط فيديو حقيقي لهذا الدرس.');
AppLogger.error('Video URL is empty for lesson: ${widget.lesson.title}', tag: 'VIDEO_PLAYER');
if (mounted && !_isDisposed) {
setState(() => _videoInitError = 'لم يتم ربط فيديو معتمد لهذا الدرس بعد.');
}
return;
}
final token = await StorageService().getToken();
String finalVideoUrl = videoUrl;
String finalVideoUrl = videoUrl.trim();
// IMPORTANT: Check whether the URL points to our own Saqel API or to an external CDN (BunnyCDN, Cloudflare R2).
// NEVER send the Saqel user JWT Bearer token to external CDNs! CDNs reject unauthorized Bearer headers
// with HTTP 403 Forbidden ("Resource is protected by access token").
// 1. Resolve relative API endpoints to absolute base URL
if (finalVideoUrl.startsWith('/')) {
final base = AppConfig.baseUrl.replaceAll(RegExp(r'/+$'), '');
finalVideoUrl = '$base$finalVideoUrl';
AppLogger.log('🔗 [URL_RESOLVE] Resolved relative path to absolute: $finalVideoUrl', tag: 'VIDEO_PLAYER');
}
// 2. Attach authentication token for our backend streaming endpoints
final token = await StorageService().getToken();
final isOurBackend = finalVideoUrl.contains('/api/videos/') ||
finalVideoUrl.startsWith(AppConfig.baseUrl) ||
finalVideoUrl.contains('localhost') ||
finalVideoUrl.contains('127.0.0.1');
final headers = <String, String>{};
final headers = <String, String>{
'User-Agent': 'SaqelEdTech/2.0 (macOS; Grade10-Production)',
};
if (isOurBackend && token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
if (!finalVideoUrl.contains('token=')) {
@@ -101,92 +145,102 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
}
}
_videoController?.dispose();
_videoController = VideoPlayerController.networkUrl(
AppLogger.log('🚀 [VIDEO_CONNECT] Connecting to real video stream: $finalVideoUrl', tag: 'VIDEO_PLAYER');
// Cleanly stop any existing controller before assigning a new one
_stopAllAudioAndVideo();
final controller = VideoPlayerController.networkUrl(
Uri.parse(finalVideoUrl),
httpHeaders: headers,
)
..initialize().then((_) {
if (mounted) {
setState(() {
_isVideoInitialized = true;
_videoInitError = null;
});
_revealVideoControls();
if (resumePos > 3) {
_videoController!.seekTo(Duration(seconds: resumePos));
if (context.mounted) {
SaqelToast.showInfo(
context,
'تم استئناف المشاهدة من الدقيقة ${_formatTime(resumePos)} ⏱️',
title: 'استئناف المشاهدة',
);
}
}
if (shouldPlay) {
_videoController!.play();
}
}
}).catchError((err) {
final errStr = err.toString();
// Resilient Fallback: If 403 Forbidden or Access Token error occurred on an external URL with token query params,
// retry once without the token parameters (e.g. public pull zones or direct CDNs).
if ((errStr.contains('403') || errStr.contains('token') || errStr.contains('permission')) &&
finalVideoUrl.contains('?')) {
final strippedUrl = finalVideoUrl.split('?').first;
if (strippedUrl != finalVideoUrl && strippedUrl.isNotEmpty) {
_videoController?.dispose();
_videoController = VideoPlayerController.networkUrl(
Uri.parse(strippedUrl),
httpHeaders: const {},
)..initialize().then((_) {
if (mounted) {
setState(() {
_isVideoInitialized = true;
_videoInitError = null;
});
_revealVideoControls();
if (shouldPlay) _videoController!.play();
}
}).catchError((retryErr) {
if (mounted) {
setState(() {
_isVideoInitialized = false;
_videoInitError = 'تعذر تشغيل بث الفيديو من المصدر السحابي، يمكنك استخدام السبورة التفاعلية للشرح.';
});
}
});
return;
}
}
);
_videoController = controller;
if (mounted) {
setState(() {
_isVideoInitialized = false;
_videoInitError = 'تعذر تحميل بث الفيديو المباشر؛ انقر لإعادة المحاولة ($err)';
});
}
controller.initialize().then((_) {
if (_isDisposed || !mounted) {
AppLogger.log('🛑 [LIFECYCLE_ABORT] Screen disposed before video completed loading. Silencing controller.', tag: 'VIDEO_LIFECYCLE');
controller.pause();
controller.setVolume(0.0);
controller.dispose();
return;
}
AppLogger.log('✅ [VIDEO_READY] Video successfully initialized!\n'
' • Duration: ${controller.value.duration.inSeconds}s (${_formatTime(controller.value.duration.inSeconds)})\n'
' • Resolution: ${controller.value.size.width.toInt()}x${controller.value.size.height.toInt()}\n'
' • Aspect Ratio: ${controller.value.aspectRatio.toStringAsFixed(2)}',
tag: 'VIDEO_PLAYER');
setState(() {
_isVideoInitialized = true;
_videoInitError = null;
if (_isMuted) controller.setVolume(0.0);
});
_revealVideoControls();
if (resumePos > 3 && resumePos < controller.value.duration.inSeconds) {
controller.seekTo(Duration(seconds: resumePos));
if (context.mounted) {
SaqelToast.showInfo(
context,
'تم استئناف المشاهدة من الدقيقة ${_formatTime(resumePos)} ⏱️',
title: 'استئناف المشاهدة',
);
}
}
if (shouldPlay && !_isDisposed) {
controller.play();
AppLogger.log('▶️ [VIDEO_PLAY] Playback started naturally.', tag: 'VIDEO_PLAYER');
}
}).catchError((err, stack) {
final errStr = err.toString();
AppLogger.error(
'🚨 [VIDEO_PLAYER_ERROR] Real video playback failed!\n'
' • Target URL: $finalVideoUrl\n'
' • Error: $errStr',
error: err,
stackTrace: stack,
tag: 'VIDEO_PLAYER',
);
if (mounted && !_isDisposed) {
setState(() {
_isVideoInitialized = false;
final is403 = errStr.contains('403') || errStr.contains('permission') || errStr.contains('-12660');
final is404 = errStr.contains('404') || errStr.contains('not found');
if (is403) {
_videoInitError = 'بث الفيديو السحابي قيد المزامنة على مزود الاستضافة (HTTP 403).\nيمكنك متابعة دراسة الحصة فوراً عبر السبورة السقراطية الذكية بالصوت 🎙️ أو فتح المختبر التفاعلي 🔬.';
} else if (is404) {
_videoInitError = 'ملف الفيديو غير متوفر حالياً على الخادم (HTTP 404).\nيمكنك متابعة الدرس عبر السبورة السقراطية الذكية بالصوت 🎙️ أو فتح المختبر التفاعلي 🔬.';
} else {
_videoInitError = 'تعذر تشغيل الفيديو: $errStr';
}
});
}
});
}
Future<void> _speakConcept(String text) async {
if (text.isEmpty) return;
if (text.isEmpty || _isDisposed || _isMuted) return;
setState(() => _isSpeakingChalkboard = true);
try {
await _tts.setLanguage('ar');
await _tts.setSpeechRate(0.48);
_tts.setCompletionHandler(() {
if (mounted) setState(() => _isSpeakingChalkboard = false);
if (mounted && !_isDisposed) setState(() => _isSpeakingChalkboard = false);
});
await _tts.speak(text);
} catch (_) {
if (mounted) setState(() => _isSpeakingChalkboard = false);
if (mounted && !_isDisposed) setState(() => _isSpeakingChalkboard = false);
}
}
@override
void initState() {
super.initState();
AppLogger.log('📱 [SCREEN_ENTER] Opened SocraticVideoPlayerScreen for lesson: ${widget.lesson.title}', tag: 'VIDEO_LIFECYCLE');
context.read<VideoPlaybackCubit>().loadLesson(widget.lesson, subject: widget.subject, selectedVideoVersionId: widget.selectedVideoVersionId);
// Dynamic Floating Forensic Anti-Piracy Watermark Animation
@@ -197,9 +251,17 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
// Keep the UI clock responsive, but sync progress to the API every 15s.
_playbackTicker = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_isDisposed || !mounted) {
timer.cancel();
return;
}
final cubit = context.read<VideoPlaybackCubit>();
final state = cubit.state;
if (state is VideoPlaybackReady && _videoController?.value.isInitialized == true) {
if (state is VideoPlaybackReady &&
_videoController != null &&
_isVideoInitialized &&
!_videoController!.value.hasError &&
_videoController!.value.isInitialized) {
final position = _videoController!.value.position.inSeconds;
if (state.activeCheckpoint == null) cubit.updatePosition(position);
final duration = _videoController!.value.duration.inSeconds;
@@ -214,14 +276,23 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
});
}
@override
void deactivate() {
AppLogger.log('⏸️ [SCREEN_DEACTIVATE] Deactivating video player screen. Stopping audio & video...', tag: 'VIDEO_LIFECYCLE');
_isDisposed = true;
_stopAllAudioAndVideo();
super.deactivate();
}
@override
void dispose() {
AppLogger.log('🧹 [SCREEN_DISPOSE] Disposing video player screen resources.', tag: 'VIDEO_LIFECYCLE');
_isDisposed = true;
context.read<VideoPlaybackCubit>().endWatchSession();
_tts.stop();
_stopAllAudioAndVideo();
_playbackTicker?.cancel();
_controlsHideTimer?.cancel();
_watermarkController.dispose();
_videoController?.dispose();
super.dispose();
}
@@ -252,6 +323,21 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
),
centerTitle: false,
actions: [
IconButton(
tooltip: _isMuted ? 'إلغاء كتم الصوت' : 'كتم الصوت',
icon: Icon(
_isMuted ? CupertinoIcons.volume_off : CupertinoIcons.volume_up,
color: _isMuted ? AppColors.guardianAmber : Colors.white,
),
onPressed: () {
setState(() {
_isMuted = !_isMuted;
_videoController?.setVolume(_isMuted ? 0.0 : 1.0);
if (_isMuted) _tts.stop();
});
AppLogger.log('🔊 [AUDIO_TOGGLE] User toggled audio: ${_isMuted ? "MUTED" : "UNMUTED"}', tag: 'VIDEO_PLAYER');
},
),
Container(
margin: const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
@@ -380,13 +466,16 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
// Video Background Scene
Stack(
children: [
if (_videoController != null && _isVideoInitialized)
if (_videoController != null &&
_isVideoInitialized &&
_videoController!.value.isInitialized &&
!_videoController!.value.hasError)
Positioned.fill(
child: FittedBox(
fit: BoxFit.cover,
child: SizedBox(
width: _videoController!.value.size.width,
height: _videoController!.value.size.height,
child: Center(
child: AspectRatio(
aspectRatio: _videoController!.value.aspectRatio > 0
? _videoController!.value.aspectRatio
: 16 / 9,
child: VideoPlayer(_videoController!),
),
),
@@ -450,17 +539,45 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
const SizedBox(height: 14),
Wrap(
alignment: WrapAlignment.center,
spacing: 10,
spacing: 12,
runSpacing: 10,
children: [
ElevatedButton.icon(
icon: const Icon(CupertinoIcons.arrow_clockwise, size: 16),
label: const Text('إعادة محاولة البث 🎥'),
icon: const Icon(CupertinoIcons.mic_fill, size: 16, color: Colors.black),
label: const Text('بدء الشرح على السبورة الذكية 🎙️', style: TextStyle(color: Colors.black, fontWeight: FontWeight.w800, fontSize: 13)),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.saqelCyan,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
onPressed: () {
setState(() {
_showChalkboardMode = true;
_videoInitError = null;
});
},
),
ElevatedButton.icon(
icon: const Icon(CupertinoIcons.sparkles, size: 16, color: Colors.white),
label: const Text('المختبر التفاعلي للمبحث 🔬', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 13)),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.appleBlue,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
onPressed: () {
Navigator.of(context).pop();
},
),
OutlinedButton.icon(
icon: const Icon(CupertinoIcons.arrow_clockwise, size: 16, color: Colors.white70),
label: const Text('إعادة المحاولة 🔄', style: TextStyle(color: Colors.white70)),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.white24),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
onPressed: () {
setState(() {
@@ -474,21 +591,6 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
);
},
),
OutlinedButton.icon(
icon: const Icon(CupertinoIcons.mic_fill, size: 16, color: AppColors.appleBlue),
label: const Text('السبورة والشرح الصوتي 🎙️', style: TextStyle(color: Colors.white)),
style: OutlinedButton.styleFrom(
side: BorderSide(color: AppColors.appleBlue.withAlpha(150)),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: () {
setState(() {
_showChalkboardMode = true;
_videoInitError = null;
});
},
),
],
),
],
@@ -1104,6 +1206,7 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
],
),
),
if (isMath) _buildMathStepBoard(),
const SizedBox(height: 16),
Wrap(
alignment: WrapAlignment.center,
@@ -1202,6 +1305,143 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
),
);
}
Widget _buildMathStepBoard() {
final steps = [
{
'title': 'الخطوة 1: صياغة النظام الرياضي',
'eq1': 'المعادلة الخطية: y = 2x - 2',
'eq2': 'المعادلة التربيعية: y = x² - 4x + 3',
'note': 'نظام مكون من معادلة خطية وأخرى تربيعية بمتغيرين (x, y).'
},
{
'title': 'الخطوة 2: التعويض ومساواة الطرفين',
'eq1': 'x² - 4x + 3 = 2x - 2',
'eq2': 'نعوض قيمة y من المعادلة الخطية في المعادلة التربيعية',
'note': 'ينتج لدينا معادلة تربيعية بمتغير واحد فقط هو x.'
},
{
'title': 'الخطوة 3: التصفير والتجميع في الصورة القياسية',
'eq1': 'x² - 4x - 2x + 3 + 2 = 0',
'eq2': 'x² - 6x + 5 = 0',
'note': 'المعاملات: a = 1, b = -6, c = 5 (المميز Δ = 36 - 20 = 16 > 0 له حلان حقيقيان).'
},
{
'title': 'الخطوة 4: التحليل إلى العوامل وإيجاد قيم x',
'eq1': '(x - 5)(x - 1) = 0',
'eq2': 'إما x = 5 أو x = 1',
'note': 'أوجدنا الإحداثي السيني لنقطتي تقاطع المستقيم والقطع المكافئ.'
},
{
'title': 'الخطوة 5: التعويض لإيجاد y ومجموعة الحل',
'eq1': 'عند x = 5: y = 2(5) - 2 = 8 ➔ (5, 8)',
'eq2': 'عند x = 1: y = 2(1) - 2 = 0 ➔ (1, 0)',
'note': 'مجموعة حل النظام: {(5, 8), (1, 0)} - تم التحقق بالتعويض.'
},
];
final cur = steps[_chalkboardStep.clamp(0, steps.length - 1)];
return Container(
constraints: const BoxConstraints(maxWidth: 620),
margin: const EdgeInsets.symmetric(vertical: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF0A1926),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.saqelCyan.withAlpha(120), width: 1.5),
boxShadow: [
BoxShadow(color: AppColors.saqelCyan.withAlpha(20), blurRadius: 16, offset: const Offset(0, 4)),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppColors.saqelCyan.withAlpha(40),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'خطوة ${_chalkboardStep + 1} من ${steps.length}',
style: const TextStyle(color: AppColors.saqelCyan, fontSize: 12, fontWeight: FontWeight.w700),
),
),
Text(
cur['title']!,
style: const TextStyle(color: Colors.white, fontSize: 13.5, fontWeight: FontWeight.w800),
),
],
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withAlpha(150),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white10),
),
child: Column(
children: [
Text(
cur['eq1']!,
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFFFFD60A), fontSize: 15, fontWeight: FontWeight.w800, letterSpacing: 0.5),
),
if (cur['eq2']!.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
cur['eq2']!,
textAlign: TextAlign.center,
style: const TextStyle(color: Color(0xFF30D158), fontSize: 14.5, fontWeight: FontWeight.w700),
),
],
],
),
),
const SizedBox(height: 8),
Text(
'💡 ${cur['note']!}',
style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 12, height: 1.4),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ElevatedButton.icon(
icon: const Icon(CupertinoIcons.arrow_right, size: 14),
label: const Text('السابقة'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white12,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
),
onPressed: _chalkboardStep > 0
? () => setState(() => _chalkboardStep--)
: null,
),
ElevatedButton.icon(
icon: const Icon(CupertinoIcons.arrow_left, size: 14),
label: const Text('التالية'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.saqelCyan,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
),
onPressed: _chalkboardStep < steps.length - 1
? () => setState(() => _chalkboardStep++)
: null,
),
],
),
],
),
);
}
}
class _GridPatternPainter extends CustomPainter {
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
@@ -28,51 +29,82 @@ class HistoryJordanChronologyLabView extends StatefulWidget {
class _HistoryJordanChronologyLabViewState
extends State<HistoryJordanChronologyLabView> {
int era = 2;
int era = 0;
double progress = 1.0;
// المحطات التاريخية الرسمية لمنهاج الصف العاشر (الوحدات 1-4)
static const milestones = [
{
'y': '1916',
't': 'الثورة العربية الكبرى',
'y': '550 ق.م',
't': 'تأسيس الدولة الأخمينية الفارسية',
'unit': 'الوحدة الأولى: الإمبراطورية الفارسية',
'd':
'انطلاق الثورة بقيادة الشريف الحسين بن علي ضد الحكم العثماني؛ تشكل الوعي العربي الحديث وخط سير الجيوش نحو الشام.',
'c': Color(0xFFFF9F0A)
'تأسيس الملك كورش الأكبر للإمبراطورية الأخمينية الفارسية، وتوحيد الميديين والفرس، وشق الطريق الملكي (2700 كم) من ساردس إلى سوسة وبرسبوليس.',
'c': Color(0xFFFFD60A),
'icon': '🏛️',
},
{
'y': '1921',
't': 'تأسيس إمارة شرق الأردن',
'y': '224 م',
't': 'تأسيس الدولة الساسانية (معركة هرمزجان)',
'unit': 'الوحدة الأولى: الإمبراطورية الفارسية',
'd':
'وصول الأمير عبد الله الأول إلى عمان (2 آذار) وتشكيل أول حكومة (11 نيسان) برئاسة رشيد طليع.',
'c': Color(0xFF8B5CF6)
'تتويج أردشير الأول شاهنشاهاً بعد معركة هرمزجان (224م) وإسقاط البارثيين، وإعلان طيسفون (المدائن) عاصمة جديدة وبناء إيوان كسرى ومجمع جنديسابور.',
'c': Color(0xFFFF9F0A),
'icon': '👑',
},
{
'y': '1928',
't': 'القانون الأساسي والمعاهدة',
'y': '651 م',
't': 'معركة نهاوند ونهاية الساسانيين',
'unit': 'الوحدة الأولى: الإمبراطورية الفارسية',
'd':
'أول وثيقة دستورية للإمارة (القانون الأساسي) وتنظيم العلاقة مع بريطانيا والمؤتمر الوطني الأول.',
'c': Color(0xFF60A5FA)
'معركة نهاوند (فتح الفتوح - 642م/651م) وسقوط يزدجرد الثالث، ودخول بلاد فارس بالكامل تحت راية الحضارة الإسلامية.',
'c': Color(0xFF30D158),
'icon': '⚔️',
},
{
'y': '1946',
't': 'الاستقلال 25 أيار',
'y': '1299 م',
't': 'تأسيس الدولة العثمانية',
'unit': 'الوحدة الثانية: الدولة العثمانية',
'd':
'إعلان الاستقلال التام ومبايعة الملك المؤسس ملكاً دستورياً وتحول الاسم إلى المملكة الأردنية الهاشمية.',
'c': Color(0xFF30D158)
'تأسيس الأمير عثمان بن أرطغرل للإمارة العثمانية في سوغوت شمال غرب الأناضول، وتوسعها التدريجي في آسيا الصغرى والبلقان.',
'c': Color(0xFF60A5FA),
'icon': '🏹',
},
{
'y': '1952',
't': 'دستور 1952',
'y': '1453 م',
't': 'فتح القسطنطينية (إسطنبول)',
'unit': 'الوحدة الثانية: الدولة العثمانية',
'd':
'صدور الدستور الأردني الحالي في عهد الملك طلال؛ ترسيخ الملكية الدستورية والحقوق والحريات.',
'c': Color(0xFF00F5D4)
'السلطان محمد الثاني (الفاتح) يفتح القسطنطينية ويُنهي الإمبراطورية البيزنطية ويجعلها عاصمة عثمانية، مستخدماً مدافع أوربان ونقل السفن براً.',
'c': Color(0xFF8B5CF6),
'icon': '🏰',
},
{
'y': '1994',
't': 'معاهدة وادي عربة',
'y': '1760 م',
't': 'انطلاق الثورة الصناعية',
'unit': 'الوحدة الثالثة: ثورات غيّرت العالم',
'd':
'معاهدة السلام الأردنية–الإسرائيلية؛ ملفات الحدود والمياه والأمن — تُدرس من الكتاب المقرر حصراً.',
'c': Color(0xFF0071E3)
'اختراع الآلة البخارية لجيمس واط وتطوير صناعة الغزل والنسيج في بريطانيا، والتحول الجذري من الإنتاج الزراعي واليدوي إلى المصانع والآلات.',
'c': Color(0xFF00F5D4),
'icon': '⚙️',
},
{
'y': '1789 م',
't': 'الثورة الفرنسية الكبرى',
'unit': 'الوحدة الثالثة: ثورات غيّرت العالم',
'd':
'سقوط سجن الباستيل في باريس، وإعلان حقوق الإنسان والمواطن، وإلغاء الإقطاع والمَلكية المطلقة، وإرساء مبادئ الحرية والعدالة والمساواة.',
'c': Color(0xFFFF375F),
'icon': '📜',
},
{
'y': '1805 م',
't': 'عصر محمد علي باشا وبناء الدولة الحديثة',
'unit': 'الوحدة الرابعة: شخصيات تاريخية أثرت في العالم',
'd':
'تولي محمد علي باشا حكم مصر عام 1805م، وبناء الجيش والأسطول الحديث، وإرسال البعثات التعليمية (رفاعة الطهطاوي)، وإنشاء مدرسة الألسن والقناطر الخيرية.',
'c': Color(0xFF10B981),
'icon': '🌟',
},
];
@@ -80,176 +112,257 @@ class _HistoryJordanChronologyLabViewState
Widget build(BuildContext context) {
final m = milestones[era];
return SaqelLabScaffold(
titleAr: 'التسلسل الزمني لتاريخ الأردن',
subtitleAr: 'اسحب المؤشر عبر المحطات — خريطة طريق + بطاقة الحدث',
titleAr: 'الخط الزمني الشامل لحضارات العالم — الصف العاشر',
subtitleAr:
'تسلسل منهجي يربط بين: الإمبراطورية الفارسية • الدولة العثمانية • الثورات الكبرى • الشخصيات المؤثرة',
identity: kHistoryJordanChronologyToolIdentity,
onCheckpointTriggered: widget.onCheckpointTriggered,
checkpointQuestion:
'اليوم الوطني لاستقلال المملكة الأردنية الهاشمية هو …',
'المعركة الفاصلة التي خاضها أردشير الأول وأسفرت عن تأسيس الدولة الساسانية عام 224م هي …',
checkpointOptions: const [
'25 أيار 1946',
'2 آذار 1921',
'16 نيسان 1928',
'10 حزيران 1916'
'معركة هرمزجان',
'معركة نهاوند',
'معركة الريدانية',
'معركة القادسية'
],
checkpointCorrectIdx: 0,
telemetry: [
LabPill('${m['y']} • ${m['t']}', color: m['c'] as Color),
LabPill('${m['icon']} ${m['y']}', color: m['c'] as Color),
LabPill('${m['t']}', color: Colors.white),
LabPill('${m['unit']}', color: AppColors.saqelCyan),
],
canvas: CustomPaint(
painter: _ChronoPainter(index: era, progress: progress),
painter: _WorldCivilizationsChronoPainter(index: era, milestones: milestones),
child: Container(),
),
controls: [
LabSlider(
label: 'المحطة التاريخية',
value: era.toDouble(),
min: 0,
max: 5,
display: '${milestones[era]['y']}',
onChanged: (v) => setState(() => era = v.round())),
label: 'المحطة التاريخية',
value: era.toDouble(),
min: 0,
max: (milestones.length - 1).toDouble(),
display: '${milestones[era]['y']}',
onChanged: (v) => setState(() => era = v.round()),
),
const SizedBox(height: 6),
SizedBox(
height: 40,
height: 42,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: milestones.length,
separatorBuilder: (_, __) => const SizedBox(width: 6),
itemBuilder: (ctx, i) {
final sel = i == era;
final item = milestones[i];
return GestureDetector(
onTap: () => setState(() => era = i),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: sel
? (milestones[i]['c'] as Color)
? (item['c'] as Color)
: Colors.white.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: sel ? Colors.white : Colors.white12,
width: sel ? 1.4 : 1.0,
),
),
alignment: Alignment.center,
child: Text(milestones[i]['y'] as String,
style: TextStyle(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(item['icon'] as String, style: const TextStyle(fontSize: 12)),
const SizedBox(width: 5),
Text(
item['y'] as String,
style: TextStyle(
color: sel ? Colors.black : Colors.white70,
fontWeight: FontWeight.w900,
fontSize: 12)),
fontSize: 11.5,
),
),
],
),
),
);
},
),
),
const SizedBox(height: 8),
const SizedBox(height: 10),
LabFormulaCard(
title: '${m['y']} — ${m['t']}',
body: m['d'] as String,
title: '${m['icon']} ${m['y']} — ${m['t']}',
body: '${m['d']}\n\n• المنهج الوزاري: ${m['unit']}',
accent: m['c'] as Color,
),
],
footerNote:
'عرض صفي موجز؛ التفاصيل الدقيقة والوثائق من الكتاب المدرسي المقرر.',
'منهاج وزارة التربية والتعليم الأردنية • كتاب التاريخ للصف العاشر • الوحدات الأربع المقررة.',
);
}
}
class _ChronoPainter extends CustomPainter {
class _WorldCivilizationsChronoPainter extends CustomPainter {
final int index;
final double progress;
_ChronoPainter({required this.index, required this.progress});
final List<Map<String, dynamic>> milestones;
_WorldCivilizationsChronoPainter({
required this.index,
required this.milestones,
});
@override
void paint(Canvas c, Size s) {
c.drawRect(Offset.zero & s, Paint()..color = const Color(0xFF0A1220));
// simplified Jordan outline (schematic polygon, clearly stylised)
final mapRect = Rect.fromLTWH(18, 18, s.width * 0.40, s.height - 90);
c.drawRRect(RRect.fromRectAndRadius(mapRect, const Radius.circular(14)),
Paint()..color = const Color(0xFF0F2036));
final poly = [
const Offset(0.55, 0.08),
const Offset(0.80, 0.18),
const Offset(0.86, 0.42),
const Offset(0.70, 0.62),
const Offset(0.72, 0.88),
const Offset(0.40, 0.92),
const Offset(0.22, 0.66),
const Offset(0.30, 0.34),
]
.map((e) => Offset(mapRect.left + e.dx * mapRect.width,
mapRect.top + e.dy * mapRect.height))
.toList();
c.drawPath(Path()..addPolygon(poly, true),
Paint()..color = const Color(0xFF1E3A5F));
// revolt route dashed
final route = Path()
..moveTo(mapRect.left + mapRect.width * 0.72,
mapRect.top + mapRect.height * 0.88)
..quadraticBezierTo(
mapRect.left + mapRect.width * 0.5,
mapRect.top + mapRect.height * 0.5,
mapRect.left + mapRect.width * 0.6,
mapRect.top + mapRect.height * 0.15);
_dashed(c, route, const Color(0xFFFF9F0A));
final dotT = (index / 5).clamp(0.0, 1.0);
final dotPos = _pointOn(route, dotT, mapRect);
c.drawCircle(dotPos, 7, Paint()..color = const Color(0xFFFF375F));
c.drawCircle(
dotPos,
7,
Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 1.6);
// timeline rail (right side)
final rx = s.width * 0.52, ry0 = 30.0, ry1 = s.height - 40;
c.drawLine(
Offset(rx, ry0),
Offset(rx, ry1),
Paint()
..color = Colors.white24
..strokeWidth = 3);
for (int i = 0; i < 6; i++) {
final y = ry0 + i * ((ry1 - ry0) / 5);
final sel = i == index;
c.drawCircle(Offset(rx, y), sel ? 11 : 7,
Paint()..color = sel ? const Color(0xFF00F5D4) : Colors.white24);
final w = s.width;
final h = s.height;
// Dark parchment starry background
final bgShader = const RadialGradient(
center: Alignment(0.0, -0.3),
colors: [Color(0xFF0F1B2C), Color(0xFF060B12)],
radius: 1.0,
).createShader(Rect.fromLTWH(0, 0, w, h));
c.drawRect(Rect.fromLTWH(0, 0, w, h), Paint()..shader = bgShader);
// Subtle historical coordinate lines
final gridPaint = Paint()
..color = Colors.white.withValues(alpha: 0.03)
..strokeWidth = 1.0;
for (double x = 0; x < w; x += 35) {
c.drawLine(Offset(x, 0), Offset(x, h), gridPaint);
}
// 4 Era Color Zones (أشرطة العصور التاريخية الأربعة)
final eraWidth = w / 4;
final eraLabels = [
'العصر الفارسي القديم\n(550 ق.م - 651 م)',
'العصر العثماني والفتوح\n(1299 م - 1923 م)',
'عصر الثورات الكبرى\n(1760 م - 1916 م)',
'بناء الدولة الحديثة\n(1805 م - 1938 م)',
];
final eraColors = [
const Color(0xFFFFD60A),
const Color(0xFF60A5FA),
const Color(0xFFFF375F),
const Color(0xFF10B981),
];
for (int i = 0; i < 4; i++) {
final x = i * eraWidth;
final zonePaint = Paint()
..color = eraColors[i].withValues(alpha: 0.04)
..style = PaintingStyle.fill;
c.drawRect(Rect.fromLTWH(x, 10, eraWidth - 2, h - 30), zonePaint);
final dividerPaint = Paint()
..color = Colors.white.withValues(alpha: 0.08)
..strokeWidth = 1.0;
c.drawLine(Offset(x + eraWidth - 2, 10), Offset(x + eraWidth - 2, h - 20), dividerPaint);
final tp = TextPainter(
text: TextSpan(
text: ['1916', '1921', '1928', '1946', '1952', '1994'][i],
style: TextStyle(
color: sel ? Colors.white : Colors.white54,
fontSize: 11,
fontWeight: FontWeight.w800)),
textDirection: TextDirection.ltr,
)..layout();
tp.paint(c, Offset(rx + 18, y - 8));
text: eraLabels[i],
style: TextStyle(
color: eraColors[i].withValues(alpha: 0.7),
fontSize: 9.0,
fontWeight: FontWeight.w700,
),
),
textAlign: TextAlign.center,
textDirection: TextDirection.rtl,
)..layout(maxWidth: eraWidth - 8);
tp.paint(c, Offset(x + (eraWidth - tp.width) / 2, 16));
}
}
void _dashed(Canvas c, Path p, Color col) {
final metrics = p.computeMetrics().toList();
for (final m in metrics) {
double d = 0;
while (d < m.length) {
c.drawPath(
m.extractPath(d, d + 8),
// Interactive Flowing S-Curve Timeline Rail (خط زمني انسيابي ممتد)
final railPath = Path();
final count = milestones.length;
final points = <Offset>[];
for (int i = 0; i < count; i++) {
final px = 24.0 + (i / (count - 1)) * (w - 48.0);
// Harmonious wave across the canvas
final py = (h * 0.52) + math.sin(i * 1.1) * (h * 0.16);
points.add(Offset(px, py));
}
railPath.moveTo(points.first.dx, points.first.dy);
for (int i = 0; i < points.length - 1; i++) {
final p0 = points[i];
final p1 = points[i + 1];
final mx = (p0.dx + p1.dx) / 2;
railPath.cubicTo(mx, p0.dy, mx, p1.dy, p1.dx, p1.dy);
}
// Draw main timeline track
c.drawPath(
railPath,
Paint()
..color = Colors.white24
..strokeWidth = 3.2
..style = PaintingStyle.stroke,
);
// Active illuminated segment
final activeColor = milestones[index]['c'] as Color;
final completedPath = Path()..moveTo(points.first.dx, points.first.dy);
for (int i = 0; i < index; i++) {
final p0 = points[i];
final p1 = points[i + 1];
final mx = (p0.dx + p1.dx) / 2;
completedPath.cubicTo(mx, p0.dy, mx, p1.dy, p1.dx, p1.dy);
}
c.drawPath(
completedPath,
Paint()
..color = activeColor
..strokeWidth = 3.8
..style = PaintingStyle.stroke,
);
// Draw milestone stations along the curve
for (int i = 0; i < count; i++) {
final pt = points[i];
final m = milestones[i];
final isSel = i == index;
final col = m['c'] as Color;
if (isSel) {
// Glowing halo
c.drawCircle(pt, 16, Paint()..color = col.withValues(alpha: 0.25));
c.drawCircle(
pt,
12,
Paint()
..color = col
..strokeWidth = 2.4);
d += 14;
..style = PaintingStyle.stroke
..strokeWidth = 2.0);
}
}
}
Offset _pointOn(Path route, double t, Rect mapRect) {
// approximate: lerp along bounding diagonal of route (classroom schematic)
return Offset(
mapRect.left + mapRect.width * (0.72 - 0.12 * t),
mapRect.top + mapRect.height * (0.88 - 0.73 * t),
);
// Station node
c.drawCircle(pt, isSel ? 7.5 : 5.0, Paint()..color = isSel ? Colors.white : col);
// Label (Year + Icon)
final tp = TextPainter(
text: TextSpan(
text: '${m['icon']} ${m['y']}',
style: TextStyle(
color: isSel ? Colors.white : Colors.white70,
fontSize: isSel ? 10.5 : 9.0,
fontWeight: isSel ? FontWeight.w900 : FontWeight.w700,
backgroundColor: isSel ? Colors.black87 : Colors.transparent,
),
),
textDirection: TextDirection.rtl,
)..layout();
final labelY = (i % 2 == 0) ? pt.dy - 24 : pt.dy + 12;
tp.paint(c, Offset(pt.dx - tp.width / 2, labelY));
}
}
@override
bool shouldRepaint(covariant _ChronoPainter o) => o.index != index;
bool shouldRepaint(covariant _WorldCivilizationsChronoPainter o) => o.index != index;
}
// ---------------------------------------------------------------------------
@@ -461,7 +574,7 @@ class _FlowPainter extends CustomPainter {
}
// ---------------------------------------------------------------------------
// 3) SORTING PLAYGROUND (Bubble vs Quick trace)
// 3) SORTING & SEARCHING PLAYGROUND (المصفوفات، الفرز، والبحث خطوة بخطوة)
// ---------------------------------------------------------------------------
class DigitalSortingLabView extends StatefulWidget {
final LabCheckpointCallback? onCheckpointTriggered;
@@ -474,52 +587,91 @@ class DigitalSortingLabView extends StatefulWidget {
class _DigitalSortingLabViewState extends State<DigitalSortingLabView>
with SingleTickerProviderStateMixin {
List<int> arr = [7, 2, 9, 4, 1, 6];
int algo = 0; // 0 bubble, 1 quick (trace)
int algo = 0; // 0: Bubble Sort, 1: Selection Sort, 2: Quick Sort
int stepIdx = 0;
List<List<int>> snaps = [];
List<String> notes = [];
bool playing = false;
late final AnimationController ctl;
List<List<int>> activeIndices = []; // [index1, index2, isSwapped]
bool isPlaying = false;
// ignore: unused_field
Timer? _autoPlayTimer;
@override
void initState() {
super.initState();
_buildTrace();
ctl = AnimationController(
vsync: this, duration: const Duration(milliseconds: 600))
..addListener(() {
if (!playing) return;
});
}
void _buildTrace() {
snaps = [List.of(arr)];
notes = ['البداية: ${arr.join(' ')}'];
notes = ['المصفوفة الأولية قبل البدء: [${arr.join(', ')}]'];
activeIndices = [[-1, -1, 0]];
if (algo == 0) {
// Bubble Sort with explicit comparison & swap frames
final a = List<int>.of(arr);
for (int i = 0; i < a.length; i++) {
for (int j = 0; j < a.length - 1 - i; j++) {
final n = a.length;
for (int i = 0; i < n; i++) {
for (int j = 0; j < n - 1 - i; j++) {
// Comparison frame
snaps.add(List.of(a));
notes.add('مقارنة العنصر [j=$j]=${a[j]} مع [j+1=${j + 1}]=${a[j + 1]}');
activeIndices.add([j, j + 1, 0]);
if (a[j] > a[j + 1]) {
final t = a[j];
a[j] = a[j + 1];
a[j + 1] = t;
// Swap frame
snaps.add(List.of(a));
notes.add('بدّل ${a[j + 1]} و ${a[j]} → ${a.join(' ')}');
notes.add('تبديل ${a[j + 1]} مع ${a[j]} لأن ${a[j + 1]} > ${a[j]}');
activeIndices.add([j, j + 1, 1]);
}
}
}
} else if (algo == 1) {
// Selection Sort
final a = List<int>.of(arr);
final n = a.length;
for (int i = 0; i < n - 1; i++) {
int minIdx = i;
for (int j = i + 1; j < n; j++) {
snaps.add(List.of(a));
notes.add('البحث عن الأصغر: مقارنة [min=$minIdx]=${a[minIdx]} مع [j=$j]=${a[j]}');
activeIndices.add([minIdx, j, 0]);
if (a[j] < a[minIdx]) {
minIdx = j;
}
}
if (minIdx != i) {
final t = a[i];
a[i] = a[minIdx];
a[minIdx] = t;
snaps.add(List.of(a));
notes.add('تبديل الأصغر ${a[i]} إلى موقعه الصحيح عند المؤشر [$i]');
activeIndices.add([i, minIdx, 1]);
}
}
} else {
// quicksort Lomuto trace (simplified, honest label)
// Quicksort Lomuto trace
final a = List<int>.of(arr);
void qs(int lo, int hi) {
if (lo >= hi) return;
final pivot = a[hi];
int i = lo;
for (int j = lo; j < hi; j++) {
snaps.add(List.of(a));
notes.add('مقارنة العنصر [j=$j]=${a[j]} مع المحور Pivot=$pivot');
activeIndices.add([j, hi, 0]);
if (a[j] < pivot) {
final t = a[i];
a[i] = a[j];
a[j] = t;
if (i != j) {
snaps.add(List.of(a));
notes.add('وضع العنصر الأصغر من المحور في القسم الأيسر: تبديل [$i] مع [$j]');
activeIndices.add([i, j, 1]);
}
i++;
}
}
@@ -527,49 +679,90 @@ class _DigitalSortingLabViewState extends State<DigitalSortingLabView>
a[i] = a[hi];
a[hi] = t;
snaps.add(List.of(a));
notes.add('محور $pivot → ${a.join(' ')}');
notes.add('تثبيت المحور $pivot في موقعه النهائي عند الفهرس [$i]');
activeIndices.add([i, hi, 1]);
qs(lo, i - 1);
qs(i + 1, hi);
}
qs(0, a.length - 1);
}
stepIdx = 0;
}
void _toggleAutoPlay() {
setState(() {
isPlaying = !isPlaying;
if (isPlaying) {
_autoPlayTimer = Timer.periodic(const Duration(milliseconds: 700), (timer) {
if (!mounted || !isPlaying) {
timer.cancel();
return;
}
if (stepIdx < snaps.length - 1) {
setState(() => stepIdx++);
} else {
setState(() => isPlaying = false);
timer.cancel();
}
});
} else {
_autoPlayTimer?.cancel();
}
});
}
@override
void dispose() {
ctl.dispose();
_autoPlayTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final cur = snaps.isEmpty ? arr : snaps[stepIdx.clamp(0, snaps.length - 1)];
final active = activeIndices.isEmpty
? [-1, -1, 0]
: activeIndices[stepIdx.clamp(0, activeIndices.length - 1)];
final sorted = _isSorted(cur);
return SaqelLabScaffold(
titleAr: 'ملعب المصفوفات والفرز',
subtitleAr: 'قارن فقاعي O(n²) مع سريع O(n log n) خطوة بخطوة',
titleAr: 'معمل المصفوفات وخوارزميات الفرز التفاعلية',
subtitleAr:
'تتبع بصري حي مع إبراز مؤشرات المقارنة والتبديل وفهارس المصفوفة',
identity: kDigitalSortingToolIdentity,
onCheckpointTriggered: widget.onCheckpointTriggered,
checkpointQuestion: 'التعقيد الزمني الأسوأ للفرز الفقاعي (n عناصر) هو …',
checkpointQuestion: 'التعقيد الزمني الأسوأ للفرز الفقاعي (Bubble Sort) لمصفوفة حجمها n هو …',
checkpointOptions: const ['O(n²)', 'O(n)', 'O(log n)', 'O(1)'],
checkpointCorrectIdx: 0,
telemetry: [
LabPill(algo == 0 ? 'Bubble O(n²)' : 'Quick O(n log n)'),
LabPill('خطوة ${stepIdx + 1}/${snaps.length}'),
if (sorted) const LabPill('مرتبة ✓', color: Color(0xFF30D158)),
LabPill(
algo == 0
? 'فقاعي Bubble O(n²)'
: (algo == 1 ? 'اختيار Selection O(n²)' : 'سريع Quick O(n log n)'),
color: AppColors.saqelCyan,
),
LabPill('خطوة ${stepIdx + 1} من ${snaps.length}'),
if (sorted && stepIdx == snaps.length - 1)
const LabPill('مرتبة بنجاح ✓', color: Color(0xFF30D158)),
],
canvas: CustomPaint(
painter: _BarsPainter(values: cur),
painter: _EnhancedBarsPainter(
values: cur,
activeIdx1: active[0],
activeIdx2: active[1],
isSwap: active[2] == 1,
isFullySorted: sorted && stepIdx == snaps.length - 1,
),
child: Container(),
),
controls: [
LabSegments<int>(
labels: const ['فقاعي', 'سريع'],
values: const [0, 1],
labels: const ['فقاعي (Bubble)', 'اختيار (Selection)', 'سريع (Quick)'],
values: const [0, 1, 2],
current: algo,
onSelected: (v) => setState(() {
_autoPlayTimer?.cancel();
isPlaying = false;
algo = v;
_buildTrace();
}),
@@ -578,63 +771,115 @@ class _DigitalSortingLabViewState extends State<DigitalSortingLabView>
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: stepIdx > 0 ? () => setState(() => stepIdx--) : null,
child: OutlinedButton.icon(
onPressed: stepIdx > 0
? () {
_autoPlayTimer?.cancel();
setState(() {
isPlaying = false;
stepIdx--;
});
}
: null,
icon: const Icon(CupertinoIcons.chevron_right, size: 14),
label: const Text('السابق'),
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white24)),
child: const Text('◀ سابق'),
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white24),
),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton(
onPressed: stepIdx < snaps.length - 1
? () => setState(() => stepIdx++)
: null,
child: ElevatedButton.icon(
onPressed: _toggleAutoPlay,
icon: Icon(isPlaying ? CupertinoIcons.pause_fill : CupertinoIcons.play_arrow_solid, size: 15),
label: Text(isPlaying ? 'إيقاف مؤقت' : 'تشغيل تلقائي ⏯️'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.saqelCyan,
foregroundColor: Colors.black),
child: const Text('التالي ▶',
style: TextStyle(fontWeight: FontWeight.w800)),
backgroundColor: isPlaying ? const Color(0xFFFF9F0A) : AppColors.saqelCyan,
foregroundColor: Colors.black,
textStyle: const TextStyle(fontWeight: FontWeight.w800),
),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
onPressed: stepIdx < snaps.length - 1
? () {
_autoPlayTimer?.cancel();
setState(() {
isPlaying = false;
stepIdx++;
});
}
: null,
icon: const Icon(CupertinoIcons.chevron_left, size: 14),
label: const Text('التالي'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white.withValues(alpha: 0.12),
foregroundColor: Colors.white,
textStyle: const TextStyle(fontWeight: FontWeight.w700),
),
),
),
],
),
const SizedBox(height: 6),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
icon: const Icon(CupertinoIcons.shuffle, size: 15),
label: const Text('خلط المصفوفة'),
label: const Text('توليد مصفوفة عشوائية وخلط العناصر 🎲'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFFFD166),
side: const BorderSide(color: Color(0xFFFFD166))),
foregroundColor: const Color(0xFFFFD166),
side: const BorderSide(color: Color(0xFFFFD166)),
),
onPressed: () => setState(() {
_autoPlayTimer?.cancel();
isPlaying = false;
arr.shuffle(math.Random());
_buildTrace();
}),
),
),
const SizedBox(height: 8),
Directionality(
textDirection: TextDirection.ltr,
child: Text(
notes.isEmpty ? '' : notes[stepIdx.clamp(0, notes.length - 1)],
style: const TextStyle(
color: Color(0xFF00F5D4),
fontSize: 12,
fontFamily: 'monospace')),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.black45,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.saqelCyan.withValues(alpha: 0.25)),
),
child: Row(
children: [
const Icon(CupertinoIcons.info_circle_fill, color: AppColors.saqelCyan, size: 16),
const SizedBox(width: 8),
Expanded(
child: Text(
notes.isEmpty ? '' : notes[stepIdx.clamp(0, notes.length - 1)],
style: const TextStyle(
color: Color(0xFF00F5D4),
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
],
),
),
const SizedBox(height: 4),
const SizedBox(height: 6),
const LabFormulaCard(
title: 'لماذا السريع أسرع؟',
title: 'دليل الخوارزميات — الصف العاشر',
body:
'الفقاعي يقارن كل زوج مجاور (تربيعي). السريع يقسم حول محور ويفرز كل قسم (لوغاريتمي) — لاحظ قفزات المحور في التتبع.',
'• الفرز الفقاعي (Bubble): يقارن كل زوج متجاور ويبدلهما. تعقيد زمني O(n²).\n'
'• فرز الاختيار (Selection): يختار أصغر عنصر وينقله لموقعه النهائي. تعقيد زمني O(n²).\n'
'• الفرز السريع (Quick): يقسم المصفوفة حول محور (Pivot) ويفرز كل قسم لوغاريتمياً. تعقيد O(n log n).',
accent: AppColors.saqelCyan,
),
],
footerNote:
'تتبع تعليمي لمدخلات صغيرة؛ قياس الأداء الحقيقي يحتاج n كبيرة وتوقيتاً فعلياً.',
'منهاج وزارة التربية والتعليم الأردنية • المهارات الرقمية للصف العاشر • وحدة الخوارزميات والبرمجة.',
);
}
@@ -646,9 +891,20 @@ class _DigitalSortingLabViewState extends State<DigitalSortingLabView>
}
}
class _BarsPainter extends CustomPainter {
class _EnhancedBarsPainter extends CustomPainter {
final List<int> values;
_BarsPainter({required this.values});
final int activeIdx1;
final int activeIdx2;
final bool isSwap;
final bool isFullySorted;
_EnhancedBarsPainter({
required this.values,
required this.activeIdx1,
required this.activeIdx2,
required this.isSwap,
required this.isFullySorted,
});
@override
void paint(Canvas c, Size s) {
@@ -656,31 +912,82 @@ class _BarsPainter extends CustomPainter {
if (values.isEmpty) return;
final mx = values.reduce(math.max).toDouble();
final bw = s.width / values.length;
for (int i = 0; i < values.length; i++) {
final h = (values[i] / mx) * (s.height - 70);
final h = (values[i] / mx) * (s.height - 85);
final x = i * bw + 6;
final grad = const LinearGradient(
colors: [Color(0xFF00F5D4), Color(0xFF0071E3)],
begin: Alignment.bottomCenter,
end: Alignment.topCenter)
.createShader(Rect.fromLTWH(x, s.height - 30 - h, bw - 12, h));
final isComp = i == activeIdx1 || i == activeIdx2;
// Color coding for algorithm education
Color c1 = const Color(0xFF00F5D4);
Color c2 = const Color(0xFF0071E3);
if (isFullySorted) {
c1 = const Color(0xFF30D158);
c2 = const Color(0xFF10B981);
} else if (isComp) {
if (isSwap) {
c1 = const Color(0xFFFF375F);
c2 = const Color(0xFFFF5252);
} else {
c1 = const Color(0xFFFFD166);
c2 = const Color(0xFFFF9F0A);
}
}
final grad = LinearGradient(
colors: [c1, c2],
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
).createShader(Rect.fromLTWH(x, s.height - 40 - h, bw - 12, h));
c.drawRRect(
RRect.fromLTRBR(x, s.height - 30 - h, x + bw - 12, s.height - 30,
const Radius.circular(7)),
Paint()..shader = grad);
RRect.fromLTRBR(
x,
s.height - 40 - h,
x + bw - 12,
s.height - 40,
const Radius.circular(8),
),
Paint()..shader = grad,
);
// Top value text
final tp = TextPainter(
text: TextSpan(
text: '${values[i]}',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w900)),
text: '${values[i]}',
style: TextStyle(
color: isComp ? Colors.white : Colors.white70,
fontSize: isComp ? 14 : 12.5,
fontWeight: FontWeight.w900,
),
),
textDirection: TextDirection.ltr,
)..layout();
tp.paint(c, Offset(x + (bw - 12 - tp.width) / 2, s.height - 30 - h - 20));
tp.paint(c, Offset(x + (bw - 12 - tp.width) / 2, s.height - 40 - h - 20));
// Bottom array index badge: [i]
final idxTp = TextPainter(
text: TextSpan(
text: '[$i]',
style: TextStyle(
color: isComp ? (isSwap ? const Color(0xFFFF375F) : const Color(0xFFFFD166)) : Colors.white38,
fontSize: 11,
fontWeight: isComp ? FontWeight.w800 : FontWeight.w600,
),
),
textDirection: TextDirection.ltr,
)..layout();
idxTp.paint(c, Offset(x + (bw - 12 - idxTp.width) / 2, s.height - 32));
}
}
@override
bool shouldRepaint(covariant _BarsPainter o) => o.values != values;
bool shouldRepaint(covariant _EnhancedBarsPainter o) =>
o.values != values ||
o.activeIdx1 != activeIdx1 ||
o.activeIdx2 != activeIdx2 ||
o.isSwap != isSwap ||
o.isFullySorted != isFullySorted;
}
File diff suppressed because it is too large Load Diff
@@ -587,7 +587,7 @@ const LabIdentity kFinanceBudgetToolIdentity = LabIdentity(
const LabIdentity kHistoryJordanChronologyToolIdentity = LabIdentity(
toolKey: 'history_jordan_chronology',
subjectAr: 'التاريخ',
lessonAr: 'التسلسل الزمني التفاعلي',
lessonAr: 'التسلسل الزمني للحضارات (العاشر)',
);
const LabIdentity kDigitalFlowchartToolIdentity = LabIdentity(
@@ -1,5 +1,6 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import '../../../core/utils/app_logger.dart';
import '../../../data/models/socratic_checkpoint_model.dart';
import '../../widgets/socratic_dialog.dart';
import 'lab_identity.dart';
@@ -382,6 +383,9 @@ class Grade10LabsRegistry {
required String lessonTitle,
String? lessonId,
String? curriculumLessonId,
String? filePath,
String? unitKey,
String? semesterKey,
bool? allowPreview,
}) {
final usePreview = allowPreview ?? previewModeEnabled;
@@ -392,47 +396,110 @@ class Grade10LabsRegistry {
return null;
}
if (usePreview) {
// 1. Exact match on curriculumLessonId within candidate subject labs
if (curriculumLessonId != null && curriculumLessonId.isNotEmpty) {
for (final c in candidates) {
if (c.identity.curriculumLessonId == curriculumLessonId) {
return c;
}
}
}
// 2. Lesson Title fuzzy matching strictly within candidate subject labs
final t = lessonTitle.toLowerCase().trim();
// 1. Exact match on sourceMarkdown / filePath (Highest precision, 100% deterministic)
if (filePath != null && filePath.isNotEmpty) {
final cleanPath = filePath.replaceAll('\\', '/').trim();
for (final c in candidates) {
final ct = c.lessonAr.toLowerCase().trim();
if (t == ct || (ct.length > 4 && (t.contains(ct) || ct.contains(t)))) {
return c;
}
}
// 3. Lesson Key / Tool Key match strictly within candidate subject labs
if (lessonId != null && lessonId.isNotEmpty) {
for (final c in candidates) {
if (c.identity.lessonKey == lessonId ||
c.identity.toolKey == lessonId ||
c.identity.curriculumLessonId.endsWith(lessonId) ||
c.identity.sourceMarkdown.contains(lessonId)) {
if (c.identity.sourceMarkdown.isNotEmpty &&
(c.identity.sourceMarkdown == cleanPath ||
cleanPath.endsWith(c.identity.sourceMarkdown) ||
c.identity.sourceMarkdown.endsWith(cleanPath))) {
if (usePreview || c.identity.isPublished) {
AppLogger.event('VirtualLabResolvedByPath', details: {
'subject': subjectTitle,
'lesson': lessonTitle,
'path': cleanPath,
'lab': c.lessonAr,
}, tag: 'LAB_REGISTRY');
return c;
}
}
}
return null;
}
// 2. Synthesized canonical slug match from filePath (e.g. math_10_semester_1_unit_01_lesson_02)
if (filePath != null && filePath.isNotEmpty) {
final slug = filePath
.replaceAll('.md', '')
.replaceAll('grade_10/', '')
.replaceAll('/', '_')
.trim();
for (final c in candidates) {
if (c.identity.curriculumLessonId == slug) {
if (usePreview || c.identity.isPublished) {
AppLogger.event('VirtualLabResolvedBySlug', details: {
'subject': subjectTitle,
'lesson': lessonTitle,
'slug': slug,
'lab': c.lessonAr,
}, tag: 'LAB_REGISTRY');
return c;
}
}
}
}
// 3. Exact match on curriculumLessonId
if (curriculumLessonId != null && curriculumLessonId.isNotEmpty) {
for (final c in candidates) {
if (c.identity.curriculumLessonId == curriculumLessonId && c.identity.isPublished) {
return c;
if (c.identity.curriculumLessonId == curriculumLessonId) {
if (usePreview || c.identity.isPublished) {
AppLogger.event('VirtualLabResolvedByCurriculumId', details: {
'subject': subjectTitle,
'lesson': lessonTitle,
'curriculumId': curriculumLessonId,
'lab': c.lessonAr,
}, tag: 'LAB_REGISTRY');
return c;
}
}
}
}
if (usePreview) {
// 4. Strict Title matching within candidate subject labs (only within same unit if provided)
final t = lessonTitle.toLowerCase().trim();
for (final c in candidates) {
if (unitKey != null && c.identity.unitKey.isNotEmpty && c.identity.unitKey != unitKey) {
continue;
}
if (semesterKey != null && c.identity.semesterKey.isNotEmpty && c.identity.semesterKey != semesterKey) {
continue;
}
final ct = c.lessonAr.toLowerCase().trim();
if (t == ct || (ct.length > 5 && (t.contains(ct) || ct.contains(t)))) {
AppLogger.event('VirtualLabResolvedByTitle', details: {
'subject': subjectTitle,
'lesson': lessonTitle,
'match': ct,
'lab': c.lessonAr,
}, tag: 'LAB_REGISTRY');
return c;
}
}
// 5. Lesson Key match strictly constrained to matching unit & semester
if (lessonId != null && lessonId.isNotEmpty) {
for (final c in candidates) {
if (unitKey != null && c.identity.unitKey.isNotEmpty && c.identity.unitKey != unitKey) {
continue;
}
if (semesterKey != null && c.identity.semesterKey.isNotEmpty && c.identity.semesterKey != semesterKey) {
continue;
}
if (c.identity.lessonKey == lessonId || c.identity.toolKey == lessonId) {
AppLogger.event('VirtualLabResolvedByKey', details: {
'subject': subjectTitle,
'lesson': lessonTitle,
'key': lessonId,
'lab': c.lessonAr,
}, tag: 'LAB_REGISTRY');
return c;
}
}
}
}
return null;
}
@@ -1,6 +1,7 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/utils/app_logger.dart';
import '../../../data/models/socratic_checkpoint_model.dart';
import '../../widgets/socratic_dialog.dart';
import 'labs_gallery_screen.dart';
@@ -17,6 +18,7 @@ class SubjectVirtualLabsView extends StatefulWidget {
final Color primaryColor;
final Widget? specializedToolWidget;
final String? specializedToolTitle;
final String? selectedSemester;
const SubjectVirtualLabsView({
super.key,
@@ -24,6 +26,7 @@ class SubjectVirtualLabsView extends StatefulWidget {
required this.primaryColor,
this.specializedToolWidget,
this.specializedToolTitle,
this.selectedSemester,
});
@override
@@ -34,7 +37,26 @@ class _SubjectVirtualLabsViewState extends State<SubjectVirtualLabsView> {
int _selectedIndex = 0;
bool _showSpecialized = false;
@override
void didUpdateWidget(covariant SubjectVirtualLabsView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.selectedSemester != widget.selectedSemester) {
AppLogger.event('VirtualLabsSemesterChanged', details: {
'subject': widget.subjectTitle,
'from': oldWidget.selectedSemester,
'to': widget.selectedSemester,
}, tag: 'VIRTUAL_LABS_VIEW');
setState(() {
_selectedIndex = 0;
_showSpecialized = false;
});
}
}
void _openGallery(BuildContext context) {
AppLogger.event('OpenVirtualLabsGallery', details: {
'subject': widget.subjectTitle,
}, tag: 'VIRTUAL_LABS_VIEW');
Navigator.of(context).push(
CupertinoPageRoute(
builder: (_) => Scaffold(
@@ -52,6 +74,12 @@ class _SubjectVirtualLabsViewState extends State<SubjectVirtualLabsView> {
void _triggerCheckpoint(
String q, List<String> opts, int correct, String lessonTitle) {
AppLogger.event('LabCheckpointTriggered', details: {
'lesson': lessonTitle,
'question': q,
'optionsCount': opts.length,
'correctIndex': correct,
}, tag: 'VIRTUAL_LABS_VIEW');
final checkpoint = SocraticCheckpointModel(
id: lessonTitle.hashCode & 0x7fffffff,
questionText: q,
@@ -79,10 +107,24 @@ class _SubjectVirtualLabsViewState extends State<SubjectVirtualLabsView> {
Widget build(BuildContext context) {
final allLabs =
Grade10LabsRegistry.bySubjectNormalized(widget.subjectTitle);
final labs = Grade10LabsRegistry.previewModeEnabled
var candidateLabs = Grade10LabsRegistry.previewModeEnabled
? allLabs
: allLabs.where((e) => e.identity.isPublished).toList();
if (widget.selectedSemester != null && widget.selectedSemester!.isNotEmpty) {
final semesterLabs = candidateLabs.where((e) {
if (e.identity.semesterKey.isNotEmpty) {
return e.identity.semesterKey == widget.selectedSemester;
}
return true; // Keep standalone authoring tools accessible
}).toList();
if (semesterLabs.isNotEmpty) {
candidateLabs = semesterLabs;
}
}
final labs = candidateLabs;
if (labs.isEmpty) {
if (widget.specializedToolWidget != null) {
return widget.specializedToolWidget!;
@@ -228,7 +270,13 @@ class _SubjectVirtualLabsViewState extends State<SubjectVirtualLabsView> {
idx == labs.length) {
final isSel = _showSpecialized;
return InkWell(
onTap: () => setState(() => _showSpecialized = true),
onTap: () {
AppLogger.event('SelectSpecializedTool', details: {
'subject': widget.subjectTitle,
'tool': widget.specializedToolTitle,
}, tag: 'VIRTUAL_LABS_VIEW');
setState(() => _showSpecialized = true);
},
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(
@@ -263,6 +311,13 @@ class _SubjectVirtualLabsViewState extends State<SubjectVirtualLabsView> {
final isSel = !_showSpecialized && activeIndex == idx;
return InkWell(
onTap: () {
AppLogger.event('SelectVirtualLabChip', details: {
'subject': widget.subjectTitle,
'index': idx,
'lab': lab.lessonAr,
'curriculumId': lab.identity.curriculumLessonId,
'semester': lab.identity.semesterKey,
}, tag: 'VIRTUAL_LABS_VIEW');
setState(() {
_showSpecialized = false;
_selectedIndex = idx;
@@ -8,6 +8,7 @@ import Foundation
import device_info_plus
import flutter_secure_storage_macos
import flutter_tts
import path_provider_foundation
import shared_preferences_foundation
import url_launcher_macos
import video_player_avfoundation
@@ -16,6 +17,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin"))
+7
View File
@@ -6,6 +6,9 @@ PODS:
- flutter_tts (0.0.1):
- FlutterMacOS
- FlutterMacOS (1.0.0)
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
@@ -20,6 +23,7 @@ DEPENDENCIES:
- flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`)
- flutter_tts (from `Flutter/ephemeral/.symlinks/plugins/flutter_tts/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
- video_player_avfoundation (from `Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin`)
@@ -33,6 +37,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_tts/macos
FlutterMacOS:
:path: Flutter/ephemeral
path_provider_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
shared_preferences_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
url_launcher_macos:
@@ -45,6 +51,7 @@ SPEC CHECKSUMS:
flutter_secure_storage_macos: 7f45e30f838cf2659862a4e4e3ee1c347c2b3b54
flutter_tts: ae915565cc6948444b513acc8ee021993281e027
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52
+4 -60
View File
@@ -49,14 +49,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.dev"
source: hosted
version: "1.2.1"
collection:
dependency: transitive
description:
@@ -224,14 +216,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.3.3"
hooks:
dependency: transitive
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
html:
dependency: transitive
description:
@@ -328,14 +312,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
@@ -368,14 +344,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e
url: "https://pub.dev"
source: hosted
version: "9.5.0"
package_config:
dependency: transitive
description:
@@ -409,13 +377,13 @@ packages:
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
dependency: "direct overridden"
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
version: "2.4.2"
path_provider_linux:
dependency: transitive
description:
@@ -464,22 +432,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24"
url: "https://pub.dev"
source: hosted
version: "2.2.1"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.dev"
source: hosted
version: "0.6.0"
shared_preferences:
dependency: "direct main"
description:
@@ -749,14 +701,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
url: "https://pub.dev"
source: hosted
version: "3.1.4"
sdks:
dart: ">=3.11.0 <4.0.0"
flutter: ">=3.38.4"
flutter: ">=3.38.0"
+3
View File
@@ -45,6 +45,9 @@ dev_dependencies:
flutter_lints: ^3.0.0
dependency_overrides:
path_provider_foundation: 2.4.2
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
@@ -5,6 +5,8 @@ import 'package:student_app/presentation/screens/virtual_labs/lab_identity.dart'
import 'package:student_app/presentation/screens/virtual_labs/labs_gallery_screen.dart';
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/curriculum/arabic_interactive_lab_view.dart';
import 'package:student_app/presentation/screens/curriculum/english_interactive_lab_view.dart';
void main() {
test('registry covers all Grade-10 labs (48 entries, 13 subjects)', () {
@@ -103,7 +105,7 @@ void main() {
// History lesson 2 must NEVER return physics vector lab
final historyLab = Grade10LabsRegistry.findForLesson(
subjectTitle: 'تاريخ الأردن (Jordan History 10)',
subjectTitle: 'التاريخ (History 10)',
lessonTitle: 'الدرس الثاني: الإمبراطورية الساسانية',
lessonId: 'lesson_02',
);
@@ -119,7 +121,7 @@ void main() {
// History lesson 1 MUST return Persian Empire lab, NOT physics!
final historyUnit1Lab = Grade10LabsRegistry.findForLesson(
subjectTitle: 'تاريخ الأردن (Jordan History 10)',
subjectTitle: 'التاريخ (History 10)',
lessonTitle: 'الدرس الأول: الإمبراطورية الفارسية: النشأة والتطور',
lessonId: 'lesson_01',
curriculumLessonId: 'history_10_semester_1_unit_01_lesson_01',
@@ -824,6 +826,89 @@ void main() {
await t.pump();
expect(checkpointTriggered, isTrue);
});
testWidgets('arabic interactive lab view renders and supports tab navigation',
(t) async {
await t.binding.setSurfaceSize(const Size(1200, 2400));
addTearDown(() => t.binding.setSurfaceSize(null));
await t.pumpWidget(const MaterialApp(
home: Scaffold(
body: ArabicInteractiveLabView(),
),
));
await t.pump();
// Default tab: الصرف والاشتقاق
expect(find.textContaining('مختبر الضاد اللغوي الذكي'), findsWidgets);
expect(find.text('ميزان الصرف والاشتقاق ⚖️'), findsWidgets);
expect(find.text('معمل البلاغة والبيان 💎'), findsWidgets);
expect(find.text('استوديو الإعراب والتراكيب 📜'), findsWidgets);
expect(find.text('العروض وموسيقى الشعر 🎵'), findsWidgets);
// Verify root selector
expect(find.text('ك - ت - ب'), findsWidgets);
expect(find.text('ع - ل - م'), findsWidgets);
await t.tap(find.text('ع - ل - م').first);
await t.pump();
// Switch to Rhetoric tab
await t.tap(find.text('معمل البلاغة والبيان 💎').first);
await t.pumpAndSettle();
expect(find.textContaining('الطباق'), findsWidgets);
// Switch to Syntax tab
await t.tap(find.text('استوديو الإعراب والتراكيب 📜').first);
await t.pumpAndSettle();
expect(find.textContaining('اختر الجملة لتحليل بنيتها الإعرابية'), findsWidgets);
// Switch to Prosody tab
await t.tap(find.text('العروض وموسيقى الشعر 🎵').first);
await t.pumpAndSettle();
expect(find.textContaining('بحر الكامل'), findsWidgets);
});
testWidgets('english interactive lab view renders and supports tab navigation',
(t) async {
await t.binding.setSurfaceSize(const Size(1200, 2400));
addTearDown(() => t.binding.setSurfaceSize(null));
await t.pumpWidget(const MaterialApp(
home: Scaffold(
body: EnglishInteractiveLabView(),
),
));
await t.pump();
// Default tab: Grammar Matrix
expect(find.textContaining('Action Pack 10'), findsWidgets);
expect(find.text('مصفوفة القواعد (Grammar) 📐'), findsWidgets);
expect(find.text('الصوتيات والنبر (Phonetics) 🎙️'), findsWidgets);
expect(find.text('المفردات والمتلازمات 📚'), findsWidgets);
expect(find.text('تحدي الاستماع (Listening) ⚡'), findsWidgets);
// Check grammar tense chips
expect(find.text('Present Simple'), findsWidgets);
expect(find.text('Present Perfect'), findsWidgets);
await t.tap(find.text('Present Perfect').first);
await t.pumpAndSettle();
// Switch to Phonetics tab
await t.tap(find.text('الصوتيات والنبر (Phonetics) 🎙️').first);
await t.pumpAndSettle();
expect(find.textContaining('Minimal Pairs'), findsWidgets);
expect(find.textContaining('Syllable Stress Shift'), findsWidgets);
// Switch to Vocab tab
await t.tap(find.text('المفردات والمتلازمات 📚').first);
await t.pumpAndSettle();
expect(find.textContaining('Thematic Keywords'), findsWidgets);
// Switch to Listening tab
await t.tap(find.text('تحدي الاستماع (Listening) ⚡').first);
await t.pumpAndSettle();
expect(find.textContaining('Gulf of Aqaba'), findsWidgets);
});
}
+6 -6
View File
@@ -340,10 +340,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.18"
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
@@ -356,10 +356,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev"
source: hosted
version: "1.17.0"
version: "1.18.0"
nested:
dependency: transitive
description:
@@ -585,10 +585,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev"
source: hosted
version: "0.7.9"
version: "0.7.11"
typed_data:
dependency: transitive
description:
+3
View File
@@ -43,6 +43,9 @@ dev_dependencies:
flutter_lints: ^3.0.0
dependency_overrides:
path_provider_foundation: 2.4.2
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
File diff suppressed because it is too large Load Diff