Update Saqel Platform: 2026-09-13 16:31:17
This commit is contained in:
@@ -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',
|
||||
|
||||
+992
-731
File diff suppressed because it is too large
Load Diff
+575
@@ -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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+914
-885
File diff suppressed because it is too large
Load Diff
+1158
-296
File diff suppressed because it is too large
Load Diff
+441
-792
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,8 +450,12 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20),
|
||||
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];
|
||||
@@ -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,8 +618,11 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
|
||||
const Icon(CupertinoIcons.lab_flask_solid, color: AppColors.saqelCyan, size: 14),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
'المختبر الافتراضي: ${lab.lessonAr}',
|
||||
'المختبر: ${lab.lessonAr}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11.5,
|
||||
@@ -486,6 +631,31 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
|
||||
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(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
@@ -513,64 +683,34 @@ 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),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.subject.primaryColor.withValues(alpha: 0.1),
|
||||
color: Colors.white.withAlpha(6),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: widget.subject.primaryColor.withValues(alpha: 0.25)),
|
||||
border: Border.all(color: Colors.white.withAlpha(12)),
|
||||
),
|
||||
child: Row(
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(CupertinoIcons.lab_flask_solid, color: widget.subject.primaryColor, size: 14),
|
||||
const SizedBox(width: 6),
|
||||
Icon(CupertinoIcons.lab_flask, color: Colors.white38, size: 13),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'مختبر ${widget.subject.title} التفاعلي المعتمد',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
'المختبر الافتراضي: قيد الإعداد الأكاديمي ⚪',
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||
@@ -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(
|
||||
|
||||
+327
-87
@@ -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,20 +145,41 @@ 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) {
|
||||
);
|
||||
_videoController = controller;
|
||||
|
||||
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) {
|
||||
_videoController!.seekTo(Duration(seconds: resumePos));
|
||||
|
||||
if (resumePos > 3 && resumePos < controller.value.duration.inSeconds) {
|
||||
controller.seekTo(Duration(seconds: resumePos));
|
||||
if (context.mounted) {
|
||||
SaqelToast.showInfo(
|
||||
context,
|
||||
@@ -123,70 +188,59 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
|
||||
);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
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;
|
||||
_videoInitError = 'تعذر تحميل بث الفيديو المباشر؛ انقر لإعادة المحاولة ($err)';
|
||||
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 {
|
||||
|
||||
+498
-191
@@ -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,24 +112,27 @@ 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: [
|
||||
@@ -105,151 +140,229 @@ class _HistoryJordanChronologyLabViewState
|
||||
label: 'المحطة التاريخية',
|
||||
value: era.toDouble(),
|
||||
min: 0,
|
||||
max: 5,
|
||||
max: (milestones.length - 1).toDouble(),
|
||||
display: '${milestones[era]['y']}',
|
||||
onChanged: (v) => setState(() => era = v.round())),
|
||||
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,
|
||||
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],
|
||||
text: eraLabels[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));
|
||||
}
|
||||
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) {
|
||||
// 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(
|
||||
m.extractPath(d, d + 8),
|
||||
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('◀ سابق'),
|
||||
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))),
|
||||
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,
|
||||
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,
|
||||
fontFamily: 'monospace')),
|
||||
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)],
|
||||
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 - 30 - h, bw - 12, h));
|
||||
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)),
|
||||
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
|
||||
// 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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (usePreview || c.identity.isPublished) {
|
||||
AppLogger.event('VirtualLabResolvedByCurriculumId', details: {
|
||||
'subject': subjectTitle,
|
||||
'lesson': lessonTitle,
|
||||
'curriculumId': curriculumLessonId,
|
||||
'lab': c.lessonAr,
|
||||
}, tag: 'LAB_REGISTRY');
|
||||
return c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Lesson Title fuzzy matching strictly within candidate subject labs
|
||||
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 > 4 && (t.contains(ct) || ct.contains(t)))) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Lesson Key / Tool Key match strictly within candidate subject labs
|
||||
// 5. Lesson Key match strictly constrained to matching unit & semester
|
||||
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 (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;
|
||||
}
|
||||
|
||||
if (curriculumLessonId != null && curriculumLessonId.isNotEmpty) {
|
||||
for (final c in candidates) {
|
||||
if (c.identity.curriculumLessonId == curriculumLessonId && c.identity.isPublished) {
|
||||
return c;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+57
-2
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user