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

This commit is contained in:
Hamza-Ayed
2026-09-13 16:31:18 +03:00
parent 81fa8c5dc8
commit 72c6575765
25 changed files with 6997 additions and 3852 deletions
+3
View File
@@ -43,6 +43,9 @@ dev_dependencies:
flutter_lints: ^3.0.0 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 # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # 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({ static void request({
required String method, required String method,
required Uri uri, required Uri uri,
@@ -37,8 +37,18 @@ class SubjectModel {
this.exams = const [], 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) { 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() ?? ''; final englishTitle = json['english_name']?.toString() ?? '';
// Parse units if available // Parse units if available
@@ -148,32 +148,57 @@ class CurriculumRepository {
} }
Future<List<PublishedLessonVideoModel>> getPublishedLessonVideos(String curriculumLessonId) async { 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 res = await _api.get('/api/curriculum/lessons/$curriculumLessonId/videos');
final data = res is Map ? res['data'] : null; final data = res is Map ? res['data'] : null;
final raw = data is Map ? data['items'] : null; final raw = data is Map ? data['items'] : null;
if (raw is! List) return const []; 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 { 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'); 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('فشل جلب تشغيل الحصة المنشورة من الخادم'); throw ApiException('فشل جلب تشغيل الحصة المنشورة من الخادم');
} }
Future<String> startWatchSession(String videoVersionId) async { 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 res = await _api.post('/api/video-versions/$videoVersionId/watch-sessions', body: const {});
final data = res is Map ? res['data'] : null; final data = res is Map ? res['data'] : null;
final source = data is Map ? data : res; final source = data is Map ? data : res;
final id = source is Map ? source['watch_session_id']?.toString() : null; final id = source is Map ? source['watch_session_id']?.toString() : null;
if (id == null || id.isEmpty) throw ApiException('لم ينشئ الخادم جلسة مشاهدة.'); if (id == null || id.isEmpty) throw ApiException('لم ينشئ الخادم جلسة مشاهدة.');
AppLogger.event('StartWatchSessionSuccess', details: {'sessionId': id}, tag: 'CURRICULUM_REPO');
return id; return id;
} }
Future<void> recordWatchEvent(String sessionId, int sequenceNo, String eventType, int positionMs) async { 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}); 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 { 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: { await _api.post('/api/student/lessons/$lessonId/progress', body: {
'position_seconds': positionSeconds, 'position_seconds': positionSeconds,
'watched_seconds': watchedSeconds, 'watched_seconds': watchedSeconds,
@@ -181,6 +206,11 @@ class CurriculumRepository {
} }
Future<void> submitCheckpoint({required int examId, required int questionId, required int optionId}) async { 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: { await _api.post('/api/exams/$examId/submit', body: {
'answers': [ 'answers': [
{'question_id': questionId, 'selected_option_id': optionId} {'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 { 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; _lastObservedPosition = -1;
emit(VideoPlaybackLoading()); emit(VideoPlaybackLoading());
final storageKey = _getLessonStorageKey(lesson); final storageKey = _getLessonStorageKey(lesson);
@@ -102,16 +108,31 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
throw StateError('هذا الدرس غير منشور بعد ضمن حزمة المحتوى المعتمدة.'); throw StateError('هذا الدرس غير منشور بعد ضمن حزمة المحتوى المعتمدة.');
} }
final published = await _repo.getPublishedLessonVideos(versionId); 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('لا توجد حصة منشورة ومصرح بها لهذا الدرس بعد.'); 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) { if (playback.videoUrl.isEmpty) {
throw StateError('لم يربط الخادم فيديو R2 بهذا الدرس بعد.'); throw StateError('لم يربط الخادم فيديو R2 بهذا الدرس بعد.');
} }
_watchSessionId = null; _watchSequence = 1; _lastWatchEventPosition = 0; _watchSessionId = null; _watchSequence = 1; _lastWatchEventPosition = 0;
if (playback.videoVersionId != null && playback.videoVersionId!.isNotEmpty) { 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 // 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(); passedIds = savedPassed.map((s) => int.tryParse(s) ?? 0).where((id) => id > 0).toSet();
} catch (_) {} } catch (_) {}
AppLogger.event('VideoPlaybackStateEmitted', details: {
'resumePosition': resumePos,
'passedCheckpointsCount': passedIds.length,
}, tag: 'VIDEO_CUBIT');
emit(VideoPlaybackReady( emit(VideoPlaybackReady(
playbackData: playback, playbackData: playback,
lessonItem: lesson, lessonItem: lesson,
@@ -176,13 +202,16 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
void togglePlayPause() { void togglePlayPause() {
final currentState = state; final currentState = state;
if (currentState is VideoPlaybackReady && currentState.activeCheckpoint == null) { 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() { void pause() {
final currentState = state; final currentState = state;
if (currentState is VideoPlaybackReady && currentState.isPlaying) { if (currentState is VideoPlaybackReady && currentState.isPlaying) {
AppLogger.event('VideoPaused', tag: 'VIDEO_CUBIT');
emit(currentState.copyWith(isPlaying: false)); emit(currentState.copyWith(isPlaying: false));
} }
} }
@@ -190,6 +219,7 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
void play() { void play() {
final currentState = state; final currentState = state;
if (currentState is VideoPlaybackReady && !currentState.isPlaying && currentState.activeCheckpoint == null) { if (currentState is VideoPlaybackReady && !currentState.isPlaying && currentState.activeCheckpoint == null) {
AppLogger.event('VideoResumed', tag: 'VIDEO_CUBIT');
emit(currentState.copyWith(isPlaying: true)); emit(currentState.copyWith(isPlaying: true));
} }
} }
@@ -211,6 +241,13 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
final actualSeek = blockingCheckpoint != null ? blockingCheckpoint.timestampSeconds : seconds; final actualSeek = blockingCheckpoint != null ? blockingCheckpoint.timestampSeconds : seconds;
_lastObservedPosition = actualSeek; _lastObservedPosition = actualSeek;
AppLogger.event('VideoSeekExecuted', details: {
'targetSeconds': seconds,
'actualSeek': actualSeek,
'blockedByCheckpoint': blockingCheckpoint != null,
'checkpointQuestion': blockingCheckpoint?.questionText,
}, tag: 'VIDEO_CUBIT');
emit(currentState.copyWith( emit(currentState.copyWith(
currentPositionSeconds: actualSeek, currentPositionSeconds: actualSeek,
activeCheckpoint: blockingCheckpoint, activeCheckpoint: blockingCheckpoint,
@@ -252,7 +289,12 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
if (selectedOption.isCorrect) { if (selectedOption.isCorrect) {
// Correct Answer -> Reward readiness score (+0.5%) & resume video // 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); final updatedPassed = Set<int>.from(currentState.passedCheckpointIds)..add(cp.id);
_lastObservedPosition = cp.timestampSeconds; _lastObservedPosition = cp.timestampSeconds;
@@ -279,7 +321,13 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
// Wrong Answer -> Socratic Productive Struggle: Rewind video by N seconds from checkpoint // Wrong Answer -> Socratic Productive Struggle: Rewind video by N seconds from checkpoint
final rewindTo = (cp.timestampSeconds - cp.rewindSecondsOnFail).clamp(0, currentState.playbackData.durationSeconds); final rewindTo = (cp.timestampSeconds - cp.rewindSecondsOnFail).clamp(0, currentState.playbackData.durationSeconds);
_lastObservedPosition = rewindTo; _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( emit(currentState.copyWith(
clearActiveCheckpoint: true, clearActiveCheckpoint: true,
currentPositionSeconds: rewindTo, currentPositionSeconds: rewindTo,
@@ -290,7 +338,7 @@ class VideoPlaybackCubit extends Cubit<VideoPlaybackState> {
// Auto-record gap to Smart Error Notebook // Auto-record gap to Smart Error Notebook
final correctOpt = cp.options.firstWhere( final correctOpt = cp.options.firstWhere(
(o) => o.isCorrect, (o) => o.isCorrect,
orElse: () => SocraticOptionModel(id: 0, text: '', isCorrect: false), orElse: () => const SocraticOptionModel(id: 0, text: '', isCorrect: false),
); );
ErrorNotebookRepository().logError( ErrorNotebookRepository().logError(
subjectId: currentState.subject?.id ?? 'physics_10', subjectId: currentState.subject?.id ?? 'physics_10',
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,575 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart';
/// ==============================================================================
/// SAQEL ENTERPRISE - GRADE 10 DIGITAL SKILLS INTERACTIVE LAB
/// ==============================================================================
/// مختبر المهارات الرقمية التفاعلي المعتمد للصف العاشر:
/// 1. معمل تمثيل البيانات والنظام الثنائي وبكسل الألوان (Binary, ASCII & RGB).
/// 2. معمل تحليل البيانات ومعادلات Excel والرسم البياني المرئي.
/// 3. محاكي إنترنت الأشياء (IoT) والذكاء الاصطناعي وشجرة القرارات.
class DigitalSkillsInteractiveLabView extends StatefulWidget {
const DigitalSkillsInteractiveLabView({super.key});
@override
State<DigitalSkillsInteractiveLabView> createState() =>
_DigitalSkillsInteractiveLabViewState();
}
class _DigitalSkillsInteractiveLabViewState
extends State<DigitalSkillsInteractiveLabView>
with SingleTickerProviderStateMixin {
late TabController _tabController;
// ---------------------------------------------------------------------------
// Tab 1: Binary & RGB Data Representation
// ---------------------------------------------------------------------------
final List<bool> _binaryBits = [false, false, false, false, false, true, false, true]; // 5 in 8-bit
int _redVal = 30;
int _greenVal = 210;
int _blueVal = 180;
int get _decimalValue {
int val = 0;
for (int i = 0; i < 8; i++) {
if (_binaryBits[i]) {
val += (1 << (7 - i));
}
}
return val;
}
String get _hexValue => _decimalValue.toRadixString(16).toUpperCase().padLeft(2, '0');
String get _asciiChar {
final d = _decimalValue;
if (d >= 32 && d <= 126) {
return String.fromCharCode(d);
}
return 'غير قابل للطباعة (Control Code)';
}
// ---------------------------------------------------------------------------
// Tab 2: Excel & Data Analytics
// ---------------------------------------------------------------------------
final List<Map<String, dynamic>> _dataRows = [
{'item': 'عمان', 'val': 85.0},
{'item': 'إربد', 'val': 72.0},
{'item': 'الزرقاء', 'val': 68.0},
{'item': 'العقبة', 'val': 94.0},
{'item': 'البلقاء', 'val': 61.0},
];
double get _calcSum => _dataRows.fold(0.0, (acc, r) => acc + (r['val'] as double));
double get _calcAvg => _dataRows.isNotEmpty ? _calcSum / _dataRows.length : 0.0;
double get _calcMax => _dataRows.fold(0.0, (max, r) => (r['val'] as double) > max ? (r['val'] as double) : max);
double get _calcMin => _dataRows.fold(999.0, (min, r) => (r['val'] as double) < min ? (r['val'] as double) : min);
// ---------------------------------------------------------------------------
// Tab 3: IoT Sensors & AI Decision Tree
// ---------------------------------------------------------------------------
double _temperature = 28.0;
double _humidity = 45.0;
bool _motionDetected = false;
String get _aiDecision {
if (_temperature > 32.0 && _humidity > 60.0) {
return 'تشغيل نظام التكييف الذكي وخفض الرطوبة (تحذير إجهاد حراري)';
} else if (_temperature < 16.0) {
return 'تشغيل نظام التدفئة المركزية والتهوية الآمنة';
} else if (_motionDetected) {
return 'تفعيل الإضاءة الذكية وتسجيل نشاط الكاميرا';
}
return 'البيئة مستقرة ومثالية • الاستهلاك الاقتصادي للطاقة مفعل';
}
Color get _aiStatusColor {
if (_temperature > 32.0 || _temperature < 16.0) {
return const Color(0xFFF59E0B);
}
if (_motionDetected) {
return const Color(0xFF38BDF8);
}
return const Color(0xFF10B981);
}
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Container(
margin: const EdgeInsets.fromLTRB(16, 12, 16, 8),
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: AppColors.darkSurface,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.darkCardBorder),
),
child: TabBar(
controller: _tabController,
indicatorColor: AppColors.saqelCyan,
labelColor: Colors.white,
unselectedLabelColor: AppColors.textSecondaryDark,
indicatorSize: TabBarIndicatorSize.tab,
labelStyle: const TextStyle(fontWeight: FontWeight.w800, fontSize: 11.5),
tabs: const [
Tab(text: 'تمثيل البيانات والثنائي 0101'),
Tab(text: 'تحليل البيانات وExcel 📊'),
Tab(text: 'إنترنت الأشياء والذكاء 🤖'),
],
),
),
Expanded(
child: TabBarView(
controller: _tabController,
children: [
_buildBinaryLab(),
_buildExcelLab(),
_buildIotLab(),
],
),
),
],
);
}
Widget _buildBinaryLab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 8-bit Binary Switcher Card
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.darkSurface,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: AppColors.saqelCyan.withValues(alpha: 0.4)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(CupertinoIcons.chevron_left_slash_chevron_right, color: AppColors.saqelCyan, size: 18),
SizedBox(width: 8),
Text(
'محاكي بايت البيانات الثنائي (8-Bit Binary Byte):',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 13.5),
),
],
),
const SizedBox(height: 12),
const Text(
'انقر على البت لتشغيله (1) أو إطفائه (0) وشاهد تحويل القيمة فورياً:',
style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 12),
),
const SizedBox(height: 14),
// 8 Switches
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(8, (i) {
final weight = 1 << (7 - i);
final isSet = _binaryBits[i];
return GestureDetector(
onTap: () {
setState(() {
_binaryBits[i] = !_binaryBits[i];
});
},
child: Column(
children: [
Text(
'$weight',
style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 10, fontWeight: FontWeight.w700),
),
const SizedBox(height: 6),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 34,
height: 48,
decoration: BoxDecoration(
color: isSet ? AppColors.saqelCyan.withValues(alpha: 0.25) : Colors.black26,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSet ? AppColors.saqelCyan : Colors.white24,
width: isSet ? 2 : 1,
),
),
child: Center(
child: Text(
isSet ? '1' : '0',
style: TextStyle(
color: isSet ? AppColors.saqelCyan : Colors.white38,
fontSize: 18,
fontWeight: FontWeight.w900,
),
),
),
),
],
),
);
}),
),
const SizedBox(height: 16),
// Computed Output Badges
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildResultBadge('العشري (Decimal)', '$_decimalValue'),
_buildResultBadge('الست عشري (Hex)', '0x$_hexValue'),
_buildResultBadge('حرف ASCII', _asciiChar),
],
),
),
],
),
),
const SizedBox(height: 16),
// RGB Pixel Inspector
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.darkSurface,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: Colors.white12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(CupertinoIcons.paintbrush_fill, color: Color(0xFFFF375F), size: 18),
SizedBox(width: 8),
Text(
'مفتش البكسل اللوني وتمثيل الصور (24-bit RGB):',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 13.5),
),
],
),
const SizedBox(height: 12),
Row(
children: [
// Color Preview Box
Container(
width: 70,
height: 70,
decoration: BoxDecoration(
color: Color.fromARGB(255, _redVal, _greenVal, _blueVal),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white24, width: 2),
boxShadow: [
BoxShadow(
color: Color.fromARGB(255, _redVal, _greenVal, _blueVal).withValues(alpha: 0.5),
blurRadius: 14,
),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'RGB($_redVal, $_greenVal, $_blueVal)',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w900, fontSize: 14),
),
const SizedBox(height: 4),
Text(
'حجم البكسل: 3 بايت = 24 بت في الذاكرة (16.7 مليون لون ممكن)',
style: TextStyle(color: Colors.white.withValues(alpha: 0.7), fontSize: 11),
),
],
),
),
],
),
const SizedBox(height: 12),
_buildColorSlider('أحمر (Red R)', _redVal, const Color(0xFFFF453A), (v) => setState(() => _redVal = v.round())),
_buildColorSlider('أخضر (Green G)', _greenVal, const Color(0xFF30D158), (v) => setState(() => _greenVal = v.round())),
_buildColorSlider('أزرق (Blue B)', _blueVal, const Color(0xFF0A84FF), (v) => setState(() => _blueVal = v.round())),
],
),
),
],
),
);
}
Widget _buildExcelLab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Formulas Summary Header
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.darkSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF10B981).withValues(alpha: 0.4)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildStatColumn('دالة المجموع', '=SUM(B2:B6)', _calcSum.toStringAsFixed(0), const Color(0xFF10B981)),
_buildStatColumn('دالة المتوسط', '=AVERAGE(B2:B6)', _calcAvg.toStringAsFixed(1), const Color(0xFF38BDF8)),
_buildStatColumn('أعلى قيمة', '=MAX(B2:B6)', _calcMax.toStringAsFixed(0), const Color(0xFFF59E0B)),
_buildStatColumn('أدنى قيمة', '=MIN(B2:B6)', _calcMin.toStringAsFixed(0), const Color(0xFFEC4899)),
],
),
),
const SizedBox(height: 16),
// Interactive Data Table & Bar Chart
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.darkSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.darkCardBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(CupertinoIcons.table, color: Color(0xFF10B981), size: 18),
SizedBox(width: 8),
Text(
'جدول البيانات والتمثيل المرئي (Data Visualization):',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 13.5),
),
],
),
const SizedBox(height: 14),
..._dataRows.map((row) {
final val = row['val'] as double;
final ratio = (_calcMax > 0) ? (val / _calcMax) : 0.0;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
SizedBox(
width: 60,
child: Text(
row['item'],
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 12.5),
),
),
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: LinearProgressIndicator(
value: ratio,
minHeight: 14,
backgroundColor: Colors.white10,
valueColor: const AlwaysStoppedAnimation(Color(0xFF10B981)),
),
),
),
const SizedBox(width: 12),
Text(
val.toStringAsFixed(0),
style: const TextStyle(color: Color(0xFF10B981), fontWeight: FontWeight.w900, fontSize: 13),
),
],
),
],
),
);
}),
],
),
),
],
),
);
}
Widget _buildIotLab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// IoT Sensor Sliders Card
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.darkSurface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.saqelCyan.withValues(alpha: 0.4)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(CupertinoIcons.radiowaves_right, color: AppColors.saqelCyan, size: 18),
SizedBox(width: 8),
Text(
'محاكي مجسات إنترنت الأشياء (IoT Smart Sensors):',
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 13.5),
),
],
),
const SizedBox(height: 14),
// Temperature Slider
Text(
'حساس درجة الحرارة (DHT22): ${_temperature.toStringAsFixed(1)} °C',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 12.5),
),
Slider(
value: _temperature,
min: 10.0,
max: 45.0,
divisions: 35,
activeColor: const Color(0xFFFF9F0A),
onChanged: (v) => setState(() => _temperature = v),
),
// Humidity Slider
Text(
'حساس الرطوبة النسبية: ${_humidity.toStringAsFixed(0)} %',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 12.5),
),
Slider(
value: _humidity,
min: 20.0,
max: 95.0,
divisions: 75,
activeColor: const Color(0xFF38BDF8),
onChanged: (v) => setState(() => _humidity = v),
),
// Motion Detection Switch
Row(
children: [
const Icon(CupertinoIcons.person_crop_circle_badge_exclam, color: Colors.white70, size: 18),
const SizedBox(width: 8),
const Text('مستشعر الحركة بالأشعة (PIR):', style: TextStyle(color: Colors.white, fontSize: 12.5, fontWeight: FontWeight.w600)),
const Spacer(),
CupertinoSwitch(
value: _motionDetected,
activeTrackColor: AppColors.saqelCyan,
onChanged: (v) => setState(() => _motionDetected = v),
),
],
),
],
),
),
const SizedBox(height: 14),
// AI Decision Output Box
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _aiStatusColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: _aiStatusColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(CupertinoIcons.sparkles, color: _aiStatusColor, size: 20),
const SizedBox(width: 8),
Text(
'قرار الذكاء الاصطناعي (AI Decision Engine):',
style: TextStyle(color: _aiStatusColor, fontWeight: FontWeight.w800, fontSize: 13.5),
),
],
),
const SizedBox(height: 10),
Text(
_aiDecision,
style: const TextStyle(color: Colors.white, fontSize: 13.5, height: 1.45, fontWeight: FontWeight.w700),
),
],
),
),
],
),
);
}
Widget _buildResultBadge(String label, String val) {
return Column(
children: [
Text(label, style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 11)),
const SizedBox(height: 4),
Text(val, style: const TextStyle(color: AppColors.saqelCyan, fontSize: 16, fontWeight: FontWeight.w900)),
],
);
}
Widget _buildColorSlider(String label, int value, Color col, ValueChanged<double> onChanged) {
return Row(
children: [
SizedBox(
width: 90,
child: Text(label, style: TextStyle(color: col, fontSize: 11.5, fontWeight: FontWeight.w700)),
),
Expanded(
child: Slider(
value: value.toDouble(),
min: 0,
max: 255,
activeColor: col,
onChanged: onChanged,
),
),
SizedBox(
width: 32,
child: Text('$value', style: const TextStyle(color: Colors.white, fontSize: 11.5, fontWeight: FontWeight.w700)),
),
],
);
}
Widget _buildStatColumn(String label, String formula, String val, Color col) {
return Column(
children: [
Text(label, style: const TextStyle(color: Colors.white70, fontSize: 10.5, fontWeight: FontWeight.w700)),
const SizedBox(height: 2),
Text(formula, style: TextStyle(color: col, fontSize: 9.5, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text(val, style: TextStyle(color: col, fontSize: 16, fontWeight: FontWeight.w900)),
],
);
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,7 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
import '../../../core/utils/app_logger.dart';
import '../../../core/utils/saqel_toast.dart'; import '../../../core/utils/saqel_toast.dart';
import '../../../data/models/subject_model.dart'; import '../../../data/models/subject_model.dart';
import '../../widgets/luxury_widgets.dart'; import '../../widgets/luxury_widgets.dart';
@@ -30,6 +31,7 @@ import 'islamic_interactive_lab_view.dart';
import 'history_interactive_timeline_view.dart'; import 'history_interactive_timeline_view.dart';
import 'math_interactive_lab_view.dart'; import 'math_interactive_lab_view.dart';
import 'english_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_gallery_screen.dart';
import '../virtual_labs/labs_registry.dart'; import '../virtual_labs/labs_registry.dart';
import '../virtual_labs/subject_virtual_labs_view.dart'; import '../virtual_labs/subject_virtual_labs_view.dart';
@@ -54,6 +56,7 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
late Future<List<ExamModel>> _examsFuture; late Future<List<ExamModel>> _examsFuture;
String _selectedSemester = 'semester_1';
bool get _isMathSubject => bool get _isMathSubject =>
widget.subject.id.contains('math') || 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('جغرافيا')) ||
(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 => bool get _hasLab =>
Grade10LabsRegistry.bySubjectNormalized(widget.subject.title).isNotEmpty || Grade10LabsRegistry.bySubjectNormalized(widget.subject.title).isNotEmpty ||
_isMathSubject || _isMathSubject ||
@@ -111,7 +120,8 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
_isPhysicsSubject || _isPhysicsSubject ||
_isArabicSubject || _isArabicSubject ||
_isIslamicSubject || _isIslamicSubject ||
_isHistorySubject; _isHistorySubject ||
_isDigitalSkillsSubject;
Tab get _dynamicLabTab { Tab get _dynamicLabTab {
final norm = Grade10LabsRegistry.normalizeSubject(widget.subject.title); final norm = Grade10LabsRegistry.normalizeSubject(widget.subject.title);
@@ -133,12 +143,12 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
} else if (_isIslamicSubject || norm == 'التربية الإسلامية') { } else if (_isIslamicSubject || norm == 'التربية الإسلامية') {
return const Tab( return const Tab(
icon: Icon(CupertinoIcons.money_dollar_circle_fill, size: 18), icon: Icon(CupertinoIcons.money_dollar_circle_fill, size: 18),
text: 'حاسبة المواريث والتجويد ⚖️', text: 'معمل المعاملات والتجويد ⚖️',
); );
} else if (_isHistorySubject || norm == 'تاريخ الأردن' || norm == 'التاريخ') { } else if (_isHistorySubject || norm == 'تاريخ الأردن' || norm == 'التاريخ') {
return const Tab( return const Tab(
icon: Icon(CupertinoIcons.time_solid, size: 18), icon: Icon(CupertinoIcons.time_solid, size: 18),
text: 'الخط الزمني الشامل والتاريخ 🏛️', text: 'مختبر التاريخ والخط الزمني 🏛️',
); );
} else if (_isChemistrySubject || norm == 'الكيمياء') { } else if (_isChemistrySubject || norm == 'الكيمياء') {
return const Tab( return const Tab(
@@ -160,10 +170,10 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
icon: Icon(CupertinoIcons.chart_bar_square_fill, size: 18), icon: Icon(CupertinoIcons.chart_bar_square_fill, size: 18),
text: 'مختبر الثقافة المالية والجدوى 📊', text: 'مختبر الثقافة المالية والجدوى 📊',
); );
} else if (norm == 'المهارات الرقمية') { } else if (_isDigitalSkillsSubject || norm == 'المهارات الرقمية') {
return const Tab( return const Tab(
icon: Icon(CupertinoIcons.desktopcomputer, size: 18), icon: Icon(CupertinoIcons.desktopcomputer, size: 18),
text: 'مختبر الخوارزميات والبرمجة 💻', text: 'معمل البيانات والذكاء الاصطناعي 💻',
); );
} else if (norm == 'الجغرافيا') { } else if (norm == 'الجغرافيا') {
return const Tab( return const Tab(
@@ -204,10 +214,13 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
specializedTitle = 'شجرة الإعراب 📜'; specializedTitle = 'شجرة الإعراب 📜';
} else if (_isIslamicSubject || norm == 'التربية الإسلامية') { } else if (_isIslamicSubject || norm == 'التربية الإسلامية') {
specializedTool = const IslamicInteractiveLabView(); specializedTool = const IslamicInteractiveLabView();
specializedTitle = 'حاسبة المواريث ⚖️'; specializedTitle = 'معمل المعاملات والتجويد ⚖️';
} else if (_isHistorySubject || norm == 'تاريخ الأردن' || norm == 'التاريخ') { } else if (_isHistorySubject || norm == 'تاريخ الأردن' || norm == 'التاريخ') {
specializedTool = const HistoryInteractiveTimelineView(); specializedTool = const HistoryInteractiveTimelineView();
specializedTitle = 'الخط الزمني الشامل 🏛️'; specializedTitle = 'الخط الزمني الشامل والتاريخ 🏛️';
} else if (_isDigitalSkillsSubject || norm == 'المهارات الرقمية') {
specializedTool = const DigitalSkillsInteractiveLabView();
specializedTitle = 'معمل البيانات والذكاء 💻';
} else if (_isChemistrySubject || norm == 'الكيمياء') { } else if (_isChemistrySubject || norm == 'الكيمياء') {
specializedTool = const ChemistryInteractiveLabView(); specializedTool = const ChemistryInteractiveLabView();
specializedTitle = 'طيف ذرة بور ⚗️'; specializedTitle = 'طيف ذرة بور ⚗️';
@@ -227,6 +240,7 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
primaryColor: widget.subject.primaryColor, primaryColor: widget.subject.primaryColor,
specializedToolWidget: specializedTool, specializedToolWidget: specializedTool,
specializedToolTitle: specializedTitle, 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 /// Tab 1: Interactive Video Lessons by Unit
Widget _buildLessonsTab(BuildContext context) { 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) { if (units.isEmpty) {
return Center( return Center(
@@ -330,8 +450,12 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
); );
} }
return ListView.builder( return Column(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20), children: [
if (hasMultipleSemesters) _buildSemesterSelector(),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
itemCount: units.length, itemCount: units.length,
itemBuilder: (context, uIdx) { itemBuilder: (context, uIdx) {
final unit = units[uIdx]; final unit = units[uIdx];
@@ -380,6 +504,9 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
lessonTitle: lesson.title, lessonTitle: lesson.title,
lessonId: lesson.id, lessonId: lesson.id,
curriculumLessonId: lesson.curriculumLessonId, curriculumLessonId: lesson.curriculumLessonId,
filePath: lesson.markdownFilePath,
unitKey: unit.id,
semesterKey: _selectedSemester,
); );
return Container( return Container(
margin: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), margin: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
@@ -455,6 +582,13 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
size: 16, size: 16,
), ),
onTap: () { 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); _showLessonVideoSelector(context, lesson);
}, },
), ),
@@ -463,7 +597,15 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
padding: const EdgeInsets.fromLTRB(12, 0, 12, 6), padding: const EdgeInsets.fromLTRB(12, 0, 12, 6),
child: InkWell( child: InkWell(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
onTap: () => Grade10LabsRegistry.openLab(context, lab), onTap: () {
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( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -476,8 +618,11 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
const Icon(CupertinoIcons.lab_flask_solid, color: AppColors.saqelCyan, size: 14), const Icon(CupertinoIcons.lab_flask_solid, color: AppColors.saqelCyan, size: 14),
const SizedBox(width: 6), const SizedBox(width: 6),
Expanded( Expanded(
child: Row(
children: [
Flexible(
child: Text( child: Text(
'المختبر الافتراضي: ${lab.lessonAr}', 'المختبر: ${lab.lessonAr}',
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 11.5, fontSize: 11.5,
@@ -486,6 +631,31 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
overflow: TextOverflow.ellipsis, 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( Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -513,64 +683,34 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
), ),
), ),
) )
else if (_hasLab) else
Padding( Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 6), padding: const EdgeInsets.fromLTRB(12, 0, 12, 6),
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: () {
if (_tabController.length > 1) {
_tabController.animateTo(1);
}
},
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: widget.subject.primaryColor.withValues(alpha: 0.1), color: Colors.white.withAlpha(6),
borderRadius: BorderRadius.circular(8), 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: [ children: [
Icon(CupertinoIcons.lab_flask_solid, color: widget.subject.primaryColor, size: 14), Icon(CupertinoIcons.lab_flask, color: Colors.white38, size: 13),
const SizedBox(width: 6), SizedBox(width: 6),
Expanded( Expanded(
child: Text( child: Text(
'مختبر ${widget.subject.title} التفاعلي المعتمد', 'المختبر الافتراضي: قيد الإعداد الأكاديمي ⚪',
style: const TextStyle( style: TextStyle(
color: Colors.white, color: Colors.white54,
fontSize: 11.5, fontSize: 11,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w600,
), ),
overflow: TextOverflow.ellipsis, 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(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), 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, lessonTitle: lesson.title,
lessonId: lesson.id, lessonId: lesson.id,
curriculumLessonId: lesson.curriculumLessonId, curriculumLessonId: lesson.curriculumLessonId,
filePath: lesson.markdownFilePath,
); );
showModalBottomSheet( showModalBottomSheet(
@@ -19,6 +19,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_theme.dart'; import '../../../core/theme/app_theme.dart';
import '../../../core/utils/app_logger.dart';
import '../../../core/utils/saqel_toast.dart'; import '../../../core/utils/saqel_toast.dart';
import '../../../core/config/app_config.dart'; import '../../../core/config/app_config.dart';
import '../../../core/services/storage_service.dart'; import '../../../core/services/storage_service.dart';
@@ -65,34 +66,77 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
bool _showChalkboardMode = false; bool _showChalkboardMode = false;
final FlutterTts _tts = FlutterTts(); final FlutterTts _tts = FlutterTts();
bool _isSpeakingChalkboard = false; bool _isSpeakingChalkboard = false;
int _chalkboardStep = 0;
bool _isDisposed = false;
bool _isMuted = false;
void _revealVideoControls() { void _revealVideoControls() {
if (!mounted) return; if (!mounted || _isDisposed) return;
setState(() => _showVideoControls = true); setState(() => _showVideoControls = true);
_controlsHideTimer?.cancel(); _controlsHideTimer?.cancel();
_controlsHideTimer = Timer(const Duration(seconds: 3), () { _controlsHideTimer = Timer(const Duration(seconds: 4), () {
if (mounted) setState(() => _showVideoControls = false); 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 { 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) { 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; return;
} }
final token = await StorageService().getToken(); String finalVideoUrl = videoUrl.trim();
String finalVideoUrl = videoUrl;
// IMPORTANT: Check whether the URL points to our own Saqel API or to an external CDN (BunnyCDN, Cloudflare R2). // 1. Resolve relative API endpoints to absolute base URL
// NEVER send the Saqel user JWT Bearer token to external CDNs! CDNs reject unauthorized Bearer headers if (finalVideoUrl.startsWith('/')) {
// with HTTP 403 Forbidden ("Resource is protected by access token"). 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/') || final isOurBackend = finalVideoUrl.contains('/api/videos/') ||
finalVideoUrl.startsWith(AppConfig.baseUrl) || finalVideoUrl.startsWith(AppConfig.baseUrl) ||
finalVideoUrl.contains('localhost') || finalVideoUrl.contains('localhost') ||
finalVideoUrl.contains('127.0.0.1'); 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) { if (isOurBackend && token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token'; headers['Authorization'] = 'Bearer $token';
if (!finalVideoUrl.contains('token=')) { if (!finalVideoUrl.contains('token=')) {
@@ -101,20 +145,41 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
} }
} }
_videoController?.dispose(); AppLogger.log('🚀 [VIDEO_CONNECT] Connecting to real video stream: $finalVideoUrl', tag: 'VIDEO_PLAYER');
_videoController = VideoPlayerController.networkUrl(
// Cleanly stop any existing controller before assigning a new one
_stopAllAudioAndVideo();
final controller = VideoPlayerController.networkUrl(
Uri.parse(finalVideoUrl), Uri.parse(finalVideoUrl),
httpHeaders: headers, httpHeaders: headers,
) );
..initialize().then((_) { _videoController = controller;
if (mounted) {
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(() { setState(() {
_isVideoInitialized = true; _isVideoInitialized = true;
_videoInitError = null; _videoInitError = null;
if (_isMuted) controller.setVolume(0.0);
}); });
_revealVideoControls(); _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) { if (context.mounted) {
SaqelToast.showInfo( SaqelToast.showInfo(
context, 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(() { setState(() {
_isVideoInitialized = false; _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 { Future<void> _speakConcept(String text) async {
if (text.isEmpty) return; if (text.isEmpty || _isDisposed || _isMuted) return;
setState(() => _isSpeakingChalkboard = true); setState(() => _isSpeakingChalkboard = true);
try { try {
await _tts.setLanguage('ar'); await _tts.setLanguage('ar');
await _tts.setSpeechRate(0.48); await _tts.setSpeechRate(0.48);
_tts.setCompletionHandler(() { _tts.setCompletionHandler(() {
if (mounted) setState(() => _isSpeakingChalkboard = false); if (mounted && !_isDisposed) setState(() => _isSpeakingChalkboard = false);
}); });
await _tts.speak(text); await _tts.speak(text);
} catch (_) { } catch (_) {
if (mounted) setState(() => _isSpeakingChalkboard = false); if (mounted && !_isDisposed) setState(() => _isSpeakingChalkboard = false);
} }
} }
@override @override
void initState() { void initState() {
super.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); context.read<VideoPlaybackCubit>().loadLesson(widget.lesson, subject: widget.subject, selectedVideoVersionId: widget.selectedVideoVersionId);
// Dynamic Floating Forensic Anti-Piracy Watermark Animation // 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. // Keep the UI clock responsive, but sync progress to the API every 15s.
_playbackTicker = Timer.periodic(const Duration(seconds: 1), (timer) { _playbackTicker = Timer.periodic(const Duration(seconds: 1), (timer) {
if (_isDisposed || !mounted) {
timer.cancel();
return;
}
final cubit = context.read<VideoPlaybackCubit>(); final cubit = context.read<VideoPlaybackCubit>();
final state = cubit.state; 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; final position = _videoController!.value.position.inSeconds;
if (state.activeCheckpoint == null) cubit.updatePosition(position); if (state.activeCheckpoint == null) cubit.updatePosition(position);
final duration = _videoController!.value.duration.inSeconds; 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 @override
void dispose() { void dispose() {
AppLogger.log('🧹 [SCREEN_DISPOSE] Disposing video player screen resources.', tag: 'VIDEO_LIFECYCLE');
_isDisposed = true;
context.read<VideoPlaybackCubit>().endWatchSession(); context.read<VideoPlaybackCubit>().endWatchSession();
_tts.stop(); _stopAllAudioAndVideo();
_playbackTicker?.cancel(); _playbackTicker?.cancel();
_controlsHideTimer?.cancel(); _controlsHideTimer?.cancel();
_watermarkController.dispose(); _watermarkController.dispose();
_videoController?.dispose();
super.dispose(); super.dispose();
} }
@@ -252,6 +323,21 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
), ),
centerTitle: false, centerTitle: false,
actions: [ 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( Container(
margin: const EdgeInsets.symmetric(vertical: 10, horizontal: 14), margin: const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
@@ -380,13 +466,16 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
// Video Background Scene // Video Background Scene
Stack( Stack(
children: [ children: [
if (_videoController != null && _isVideoInitialized) if (_videoController != null &&
_isVideoInitialized &&
_videoController!.value.isInitialized &&
!_videoController!.value.hasError)
Positioned.fill( Positioned.fill(
child: FittedBox( child: Center(
fit: BoxFit.cover, child: AspectRatio(
child: SizedBox( aspectRatio: _videoController!.value.aspectRatio > 0
width: _videoController!.value.size.width, ? _videoController!.value.aspectRatio
height: _videoController!.value.size.height, : 16 / 9,
child: VideoPlayer(_videoController!), child: VideoPlayer(_videoController!),
), ),
), ),
@@ -450,17 +539,45 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
const SizedBox(height: 14), const SizedBox(height: 14),
Wrap( Wrap(
alignment: WrapAlignment.center, alignment: WrapAlignment.center,
spacing: 10, spacing: 12,
runSpacing: 10, runSpacing: 10,
children: [ children: [
ElevatedButton.icon( ElevatedButton.icon(
icon: const Icon(CupertinoIcons.arrow_clockwise, size: 16), icon: const Icon(CupertinoIcons.mic_fill, size: 16, color: Colors.black),
label: const Text('إعادة محاولة البث 🎥'), label: const Text('بدء الشرح على السبورة الذكية 🎙️', style: TextStyle(color: Colors.black, fontWeight: FontWeight.w800, fontSize: 13)),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.saqelCyan, backgroundColor: AppColors.saqelCyan,
foregroundColor: Colors.black, foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(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: () { onPressed: () {
setState(() { 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), const SizedBox(height: 16),
Wrap( Wrap(
alignment: WrapAlignment.center, 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 { class _GridPatternPainter extends CustomPainter {
@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -28,51 +29,82 @@ class HistoryJordanChronologyLabView extends StatefulWidget {
class _HistoryJordanChronologyLabViewState class _HistoryJordanChronologyLabViewState
extends State<HistoryJordanChronologyLabView> { extends State<HistoryJordanChronologyLabView> {
int era = 2; int era = 0;
double progress = 1.0; double progress = 1.0;
// المحطات التاريخية الرسمية لمنهاج الصف العاشر (الوحدات 1-4)
static const milestones = [ static const milestones = [
{ {
'y': '1916', 'y': '550 ق.م',
't': 'الثورة العربية الكبرى', 't': 'تأسيس الدولة الأخمينية الفارسية',
'unit': 'الوحدة الأولى: الإمبراطورية الفارسية',
'd': 'd':
'انطلاق الثورة بقيادة الشريف الحسين بن علي ضد الحكم العثماني؛ تشكل الوعي العربي الحديث وخط سير الجيوش نحو الشام.', 'تأسيس الملك كورش الأكبر للإمبراطورية الأخمينية الفارسية، وتوحيد الميديين والفرس، وشق الطريق الملكي (2700 كم) من ساردس إلى سوسة وبرسبوليس.',
'c': Color(0xFFFF9F0A) 'c': Color(0xFFFFD60A),
'icon': '🏛️',
}, },
{ {
'y': '1921', 'y': '224 م',
't': 'تأسيس إمارة شرق الأردن', 't': 'تأسيس الدولة الساسانية (معركة هرمزجان)',
'unit': 'الوحدة الأولى: الإمبراطورية الفارسية',
'd': 'd':
'وصول الأمير عبد الله الأول إلى عمان (2 آذار) وتشكيل أول حكومة (11 نيسان) برئاسة رشيد طليع.', 'تتويج أردشير الأول شاهنشاهاً بعد معركة هرمزجان (224م) وإسقاط البارثيين، وإعلان طيسفون (المدائن) عاصمة جديدة وبناء إيوان كسرى ومجمع جنديسابور.',
'c': Color(0xFF8B5CF6) 'c': Color(0xFFFF9F0A),
'icon': '👑',
}, },
{ {
'y': '1928', 'y': '651 م',
't': 'القانون الأساسي والمعاهدة', 't': 'معركة نهاوند ونهاية الساسانيين',
'unit': 'الوحدة الأولى: الإمبراطورية الفارسية',
'd': 'd':
'أول وثيقة دستورية للإمارة (القانون الأساسي) وتنظيم العلاقة مع بريطانيا والمؤتمر الوطني الأول.', 'معركة نهاوند (فتح الفتوح - 642م/651م) وسقوط يزدجرد الثالث، ودخول بلاد فارس بالكامل تحت راية الحضارة الإسلامية.',
'c': Color(0xFF60A5FA) 'c': Color(0xFF30D158),
'icon': '⚔️',
}, },
{ {
'y': '1946', 'y': '1299 م',
't': 'الاستقلال 25 أيار', 't': 'تأسيس الدولة العثمانية',
'unit': 'الوحدة الثانية: الدولة العثمانية',
'd': 'd':
'إعلان الاستقلال التام ومبايعة الملك المؤسس ملكاً دستورياً وتحول الاسم إلى المملكة الأردنية الهاشمية.', 'تأسيس الأمير عثمان بن أرطغرل للإمارة العثمانية في سوغوت شمال غرب الأناضول، وتوسعها التدريجي في آسيا الصغرى والبلقان.',
'c': Color(0xFF30D158) 'c': Color(0xFF60A5FA),
'icon': '🏹',
}, },
{ {
'y': '1952', 'y': '1453 م',
't': 'دستور 1952', 't': 'فتح القسطنطينية (إسطنبول)',
'unit': 'الوحدة الثانية: الدولة العثمانية',
'd': 'd':
'صدور الدستور الأردني الحالي في عهد الملك طلال؛ ترسيخ الملكية الدستورية والحقوق والحريات.', 'السلطان محمد الثاني (الفاتح) يفتح القسطنطينية ويُنهي الإمبراطورية البيزنطية ويجعلها عاصمة عثمانية، مستخدماً مدافع أوربان ونقل السفن براً.',
'c': Color(0xFF00F5D4) 'c': Color(0xFF8B5CF6),
'icon': '🏰',
}, },
{ {
'y': '1994', 'y': '1760 م',
't': 'معاهدة وادي عربة', 't': 'انطلاق الثورة الصناعية',
'unit': 'الوحدة الثالثة: ثورات غيّرت العالم',
'd': '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) { Widget build(BuildContext context) {
final m = milestones[era]; final m = milestones[era];
return SaqelLabScaffold( return SaqelLabScaffold(
titleAr: 'التسلسل الزمني لتاريخ الأردن', titleAr: 'الخط الزمني الشامل لحضارات العالم — الصف العاشر',
subtitleAr: 'اسحب المؤشر عبر المحطات — خريطة طريق + بطاقة الحدث', subtitleAr:
'تسلسل منهجي يربط بين: الإمبراطورية الفارسية • الدولة العثمانية • الثورات الكبرى • الشخصيات المؤثرة',
identity: kHistoryJordanChronologyToolIdentity, identity: kHistoryJordanChronologyToolIdentity,
onCheckpointTriggered: widget.onCheckpointTriggered, onCheckpointTriggered: widget.onCheckpointTriggered,
checkpointQuestion: checkpointQuestion:
'اليوم الوطني لاستقلال المملكة الأردنية الهاشمية هو …', 'المعركة الفاصلة التي خاضها أردشير الأول وأسفرت عن تأسيس الدولة الساسانية عام 224م هي …',
checkpointOptions: const [ checkpointOptions: const [
'25 أيار 1946', 'معركة هرمزجان',
'2 آذار 1921', 'معركة نهاوند',
'16 نيسان 1928', 'معركة الريدانية',
'10 حزيران 1916' 'معركة القادسية'
], ],
checkpointCorrectIdx: 0, checkpointCorrectIdx: 0,
telemetry: [ 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( canvas: CustomPaint(
painter: _ChronoPainter(index: era, progress: progress), painter: _WorldCivilizationsChronoPainter(index: era, milestones: milestones),
child: Container(), child: Container(),
), ),
controls: [ controls: [
@@ -105,151 +140,229 @@ class _HistoryJordanChronologyLabViewState
label: 'المحطة التاريخية', label: 'المحطة التاريخية',
value: era.toDouble(), value: era.toDouble(),
min: 0, min: 0,
max: 5, max: (milestones.length - 1).toDouble(),
display: '${milestones[era]['y']}', display: '${milestones[era]['y']}',
onChanged: (v) => setState(() => era = v.round())), onChanged: (v) => setState(() => era = v.round()),
),
const SizedBox(height: 6), const SizedBox(height: 6),
SizedBox( SizedBox(
height: 40, height: 42,
child: ListView.separated( child: ListView.separated(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
itemCount: milestones.length, itemCount: milestones.length,
separatorBuilder: (_, __) => const SizedBox(width: 6), separatorBuilder: (_, __) => const SizedBox(width: 6),
itemBuilder: (ctx, i) { itemBuilder: (ctx, i) {
final sel = i == era; final sel = i == era;
final item = milestones[i];
return GestureDetector( return GestureDetector(
onTap: () => setState(() => era = i), onTap: () => setState(() => era = i),
child: Container( child: Container(
padding: padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: sel color: sel
? (milestones[i]['c'] as Color) ? (item['c'] as Color)
: Colors.white.withValues(alpha: 0.06), : Colors.white.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
border: Border.all(
color: sel ? Colors.white : Colors.white12,
width: sel ? 1.4 : 1.0,
),
), ),
alignment: Alignment.center, 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( style: TextStyle(
color: sel ? Colors.black : Colors.white70, color: sel ? Colors.black : Colors.white70,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
fontSize: 12)), fontSize: 11.5,
),
),
],
),
), ),
); );
}, },
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 10),
LabFormulaCard( LabFormulaCard(
title: '${m['y']} — ${m['t']}', title: '${m['icon']} ${m['y']} — ${m['t']}',
body: m['d'] as String, body: '${m['d']}\n\n• المنهج الوزاري: ${m['unit']}',
accent: m['c'] as Color, accent: m['c'] as Color,
), ),
], ],
footerNote: footerNote:
'عرض صفي موجز؛ التفاصيل الدقيقة والوثائق من الكتاب المدرسي المقرر.', 'منهاج وزارة التربية والتعليم الأردنية • كتاب التاريخ للصف العاشر • الوحدات الأربع المقررة.',
); );
} }
} }
class _ChronoPainter extends CustomPainter { class _WorldCivilizationsChronoPainter extends CustomPainter {
final int index; final int index;
final double progress; final List<Map<String, dynamic>> milestones;
_ChronoPainter({required this.index, required this.progress});
_WorldCivilizationsChronoPainter({
required this.index,
required this.milestones,
});
@override @override
void paint(Canvas c, Size s) { void paint(Canvas c, Size s) {
c.drawRect(Offset.zero & s, Paint()..color = const Color(0xFF0A1220)); final w = s.width;
// simplified Jordan outline (schematic polygon, clearly stylised) final h = s.height;
final mapRect = Rect.fromLTWH(18, 18, s.width * 0.40, s.height - 90);
c.drawRRect(RRect.fromRectAndRadius(mapRect, const Radius.circular(14)), // Dark parchment starry background
Paint()..color = const Color(0xFF0F2036)); final bgShader = const RadialGradient(
final poly = [ center: Alignment(0.0, -0.3),
const Offset(0.55, 0.08), colors: [Color(0xFF0F1B2C), Color(0xFF060B12)],
const Offset(0.80, 0.18), radius: 1.0,
const Offset(0.86, 0.42), ).createShader(Rect.fromLTWH(0, 0, w, h));
const Offset(0.70, 0.62), c.drawRect(Rect.fromLTWH(0, 0, w, h), Paint()..shader = bgShader);
const Offset(0.72, 0.88),
const Offset(0.40, 0.92), // Subtle historical coordinate lines
const Offset(0.22, 0.66), final gridPaint = Paint()
const Offset(0.30, 0.34), ..color = Colors.white.withValues(alpha: 0.03)
] ..strokeWidth = 1.0;
.map((e) => Offset(mapRect.left + e.dx * mapRect.width, for (double x = 0; x < w; x += 35) {
mapRect.top + e.dy * mapRect.height)) c.drawLine(Offset(x, 0), Offset(x, h), gridPaint);
.toList(); }
c.drawPath(Path()..addPolygon(poly, true),
Paint()..color = const Color(0xFF1E3A5F)); // 4 Era Color Zones (أشرطة العصور التاريخية الأربعة)
// revolt route dashed final eraWidth = w / 4;
final route = Path() final eraLabels = [
..moveTo(mapRect.left + mapRect.width * 0.72, 'العصر الفارسي القديم\n(550 ق.م - 651 م)',
mapRect.top + mapRect.height * 0.88) 'العصر العثماني والفتوح\n(1299 م - 1923 م)',
..quadraticBezierTo( 'عصر الثورات الكبرى\n(1760 م - 1916 م)',
mapRect.left + mapRect.width * 0.5, 'بناء الدولة الحديثة\n(1805 م - 1938 م)',
mapRect.top + mapRect.height * 0.5, ];
mapRect.left + mapRect.width * 0.6, final eraColors = [
mapRect.top + mapRect.height * 0.15); const Color(0xFFFFD60A),
_dashed(c, route, const Color(0xFFFF9F0A)); const Color(0xFF60A5FA),
final dotT = (index / 5).clamp(0.0, 1.0); const Color(0xFFFF375F),
final dotPos = _pointOn(route, dotT, mapRect); const Color(0xFF10B981),
c.drawCircle(dotPos, 7, Paint()..color = const Color(0xFFFF375F)); ];
c.drawCircle(
dotPos, for (int i = 0; i < 4; i++) {
7, final x = i * eraWidth;
Paint() final zonePaint = Paint()
..color = Colors.white ..color = eraColors[i].withValues(alpha: 0.04)
..style = PaintingStyle.stroke ..style = PaintingStyle.fill;
..strokeWidth = 1.6); c.drawRect(Rect.fromLTWH(x, 10, eraWidth - 2, h - 30), zonePaint);
// timeline rail (right side)
final rx = s.width * 0.52, ry0 = 30.0, ry1 = s.height - 40; final dividerPaint = Paint()
c.drawLine( ..color = Colors.white.withValues(alpha: 0.08)
Offset(rx, ry0), ..strokeWidth = 1.0;
Offset(rx, ry1), c.drawLine(Offset(x + eraWidth - 2, 10), Offset(x + eraWidth - 2, h - 20), dividerPaint);
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 tp = TextPainter( final tp = TextPainter(
text: TextSpan( text: TextSpan(
text: ['1916', '1921', '1928', '1946', '1952', '1994'][i], text: eraLabels[i],
style: TextStyle( style: TextStyle(
color: sel ? Colors.white : Colors.white54, color: eraColors[i].withValues(alpha: 0.7),
fontSize: 11, fontSize: 9.0,
fontWeight: FontWeight.w800)), fontWeight: FontWeight.w700,
textDirection: TextDirection.ltr, ),
)..layout(); ),
tp.paint(c, Offset(rx + 18, y - 8)); 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) { // Interactive Flowing S-Curve Timeline Rail (خط زمني انسيابي ممتد)
final metrics = p.computeMetrics().toList(); final railPath = Path();
for (final m in metrics) { final count = milestones.length;
double d = 0; final points = <Offset>[];
while (d < m.length) {
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( 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() Paint()
..color = col ..color = col
..strokeWidth = 2.4); ..style = PaintingStyle.stroke
d += 14; ..strokeWidth = 2.0);
}
}
} }
Offset _pointOn(Path route, double t, Rect mapRect) { // Station node
// approximate: lerp along bounding diagonal of route (classroom schematic) c.drawCircle(pt, isSel ? 7.5 : 5.0, Paint()..color = isSel ? Colors.white : col);
return Offset(
mapRect.left + mapRect.width * (0.72 - 0.12 * t), // Label (Year + Icon)
mapRect.top + mapRect.height * (0.88 - 0.73 * t), 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 @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 { class DigitalSortingLabView extends StatefulWidget {
final LabCheckpointCallback? onCheckpointTriggered; final LabCheckpointCallback? onCheckpointTriggered;
@@ -474,52 +587,91 @@ class DigitalSortingLabView extends StatefulWidget {
class _DigitalSortingLabViewState extends State<DigitalSortingLabView> class _DigitalSortingLabViewState extends State<DigitalSortingLabView>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
List<int> arr = [7, 2, 9, 4, 1, 6]; 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; int stepIdx = 0;
List<List<int>> snaps = []; List<List<int>> snaps = [];
List<String> notes = []; List<String> notes = [];
bool playing = false; List<List<int>> activeIndices = []; // [index1, index2, isSwapped]
late final AnimationController ctl; bool isPlaying = false;
// ignore: unused_field
Timer? _autoPlayTimer;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_buildTrace(); _buildTrace();
ctl = AnimationController(
vsync: this, duration: const Duration(milliseconds: 600))
..addListener(() {
if (!playing) return;
});
} }
void _buildTrace() { void _buildTrace() {
snaps = [List.of(arr)]; snaps = [List.of(arr)];
notes = ['البداية: ${arr.join(' ')}']; notes = ['المصفوفة الأولية قبل البدء: [${arr.join(', ')}]'];
activeIndices = [[-1, -1, 0]];
if (algo == 0) { if (algo == 0) {
// Bubble Sort with explicit comparison & swap frames
final a = List<int>.of(arr); final a = List<int>.of(arr);
for (int i = 0; i < a.length; i++) { final n = a.length;
for (int j = 0; j < a.length - 1 - i; j++) { 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]) { if (a[j] > a[j + 1]) {
final t = a[j]; final t = a[j];
a[j] = a[j + 1]; a[j] = a[j + 1];
a[j + 1] = t; a[j + 1] = t;
// Swap frame
snaps.add(List.of(a)); 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 { } else {
// quicksort Lomuto trace (simplified, honest label) // Quicksort Lomuto trace
final a = List<int>.of(arr); final a = List<int>.of(arr);
void qs(int lo, int hi) { void qs(int lo, int hi) {
if (lo >= hi) return; if (lo >= hi) return;
final pivot = a[hi]; final pivot = a[hi];
int i = lo; int i = lo;
for (int j = lo; j < hi; j++) { 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) { if (a[j] < pivot) {
final t = a[i]; final t = a[i];
a[i] = a[j]; a[i] = a[j];
a[j] = t; a[j] = t;
if (i != j) {
snaps.add(List.of(a));
notes.add('وضع العنصر الأصغر من المحور في القسم الأيسر: تبديل [$i] مع [$j]');
activeIndices.add([i, j, 1]);
}
i++; i++;
} }
} }
@@ -527,49 +679,90 @@ class _DigitalSortingLabViewState extends State<DigitalSortingLabView>
a[i] = a[hi]; a[i] = a[hi];
a[hi] = t; a[hi] = t;
snaps.add(List.of(a)); snaps.add(List.of(a));
notes.add('محور $pivot → ${a.join(' ')}'); notes.add('تثبيت المحور $pivot في موقعه النهائي عند الفهرس [$i]');
activeIndices.add([i, hi, 1]);
qs(lo, i - 1); qs(lo, i - 1);
qs(i + 1, hi); qs(i + 1, hi);
} }
qs(0, a.length - 1); qs(0, a.length - 1);
} }
stepIdx = 0; 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 @override
void dispose() { void dispose() {
ctl.dispose(); _autoPlayTimer?.cancel();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cur = snaps.isEmpty ? arr : snaps[stepIdx.clamp(0, snaps.length - 1)]; 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); final sorted = _isSorted(cur);
return SaqelLabScaffold( return SaqelLabScaffold(
titleAr: 'ملعب المصفوفات والفرز', titleAr: 'معمل المصفوفات وخوارزميات الفرز التفاعلية',
subtitleAr: 'قارن فقاعي O(n²) مع سريع O(n log n) خطوة بخطوة', subtitleAr:
'تتبع بصري حي مع إبراز مؤشرات المقارنة والتبديل وفهارس المصفوفة',
identity: kDigitalSortingToolIdentity, identity: kDigitalSortingToolIdentity,
onCheckpointTriggered: widget.onCheckpointTriggered, onCheckpointTriggered: widget.onCheckpointTriggered,
checkpointQuestion: 'التعقيد الزمني الأسوأ للفرز الفقاعي (n عناصر) هو …', checkpointQuestion: 'التعقيد الزمني الأسوأ للفرز الفقاعي (Bubble Sort) لمصفوفة حجمها n هو …',
checkpointOptions: const ['O(n²)', 'O(n)', 'O(log n)', 'O(1)'], checkpointOptions: const ['O(n²)', 'O(n)', 'O(log n)', 'O(1)'],
checkpointCorrectIdx: 0, checkpointCorrectIdx: 0,
telemetry: [ telemetry: [
LabPill(algo == 0 ? 'Bubble O(n²)' : 'Quick O(n log n)'), LabPill(
LabPill('خطوة ${stepIdx + 1}/${snaps.length}'), algo == 0
if (sorted) const LabPill('مرتبة ✓', color: Color(0xFF30D158)), ? 'فقاعي 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( 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(), child: Container(),
), ),
controls: [ controls: [
LabSegments<int>( LabSegments<int>(
labels: const ['فقاعي', 'سريع'], labels: const ['فقاعي (Bubble)', 'اختيار (Selection)', 'سريع (Quick)'],
values: const [0, 1], values: const [0, 1, 2],
current: algo, current: algo,
onSelected: (v) => setState(() { onSelected: (v) => setState(() {
_autoPlayTimer?.cancel();
isPlaying = false;
algo = v; algo = v;
_buildTrace(); _buildTrace();
}), }),
@@ -578,63 +771,115 @@ class _DigitalSortingLabViewState extends State<DigitalSortingLabView>
Row( Row(
children: [ children: [
Expanded( Expanded(
child: OutlinedButton( child: OutlinedButton.icon(
onPressed: stepIdx > 0 ? () => setState(() => stepIdx--) : null, onPressed: stepIdx > 0
? () {
_autoPlayTimer?.cancel();
setState(() {
isPlaying = false;
stepIdx--;
});
}
: null,
icon: const Icon(CupertinoIcons.chevron_right, size: 14),
label: const Text('السابق'),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: Colors.white, foregroundColor: Colors.white,
side: const BorderSide(color: Colors.white24)), side: const BorderSide(color: Colors.white24),
child: const Text('◀ سابق'), ),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: ElevatedButton( child: ElevatedButton.icon(
onPressed: stepIdx < snaps.length - 1 onPressed: _toggleAutoPlay,
? () => setState(() => stepIdx++) icon: Icon(isPlaying ? CupertinoIcons.pause_fill : CupertinoIcons.play_arrow_solid, size: 15),
: null, label: Text(isPlaying ? 'إيقاف مؤقت' : 'تشغيل تلقائي ⏯️'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.saqelCyan, backgroundColor: isPlaying ? const Color(0xFFFF9F0A) : AppColors.saqelCyan,
foregroundColor: Colors.black), foregroundColor: Colors.black,
child: const Text('التالي ▶', textStyle: const TextStyle(fontWeight: FontWeight.w800),
style: 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( SizedBox(
width: double.infinity, width: double.infinity,
child: OutlinedButton.icon( child: OutlinedButton.icon(
icon: const Icon(CupertinoIcons.shuffle, size: 15), icon: const Icon(CupertinoIcons.shuffle, size: 15),
label: const Text('خلط المصفوفة'), label: const Text('توليد مصفوفة عشوائية وخلط العناصر 🎲'),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFFFD166), foregroundColor: const Color(0xFFFFD166),
side: const BorderSide(color: Color(0xFFFFD166))), side: const BorderSide(color: Color(0xFFFFD166)),
),
onPressed: () => setState(() { onPressed: () => setState(() {
_autoPlayTimer?.cancel();
isPlaying = false;
arr.shuffle(math.Random()); arr.shuffle(math.Random());
_buildTrace(); _buildTrace();
}), }),
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Directionality( Container(
textDirection: TextDirection.ltr, 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( child: Text(
notes.isEmpty ? '' : notes[stepIdx.clamp(0, notes.length - 1)], notes.isEmpty ? '' : notes[stepIdx.clamp(0, notes.length - 1)],
style: const TextStyle( style: const TextStyle(
color: Color(0xFF00F5D4), color: Color(0xFF00F5D4),
fontSize: 12, fontSize: 12,
fontFamily: 'monospace')), fontWeight: FontWeight.w700,
), ),
const SizedBox(height: 4), ),
),
],
),
),
const SizedBox(height: 6),
const LabFormulaCard( const LabFormulaCard(
title: 'لماذا السريع أسرع؟', title: 'دليل الخوارزميات — الصف العاشر',
body: body:
'الفقاعي يقارن كل زوج مجاور (تربيعي). السريع يقسم حول محور ويفرز كل قسم (لوغاريتمي) — لاحظ قفزات المحور في التتبع.', '• الفرز الفقاعي (Bubble): يقارن كل زوج متجاور ويبدلهما. تعقيد زمني O(n²).\n'
'• فرز الاختيار (Selection): يختار أصغر عنصر وينقله لموقعه النهائي. تعقيد زمني O(n²).\n'
'• الفرز السريع (Quick): يقسم المصفوفة حول محور (Pivot) ويفرز كل قسم لوغاريتمياً. تعقيد O(n log n).',
accent: AppColors.saqelCyan,
), ),
], ],
footerNote: footerNote:
'تتبع تعليمي لمدخلات صغيرة؛ قياس الأداء الحقيقي يحتاج n كبيرة وتوقيتاً فعلياً.', 'منهاج وزارة التربية والتعليم الأردنية • المهارات الرقمية للصف العاشر • وحدة الخوارزميات والبرمجة.',
); );
} }
@@ -646,9 +891,20 @@ class _DigitalSortingLabViewState extends State<DigitalSortingLabView>
} }
} }
class _BarsPainter extends CustomPainter { class _EnhancedBarsPainter extends CustomPainter {
final List<int> values; 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 @override
void paint(Canvas c, Size s) { void paint(Canvas c, Size s) {
@@ -656,31 +912,82 @@ class _BarsPainter extends CustomPainter {
if (values.isEmpty) return; if (values.isEmpty) return;
final mx = values.reduce(math.max).toDouble(); final mx = values.reduce(math.max).toDouble();
final bw = s.width / values.length; final bw = s.width / values.length;
for (int i = 0; i < values.length; i++) { 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 x = i * bw + 6;
final grad = const LinearGradient( final isComp = i == activeIdx1 || i == activeIdx2;
colors: [Color(0xFF00F5D4), Color(0xFF0071E3)],
// 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, begin: Alignment.bottomCenter,
end: Alignment.topCenter) end: Alignment.topCenter,
.createShader(Rect.fromLTWH(x, s.height - 30 - h, bw - 12, h)); ).createShader(Rect.fromLTWH(x, s.height - 40 - h, bw - 12, h));
c.drawRRect( c.drawRRect(
RRect.fromLTRBR(x, s.height - 30 - h, x + bw - 12, s.height - 30, RRect.fromLTRBR(
const Radius.circular(7)), x,
Paint()..shader = grad); s.height - 40 - h,
x + bw - 12,
s.height - 40,
const Radius.circular(8),
),
Paint()..shader = grad,
);
// Top value text
final tp = TextPainter( final tp = TextPainter(
text: TextSpan( text: TextSpan(
text: '${values[i]}', text: '${values[i]}',
style: const TextStyle( style: TextStyle(
color: Colors.white, color: isComp ? Colors.white : Colors.white70,
fontSize: 13, fontSize: isComp ? 14 : 12.5,
fontWeight: FontWeight.w900)), fontWeight: FontWeight.w900,
),
),
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
)..layout(); )..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 @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( const LabIdentity kHistoryJordanChronologyToolIdentity = LabIdentity(
toolKey: 'history_jordan_chronology', toolKey: 'history_jordan_chronology',
subjectAr: 'التاريخ', subjectAr: 'التاريخ',
lessonAr: 'التسلسل الزمني التفاعلي', lessonAr: 'التسلسل الزمني للحضارات (العاشر)',
); );
const LabIdentity kDigitalFlowchartToolIdentity = LabIdentity( const LabIdentity kDigitalFlowchartToolIdentity = LabIdentity(
@@ -1,5 +1,6 @@
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../core/utils/app_logger.dart';
import '../../../data/models/socratic_checkpoint_model.dart'; import '../../../data/models/socratic_checkpoint_model.dart';
import '../../widgets/socratic_dialog.dart'; import '../../widgets/socratic_dialog.dart';
import 'lab_identity.dart'; import 'lab_identity.dart';
@@ -382,6 +383,9 @@ class Grade10LabsRegistry {
required String lessonTitle, required String lessonTitle,
String? lessonId, String? lessonId,
String? curriculumLessonId, String? curriculumLessonId,
String? filePath,
String? unitKey,
String? semesterKey,
bool? allowPreview, bool? allowPreview,
}) { }) {
final usePreview = allowPreview ?? previewModeEnabled; final usePreview = allowPreview ?? previewModeEnabled;
@@ -392,47 +396,110 @@ class Grade10LabsRegistry {
return null; return null;
} }
if (usePreview) { // 1. Exact match on sourceMarkdown / filePath (Highest precision, 100% deterministic)
// 1. Exact match on curriculumLessonId within candidate subject labs 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) { if (curriculumLessonId != null && curriculumLessonId.isNotEmpty) {
for (final c in candidates) { for (final c in candidates) {
if (c.identity.curriculumLessonId == curriculumLessonId) { 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; 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(); final t = lessonTitle.toLowerCase().trim();
for (final c in candidates) { 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(); 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; 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) { if (lessonId != null && lessonId.isNotEmpty) {
for (final c in candidates) { for (final c in candidates) {
if (c.identity.lessonKey == lessonId || if (unitKey != null && c.identity.unitKey.isNotEmpty && c.identity.unitKey != unitKey) {
c.identity.toolKey == lessonId || continue;
c.identity.curriculumLessonId.endsWith(lessonId) || }
c.identity.sourceMarkdown.contains(lessonId)) { 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 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; return null;
} }
@@ -1,6 +1,7 @@
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/utils/app_logger.dart';
import '../../../data/models/socratic_checkpoint_model.dart'; import '../../../data/models/socratic_checkpoint_model.dart';
import '../../widgets/socratic_dialog.dart'; import '../../widgets/socratic_dialog.dart';
import 'labs_gallery_screen.dart'; import 'labs_gallery_screen.dart';
@@ -17,6 +18,7 @@ class SubjectVirtualLabsView extends StatefulWidget {
final Color primaryColor; final Color primaryColor;
final Widget? specializedToolWidget; final Widget? specializedToolWidget;
final String? specializedToolTitle; final String? specializedToolTitle;
final String? selectedSemester;
const SubjectVirtualLabsView({ const SubjectVirtualLabsView({
super.key, super.key,
@@ -24,6 +26,7 @@ class SubjectVirtualLabsView extends StatefulWidget {
required this.primaryColor, required this.primaryColor,
this.specializedToolWidget, this.specializedToolWidget,
this.specializedToolTitle, this.specializedToolTitle,
this.selectedSemester,
}); });
@override @override
@@ -34,7 +37,26 @@ class _SubjectVirtualLabsViewState extends State<SubjectVirtualLabsView> {
int _selectedIndex = 0; int _selectedIndex = 0;
bool _showSpecialized = false; 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) { void _openGallery(BuildContext context) {
AppLogger.event('OpenVirtualLabsGallery', details: {
'subject': widget.subjectTitle,
}, tag: 'VIRTUAL_LABS_VIEW');
Navigator.of(context).push( Navigator.of(context).push(
CupertinoPageRoute( CupertinoPageRoute(
builder: (_) => Scaffold( builder: (_) => Scaffold(
@@ -52,6 +74,12 @@ class _SubjectVirtualLabsViewState extends State<SubjectVirtualLabsView> {
void _triggerCheckpoint( void _triggerCheckpoint(
String q, List<String> opts, int correct, String lessonTitle) { 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( final checkpoint = SocraticCheckpointModel(
id: lessonTitle.hashCode & 0x7fffffff, id: lessonTitle.hashCode & 0x7fffffff,
questionText: q, questionText: q,
@@ -79,10 +107,24 @@ class _SubjectVirtualLabsViewState extends State<SubjectVirtualLabsView> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final allLabs = final allLabs =
Grade10LabsRegistry.bySubjectNormalized(widget.subjectTitle); Grade10LabsRegistry.bySubjectNormalized(widget.subjectTitle);
final labs = Grade10LabsRegistry.previewModeEnabled var candidateLabs = Grade10LabsRegistry.previewModeEnabled
? allLabs ? allLabs
: allLabs.where((e) => e.identity.isPublished).toList(); : 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 (labs.isEmpty) {
if (widget.specializedToolWidget != null) { if (widget.specializedToolWidget != null) {
return widget.specializedToolWidget!; return widget.specializedToolWidget!;
@@ -228,7 +270,13 @@ class _SubjectVirtualLabsViewState extends State<SubjectVirtualLabsView> {
idx == labs.length) { idx == labs.length) {
final isSel = _showSpecialized; final isSel = _showSpecialized;
return InkWell( 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), borderRadius: BorderRadius.circular(10),
child: Container( child: Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@@ -263,6 +311,13 @@ class _SubjectVirtualLabsViewState extends State<SubjectVirtualLabsView> {
final isSel = !_showSpecialized && activeIndex == idx; final isSel = !_showSpecialized && activeIndex == idx;
return InkWell( return InkWell(
onTap: () { 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(() { setState(() {
_showSpecialized = false; _showSpecialized = false;
_selectedIndex = idx; _selectedIndex = idx;
@@ -8,6 +8,7 @@ import Foundation
import device_info_plus import device_info_plus
import flutter_secure_storage_macos import flutter_secure_storage_macos
import flutter_tts import flutter_tts
import path_provider_foundation
import shared_preferences_foundation import shared_preferences_foundation
import url_launcher_macos import url_launcher_macos
import video_player_avfoundation import video_player_avfoundation
@@ -16,6 +17,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin")) FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin")) VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin"))
+7
View File
@@ -6,6 +6,9 @@ PODS:
- flutter_tts (0.0.1): - flutter_tts (0.0.1):
- FlutterMacOS - FlutterMacOS
- FlutterMacOS (1.0.0) - FlutterMacOS (1.0.0)
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- shared_preferences_foundation (0.0.1): - shared_preferences_foundation (0.0.1):
- Flutter - Flutter
- FlutterMacOS - FlutterMacOS
@@ -20,6 +23,7 @@ DEPENDENCIES:
- flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`)
- flutter_tts (from `Flutter/ephemeral/.symlinks/plugins/flutter_tts/macos`) - flutter_tts (from `Flutter/ephemeral/.symlinks/plugins/flutter_tts/macos`)
- FlutterMacOS (from `Flutter/ephemeral`) - 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`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
- video_player_avfoundation (from `Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin`) - 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 :path: Flutter/ephemeral/.symlinks/plugins/flutter_tts/macos
FlutterMacOS: FlutterMacOS:
:path: Flutter/ephemeral :path: Flutter/ephemeral
path_provider_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
shared_preferences_foundation: shared_preferences_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
url_launcher_macos: url_launcher_macos:
@@ -45,6 +51,7 @@ SPEC CHECKSUMS:
flutter_secure_storage_macos: 7f45e30f838cf2659862a4e4e3ee1c347c2b3b54 flutter_secure_storage_macos: 7f45e30f838cf2659862a4e4e3ee1c347c2b3b54
flutter_tts: ae915565cc6948444b513acc8ee021993281e027 flutter_tts: ae915565cc6948444b513acc8ee021993281e027
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52 video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52
+4 -60
View File
@@ -49,14 +49,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" 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: collection:
dependency: transitive dependency: transitive
description: description:
@@ -224,14 +216,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.3" version: "6.3.3"
hooks:
dependency: transitive
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
html: html:
dependency: transitive dependency: transitive
description: description:
@@ -328,14 +312,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "3.0.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@@ -368,14 +344,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" 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: package_config:
dependency: transitive dependency: transitive
description: description:
@@ -409,13 +377,13 @@ packages:
source: hosted source: hosted
version: "2.3.1" version: "2.3.1"
path_provider_foundation: path_provider_foundation:
dependency: transitive dependency: "direct overridden"
description: description:
name: path_provider_foundation name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.6.0" version: "2.4.2"
path_provider_linux: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
@@ -464,22 +432,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.5+1" 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: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -749,14 +701,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
url: "https://pub.dev"
source: hosted
version: "3.1.4"
sdks: sdks:
dart: ">=3.11.0 <4.0.0" dart: ">=3.11.0 <4.0.0"
flutter: ">=3.38.4" flutter: ">=3.38.0"
+3
View File
@@ -45,6 +45,9 @@ dev_dependencies:
flutter_lints: ^3.0.0 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 # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # 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_gallery_screen.dart';
import 'package:student_app/presentation/screens/virtual_labs/labs_registry.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/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() { void main() {
test('registry covers all Grade-10 labs (48 entries, 13 subjects)', () { 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 // History lesson 2 must NEVER return physics vector lab
final historyLab = Grade10LabsRegistry.findForLesson( final historyLab = Grade10LabsRegistry.findForLesson(
subjectTitle: 'تاريخ الأردن (Jordan History 10)', subjectTitle: 'التاريخ (History 10)',
lessonTitle: 'الدرس الثاني: الإمبراطورية الساسانية', lessonTitle: 'الدرس الثاني: الإمبراطورية الساسانية',
lessonId: 'lesson_02', lessonId: 'lesson_02',
); );
@@ -119,7 +121,7 @@ void main() {
// History lesson 1 MUST return Persian Empire lab, NOT physics! // History lesson 1 MUST return Persian Empire lab, NOT physics!
final historyUnit1Lab = Grade10LabsRegistry.findForLesson( final historyUnit1Lab = Grade10LabsRegistry.findForLesson(
subjectTitle: 'تاريخ الأردن (Jordan History 10)', subjectTitle: 'التاريخ (History 10)',
lessonTitle: 'الدرس الأول: الإمبراطورية الفارسية: النشأة والتطور', lessonTitle: 'الدرس الأول: الإمبراطورية الفارسية: النشأة والتطور',
lessonId: 'lesson_01', lessonId: 'lesson_01',
curriculumLessonId: 'history_10_semester_1_unit_01_lesson_01', curriculumLessonId: 'history_10_semester_1_unit_01_lesson_01',
@@ -824,6 +826,89 @@ void main() {
await t.pump(); await t.pump();
expect(checkpointTriggered, isTrue); expect(checkpointTriggered, isTrue);
}); });
testWidgets('arabic interactive lab view renders and supports tab navigation',
(t) async {
await t.binding.setSurfaceSize(const Size(1200, 2400));
addTearDown(() => t.binding.setSurfaceSize(null));
await t.pumpWidget(const MaterialApp(
home: Scaffold(
body: ArabicInteractiveLabView(),
),
));
await t.pump();
// Default tab: الصرف والاشتقاق
expect(find.textContaining('مختبر الضاد اللغوي الذكي'), findsWidgets);
expect(find.text('ميزان الصرف والاشتقاق ⚖️'), findsWidgets);
expect(find.text('معمل البلاغة والبيان 💎'), findsWidgets);
expect(find.text('استوديو الإعراب والتراكيب 📜'), findsWidgets);
expect(find.text('العروض وموسيقى الشعر 🎵'), findsWidgets);
// Verify root selector
expect(find.text('ك - ت - ب'), findsWidgets);
expect(find.text('ع - ل - م'), findsWidgets);
await t.tap(find.text('ع - ل - م').first);
await t.pump();
// Switch to Rhetoric tab
await t.tap(find.text('معمل البلاغة والبيان 💎').first);
await t.pumpAndSettle();
expect(find.textContaining('الطباق'), findsWidgets);
// Switch to Syntax tab
await t.tap(find.text('استوديو الإعراب والتراكيب 📜').first);
await t.pumpAndSettle();
expect(find.textContaining('اختر الجملة لتحليل بنيتها الإعرابية'), findsWidgets);
// Switch to Prosody tab
await t.tap(find.text('العروض وموسيقى الشعر 🎵').first);
await t.pumpAndSettle();
expect(find.textContaining('بحر الكامل'), findsWidgets);
});
testWidgets('english interactive lab view renders and supports tab navigation',
(t) async {
await t.binding.setSurfaceSize(const Size(1200, 2400));
addTearDown(() => t.binding.setSurfaceSize(null));
await t.pumpWidget(const MaterialApp(
home: Scaffold(
body: EnglishInteractiveLabView(),
),
));
await t.pump();
// Default tab: Grammar Matrix
expect(find.textContaining('Action Pack 10'), findsWidgets);
expect(find.text('مصفوفة القواعد (Grammar) 📐'), findsWidgets);
expect(find.text('الصوتيات والنبر (Phonetics) 🎙️'), findsWidgets);
expect(find.text('المفردات والمتلازمات 📚'), findsWidgets);
expect(find.text('تحدي الاستماع (Listening) ⚡'), findsWidgets);
// Check grammar tense chips
expect(find.text('Present Simple'), findsWidgets);
expect(find.text('Present Perfect'), findsWidgets);
await t.tap(find.text('Present Perfect').first);
await t.pumpAndSettle();
// Switch to Phonetics tab
await t.tap(find.text('الصوتيات والنبر (Phonetics) 🎙️').first);
await t.pumpAndSettle();
expect(find.textContaining('Minimal Pairs'), findsWidgets);
expect(find.textContaining('Syllable Stress Shift'), findsWidgets);
// Switch to Vocab tab
await t.tap(find.text('المفردات والمتلازمات 📚').first);
await t.pumpAndSettle();
expect(find.textContaining('Thematic Keywords'), findsWidgets);
// Switch to Listening tab
await t.tap(find.text('تحدي الاستماع (Listening) ⚡').first);
await t.pumpAndSettle();
expect(find.textContaining('Gulf of Aqaba'), findsWidgets);
});
} }
+6 -6
View File
@@ -340,10 +340,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.18" version: "0.12.19"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
@@ -356,10 +356,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: meta name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.18.0"
nested: nested:
dependency: transitive dependency: transitive
description: description:
@@ -585,10 +585,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.9" version: "0.7.11"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
+3
View File
@@ -43,6 +43,9 @@ dev_dependencies:
flutter_lints: ^3.0.0 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 # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec
File diff suppressed because it is too large Load Diff