Update Saqel Platform: 2026-09-13 16:31:17
This commit is contained in:
+344
-104
@@ -19,6 +19,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/utils/app_logger.dart';
|
||||
import '../../../core/utils/saqel_toast.dart';
|
||||
import '../../../core/config/app_config.dart';
|
||||
import '../../../core/services/storage_service.dart';
|
||||
@@ -65,34 +66,77 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
|
||||
bool _showChalkboardMode = false;
|
||||
final FlutterTts _tts = FlutterTts();
|
||||
bool _isSpeakingChalkboard = false;
|
||||
int _chalkboardStep = 0;
|
||||
|
||||
bool _isDisposed = false;
|
||||
bool _isMuted = false;
|
||||
|
||||
void _revealVideoControls() {
|
||||
if (!mounted) return;
|
||||
if (!mounted || _isDisposed) return;
|
||||
setState(() => _showVideoControls = true);
|
||||
_controlsHideTimer?.cancel();
|
||||
_controlsHideTimer = Timer(const Duration(seconds: 3), () {
|
||||
if (mounted) setState(() => _showVideoControls = false);
|
||||
_controlsHideTimer = Timer(const Duration(seconds: 4), () {
|
||||
if (mounted && !_isDisposed) setState(() => _showVideoControls = false);
|
||||
});
|
||||
}
|
||||
|
||||
void _stopAllAudioAndVideo() {
|
||||
AppLogger.log('🛑 [AUDIO_VIDEO] Stopping all audio, video, and speech synthesis...', tag: 'VIDEO_LIFECYCLE');
|
||||
try {
|
||||
_tts.stop();
|
||||
} catch (e) {
|
||||
AppLogger.error('Error stopping TTS', error: e, tag: 'VIDEO_LIFECYCLE');
|
||||
}
|
||||
if (_videoController != null) {
|
||||
try {
|
||||
_videoController!.pause();
|
||||
_videoController!.setVolume(0.0);
|
||||
_videoController!.dispose();
|
||||
} catch (e) {
|
||||
AppLogger.error('Error disposing video controller', error: e, tag: 'VIDEO_LIFECYCLE');
|
||||
}
|
||||
_videoController = null;
|
||||
_isVideoInitialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initPlayer(String videoUrl, int resumePos, bool shouldPlay) async {
|
||||
if (_isDisposed || !mounted) return;
|
||||
|
||||
AppLogger.log('🎬 [VIDEO_INIT_REQUEST] Received raw video URL for lesson: "${widget.lesson.title}"\n'
|
||||
' • Raw URL: "$videoUrl"\n'
|
||||
' • Resume Position: ${resumePos}s\n'
|
||||
' • Should AutoPlay: $shouldPlay\n'
|
||||
' • Curriculum Lesson ID: ${widget.lesson.curriculumLessonId}',
|
||||
tag: 'VIDEO_PLAYER');
|
||||
|
||||
if (videoUrl.isEmpty) {
|
||||
setState(() => _videoInitError = 'لا يوجد رابط فيديو حقيقي لهذا الدرس.');
|
||||
AppLogger.error('Video URL is empty for lesson: ${widget.lesson.title}', tag: 'VIDEO_PLAYER');
|
||||
if (mounted && !_isDisposed) {
|
||||
setState(() => _videoInitError = 'لم يتم ربط فيديو معتمد لهذا الدرس بعد.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final token = await StorageService().getToken();
|
||||
String finalVideoUrl = videoUrl;
|
||||
String finalVideoUrl = videoUrl.trim();
|
||||
|
||||
// IMPORTANT: Check whether the URL points to our own Saqel API or to an external CDN (BunnyCDN, Cloudflare R2).
|
||||
// NEVER send the Saqel user JWT Bearer token to external CDNs! CDNs reject unauthorized Bearer headers
|
||||
// with HTTP 403 Forbidden ("Resource is protected by access token").
|
||||
// 1. Resolve relative API endpoints to absolute base URL
|
||||
if (finalVideoUrl.startsWith('/')) {
|
||||
final base = AppConfig.baseUrl.replaceAll(RegExp(r'/+$'), '');
|
||||
finalVideoUrl = '$base$finalVideoUrl';
|
||||
AppLogger.log('🔗 [URL_RESOLVE] Resolved relative path to absolute: $finalVideoUrl', tag: 'VIDEO_PLAYER');
|
||||
}
|
||||
|
||||
// 2. Attach authentication token for our backend streaming endpoints
|
||||
final token = await StorageService().getToken();
|
||||
final isOurBackend = finalVideoUrl.contains('/api/videos/') ||
|
||||
finalVideoUrl.startsWith(AppConfig.baseUrl) ||
|
||||
finalVideoUrl.contains('localhost') ||
|
||||
finalVideoUrl.contains('127.0.0.1');
|
||||
|
||||
final headers = <String, String>{};
|
||||
final headers = <String, String>{
|
||||
'User-Agent': 'SaqelEdTech/2.0 (macOS; Grade10-Production)',
|
||||
};
|
||||
if (isOurBackend && token != null && token.isNotEmpty) {
|
||||
headers['Authorization'] = 'Bearer $token';
|
||||
if (!finalVideoUrl.contains('token=')) {
|
||||
@@ -101,92 +145,102 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
|
||||
}
|
||||
}
|
||||
|
||||
_videoController?.dispose();
|
||||
_videoController = VideoPlayerController.networkUrl(
|
||||
AppLogger.log('🚀 [VIDEO_CONNECT] Connecting to real video stream: $finalVideoUrl', tag: 'VIDEO_PLAYER');
|
||||
|
||||
// Cleanly stop any existing controller before assigning a new one
|
||||
_stopAllAudioAndVideo();
|
||||
|
||||
final controller = VideoPlayerController.networkUrl(
|
||||
Uri.parse(finalVideoUrl),
|
||||
httpHeaders: headers,
|
||||
)
|
||||
..initialize().then((_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isVideoInitialized = true;
|
||||
_videoInitError = null;
|
||||
});
|
||||
_revealVideoControls();
|
||||
if (resumePos > 3) {
|
||||
_videoController!.seekTo(Duration(seconds: resumePos));
|
||||
if (context.mounted) {
|
||||
SaqelToast.showInfo(
|
||||
context,
|
||||
'تم استئناف المشاهدة من الدقيقة ${_formatTime(resumePos)} ⏱️',
|
||||
title: 'استئناف المشاهدة',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (shouldPlay) {
|
||||
_videoController!.play();
|
||||
}
|
||||
}
|
||||
}).catchError((err) {
|
||||
final errStr = err.toString();
|
||||
// Resilient Fallback: If 403 Forbidden or Access Token error occurred on an external URL with token query params,
|
||||
// retry once without the token parameters (e.g. public pull zones or direct CDNs).
|
||||
if ((errStr.contains('403') || errStr.contains('token') || errStr.contains('permission')) &&
|
||||
finalVideoUrl.contains('?')) {
|
||||
final strippedUrl = finalVideoUrl.split('?').first;
|
||||
if (strippedUrl != finalVideoUrl && strippedUrl.isNotEmpty) {
|
||||
_videoController?.dispose();
|
||||
_videoController = VideoPlayerController.networkUrl(
|
||||
Uri.parse(strippedUrl),
|
||||
httpHeaders: const {},
|
||||
)..initialize().then((_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isVideoInitialized = true;
|
||||
_videoInitError = null;
|
||||
});
|
||||
_revealVideoControls();
|
||||
if (shouldPlay) _videoController!.play();
|
||||
}
|
||||
}).catchError((retryErr) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isVideoInitialized = false;
|
||||
_videoInitError = 'تعذر تشغيل بث الفيديو من المصدر السحابي، يمكنك استخدام السبورة التفاعلية للشرح.';
|
||||
});
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
);
|
||||
_videoController = controller;
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isVideoInitialized = false;
|
||||
_videoInitError = 'تعذر تحميل بث الفيديو المباشر؛ انقر لإعادة المحاولة ($err)';
|
||||
});
|
||||
}
|
||||
controller.initialize().then((_) {
|
||||
if (_isDisposed || !mounted) {
|
||||
AppLogger.log('🛑 [LIFECYCLE_ABORT] Screen disposed before video completed loading. Silencing controller.', tag: 'VIDEO_LIFECYCLE');
|
||||
controller.pause();
|
||||
controller.setVolume(0.0);
|
||||
controller.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.log('✅ [VIDEO_READY] Video successfully initialized!\n'
|
||||
' • Duration: ${controller.value.duration.inSeconds}s (${_formatTime(controller.value.duration.inSeconds)})\n'
|
||||
' • Resolution: ${controller.value.size.width.toInt()}x${controller.value.size.height.toInt()}\n'
|
||||
' • Aspect Ratio: ${controller.value.aspectRatio.toStringAsFixed(2)}',
|
||||
tag: 'VIDEO_PLAYER');
|
||||
|
||||
setState(() {
|
||||
_isVideoInitialized = true;
|
||||
_videoInitError = null;
|
||||
if (_isMuted) controller.setVolume(0.0);
|
||||
});
|
||||
_revealVideoControls();
|
||||
|
||||
if (resumePos > 3 && resumePos < controller.value.duration.inSeconds) {
|
||||
controller.seekTo(Duration(seconds: resumePos));
|
||||
if (context.mounted) {
|
||||
SaqelToast.showInfo(
|
||||
context,
|
||||
'تم استئناف المشاهدة من الدقيقة ${_formatTime(resumePos)} ⏱️',
|
||||
title: 'استئناف المشاهدة',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldPlay && !_isDisposed) {
|
||||
controller.play();
|
||||
AppLogger.log('▶️ [VIDEO_PLAY] Playback started naturally.', tag: 'VIDEO_PLAYER');
|
||||
}
|
||||
}).catchError((err, stack) {
|
||||
final errStr = err.toString();
|
||||
AppLogger.error(
|
||||
'🚨 [VIDEO_PLAYER_ERROR] Real video playback failed!\n'
|
||||
' • Target URL: $finalVideoUrl\n'
|
||||
' • Error: $errStr',
|
||||
error: err,
|
||||
stackTrace: stack,
|
||||
tag: 'VIDEO_PLAYER',
|
||||
);
|
||||
|
||||
if (mounted && !_isDisposed) {
|
||||
setState(() {
|
||||
_isVideoInitialized = false;
|
||||
final is403 = errStr.contains('403') || errStr.contains('permission') || errStr.contains('-12660');
|
||||
final is404 = errStr.contains('404') || errStr.contains('not found');
|
||||
|
||||
if (is403) {
|
||||
_videoInitError = 'بث الفيديو السحابي قيد المزامنة على مزود الاستضافة (HTTP 403).\nيمكنك متابعة دراسة الحصة فوراً عبر السبورة السقراطية الذكية بالصوت 🎙️ أو فتح المختبر التفاعلي 🔬.';
|
||||
} else if (is404) {
|
||||
_videoInitError = 'ملف الفيديو غير متوفر حالياً على الخادم (HTTP 404).\nيمكنك متابعة الدرس عبر السبورة السقراطية الذكية بالصوت 🎙️ أو فتح المختبر التفاعلي 🔬.';
|
||||
} else {
|
||||
_videoInitError = 'تعذر تشغيل الفيديو: $errStr';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _speakConcept(String text) async {
|
||||
if (text.isEmpty) return;
|
||||
if (text.isEmpty || _isDisposed || _isMuted) return;
|
||||
setState(() => _isSpeakingChalkboard = true);
|
||||
try {
|
||||
await _tts.setLanguage('ar');
|
||||
await _tts.setSpeechRate(0.48);
|
||||
_tts.setCompletionHandler(() {
|
||||
if (mounted) setState(() => _isSpeakingChalkboard = false);
|
||||
if (mounted && !_isDisposed) setState(() => _isSpeakingChalkboard = false);
|
||||
});
|
||||
await _tts.speak(text);
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _isSpeakingChalkboard = false);
|
||||
if (mounted && !_isDisposed) setState(() => _isSpeakingChalkboard = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
AppLogger.log('📱 [SCREEN_ENTER] Opened SocraticVideoPlayerScreen for lesson: ${widget.lesson.title}', tag: 'VIDEO_LIFECYCLE');
|
||||
context.read<VideoPlaybackCubit>().loadLesson(widget.lesson, subject: widget.subject, selectedVideoVersionId: widget.selectedVideoVersionId);
|
||||
|
||||
// Dynamic Floating Forensic Anti-Piracy Watermark Animation
|
||||
@@ -197,9 +251,17 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
|
||||
|
||||
// Keep the UI clock responsive, but sync progress to the API every 15s.
|
||||
_playbackTicker = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (_isDisposed || !mounted) {
|
||||
timer.cancel();
|
||||
return;
|
||||
}
|
||||
final cubit = context.read<VideoPlaybackCubit>();
|
||||
final state = cubit.state;
|
||||
if (state is VideoPlaybackReady && _videoController?.value.isInitialized == true) {
|
||||
if (state is VideoPlaybackReady &&
|
||||
_videoController != null &&
|
||||
_isVideoInitialized &&
|
||||
!_videoController!.value.hasError &&
|
||||
_videoController!.value.isInitialized) {
|
||||
final position = _videoController!.value.position.inSeconds;
|
||||
if (state.activeCheckpoint == null) cubit.updatePosition(position);
|
||||
final duration = _videoController!.value.duration.inSeconds;
|
||||
@@ -214,14 +276,23 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void deactivate() {
|
||||
AppLogger.log('⏸️ [SCREEN_DEACTIVATE] Deactivating video player screen. Stopping audio & video...', tag: 'VIDEO_LIFECYCLE');
|
||||
_isDisposed = true;
|
||||
_stopAllAudioAndVideo();
|
||||
super.deactivate();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
AppLogger.log('🧹 [SCREEN_DISPOSE] Disposing video player screen resources.', tag: 'VIDEO_LIFECYCLE');
|
||||
_isDisposed = true;
|
||||
context.read<VideoPlaybackCubit>().endWatchSession();
|
||||
_tts.stop();
|
||||
_stopAllAudioAndVideo();
|
||||
_playbackTicker?.cancel();
|
||||
_controlsHideTimer?.cancel();
|
||||
_watermarkController.dispose();
|
||||
_videoController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -252,6 +323,21 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
|
||||
),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: _isMuted ? 'إلغاء كتم الصوت' : 'كتم الصوت',
|
||||
icon: Icon(
|
||||
_isMuted ? CupertinoIcons.volume_off : CupertinoIcons.volume_up,
|
||||
color: _isMuted ? AppColors.guardianAmber : Colors.white,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isMuted = !_isMuted;
|
||||
_videoController?.setVolume(_isMuted ? 0.0 : 1.0);
|
||||
if (_isMuted) _tts.stop();
|
||||
});
|
||||
AppLogger.log('🔊 [AUDIO_TOGGLE] User toggled audio: ${_isMuted ? "MUTED" : "UNMUTED"}', tag: 'VIDEO_PLAYER');
|
||||
},
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
@@ -380,13 +466,16 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
|
||||
// Video Background Scene
|
||||
Stack(
|
||||
children: [
|
||||
if (_videoController != null && _isVideoInitialized)
|
||||
if (_videoController != null &&
|
||||
_isVideoInitialized &&
|
||||
_videoController!.value.isInitialized &&
|
||||
!_videoController!.value.hasError)
|
||||
Positioned.fill(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.cover,
|
||||
child: SizedBox(
|
||||
width: _videoController!.value.size.width,
|
||||
height: _videoController!.value.size.height,
|
||||
child: Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: _videoController!.value.aspectRatio > 0
|
||||
? _videoController!.value.aspectRatio
|
||||
: 16 / 9,
|
||||
child: VideoPlayer(_videoController!),
|
||||
),
|
||||
),
|
||||
@@ -450,17 +539,45 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
|
||||
const SizedBox(height: 14),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 10,
|
||||
spacing: 12,
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(CupertinoIcons.arrow_clockwise, size: 16),
|
||||
label: const Text('إعادة محاولة البث 🎥'),
|
||||
icon: const Icon(CupertinoIcons.mic_fill, size: 16, color: Colors.black),
|
||||
label: const Text('بدء الشرح على السبورة الذكية 🎙️', style: TextStyle(color: Colors.black, fontWeight: FontWeight.w800, fontSize: 13)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.saqelCyan,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showChalkboardMode = true;
|
||||
_videoInitError = null;
|
||||
});
|
||||
},
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(CupertinoIcons.sparkles, size: 16, color: Colors.white),
|
||||
label: const Text('المختبر التفاعلي للمبحث 🔬', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 13)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.appleBlue,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(CupertinoIcons.arrow_clockwise, size: 16, color: Colors.white70),
|
||||
label: const Text('إعادة المحاولة 🔄', style: TextStyle(color: Colors.white70)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Colors.white24),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
@@ -474,21 +591,6 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
|
||||
);
|
||||
},
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(CupertinoIcons.mic_fill, size: 16, color: AppColors.appleBlue),
|
||||
label: const Text('السبورة والشرح الصوتي 🎙️', style: TextStyle(color: Colors.white)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: AppColors.appleBlue.withAlpha(150)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showChalkboardMode = true;
|
||||
_videoInitError = null;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -1104,6 +1206,7 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isMath) _buildMathStepBoard(),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
@@ -1202,6 +1305,143 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMathStepBoard() {
|
||||
final steps = [
|
||||
{
|
||||
'title': 'الخطوة 1: صياغة النظام الرياضي',
|
||||
'eq1': 'المعادلة الخطية: y = 2x - 2',
|
||||
'eq2': 'المعادلة التربيعية: y = x² - 4x + 3',
|
||||
'note': 'نظام مكون من معادلة خطية وأخرى تربيعية بمتغيرين (x, y).'
|
||||
},
|
||||
{
|
||||
'title': 'الخطوة 2: التعويض ومساواة الطرفين',
|
||||
'eq1': 'x² - 4x + 3 = 2x - 2',
|
||||
'eq2': 'نعوض قيمة y من المعادلة الخطية في المعادلة التربيعية',
|
||||
'note': 'ينتج لدينا معادلة تربيعية بمتغير واحد فقط هو x.'
|
||||
},
|
||||
{
|
||||
'title': 'الخطوة 3: التصفير والتجميع في الصورة القياسية',
|
||||
'eq1': 'x² - 4x - 2x + 3 + 2 = 0',
|
||||
'eq2': 'x² - 6x + 5 = 0',
|
||||
'note': 'المعاملات: a = 1, b = -6, c = 5 (المميز Δ = 36 - 20 = 16 > 0 له حلان حقيقيان).'
|
||||
},
|
||||
{
|
||||
'title': 'الخطوة 4: التحليل إلى العوامل وإيجاد قيم x',
|
||||
'eq1': '(x - 5)(x - 1) = 0',
|
||||
'eq2': 'إما x = 5 أو x = 1',
|
||||
'note': 'أوجدنا الإحداثي السيني لنقطتي تقاطع المستقيم والقطع المكافئ.'
|
||||
},
|
||||
{
|
||||
'title': 'الخطوة 5: التعويض لإيجاد y ومجموعة الحل',
|
||||
'eq1': 'عند x = 5: y = 2(5) - 2 = 8 ➔ (5, 8)',
|
||||
'eq2': 'عند x = 1: y = 2(1) - 2 = 0 ➔ (1, 0)',
|
||||
'note': 'مجموعة حل النظام: {(5, 8), (1, 0)} - تم التحقق بالتعويض.'
|
||||
},
|
||||
];
|
||||
|
||||
final cur = steps[_chalkboardStep.clamp(0, steps.length - 1)];
|
||||
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 620),
|
||||
margin: const EdgeInsets.symmetric(vertical: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0A1926),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.saqelCyan.withAlpha(120), width: 1.5),
|
||||
boxShadow: [
|
||||
BoxShadow(color: AppColors.saqelCyan.withAlpha(20), blurRadius: 16, offset: const Offset(0, 4)),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.saqelCyan.withAlpha(40),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'خطوة ${_chalkboardStep + 1} من ${steps.length}',
|
||||
style: const TextStyle(color: AppColors.saqelCyan, fontSize: 12, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
cur['title']!,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 13.5, fontWeight: FontWeight.w800),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withAlpha(150),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.white10),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
cur['eq1']!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Color(0xFFFFD60A), fontSize: 15, fontWeight: FontWeight.w800, letterSpacing: 0.5),
|
||||
),
|
||||
if (cur['eq2']!.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
cur['eq2']!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Color(0xFF30D158), fontSize: 14.5, fontWeight: FontWeight.w700),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'💡 ${cur['note']!}',
|
||||
style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 12, height: 1.4),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(CupertinoIcons.arrow_right, size: 14),
|
||||
label: const Text('السابقة'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white12,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
),
|
||||
onPressed: _chalkboardStep > 0
|
||||
? () => setState(() => _chalkboardStep--)
|
||||
: null,
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(CupertinoIcons.arrow_left, size: 14),
|
||||
label: const Text('التالية'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.saqelCyan,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
),
|
||||
onPressed: _chalkboardStep < steps.length - 1
|
||||
? () => setState(() => _chalkboardStep++)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GridPatternPainter extends CustomPainter {
|
||||
|
||||
Reference in New Issue
Block a user