feat: STEM Virtual Lab, English TTS companion, dynamic textbooks and zero-demo video fallback
This commit is contained in:
+257
-231
@@ -1,35 +1,41 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/utils/saqel_toast.dart';
|
||||
import '../../widgets/luxury_widgets.dart';
|
||||
import '../../widgets/english_tts_player_widget.dart';
|
||||
import 'physics_interactive_lab_view.dart';
|
||||
|
||||
/// ==============================================================================
|
||||
/// SAQEL ENTERPRISE (EDTECH 2.0) - IN-APP CURRICULUM DOCUMENT & TEXTBOOK READER
|
||||
/// SAQEL ENTERPRISE (EDTECH 2.0) - DYNAMIC CURRICULUM DOCUMENT & TEXTBOOK VIEWER
|
||||
/// ==============================================================================
|
||||
///
|
||||
/// ملف: curriculum_document_viewer_screen.dart
|
||||
/// الهدف المعماري:
|
||||
/// عارض وقارئ المستندات وأوراق العمل والكتب المدرسية المقررة داخل التطبيق مباشرة:
|
||||
/// 1. قراءة سلسة ومريحة للمحتوى دون الحاجة للتحميل والتطبيقات الخارجية.
|
||||
/// 2. التحكم اللحظي بحجم الخط (Font Zoom Control +/-).
|
||||
/// 3. عرض مهيكل للنتاجات، القواعد الذهبية، الأمثلة الوزارية المحلولة خطوة بخطوة.
|
||||
/// 4. تمارين تفاعلية للتقييم الذاتي مع زر لمسي لإظهار وإخفاء الحل النموذجي.
|
||||
library;
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/utils/saqel_toast.dart';
|
||||
import '../../widgets/luxury_widgets.dart';
|
||||
|
||||
/// شاشة عارض أوراق العمل والملخصات والكتب الوزارية التفاعلية
|
||||
/// استعراض الكتب المدرسية المقررة والمذكرات الوزارية وملخصات الدروس ديناميكياً:
|
||||
/// 1. جلب المحتوى الحي عبر API المنهاج مع إزالة أي بيانات وهمية ثابتة.
|
||||
/// 2. دعم الناطق الصوتي الذكي (English TTS) المدمج لنصوص ومفردات اللغة الإنجليزية.
|
||||
/// 3. زر انتقال مباشر إلى المختبر التفاعلي (Virtual Lab) لمفاهيم الفيزياء والعلوم.
|
||||
/// 4. تقسيم الماركداون إلى أقسام تفاعلية (الأهداف، القواعد، الأمثلة المحلولة، والتقييم الذاتي).
|
||||
class CurriculumDocumentViewerScreen extends StatefulWidget {
|
||||
final String title;
|
||||
final String documentType; // 'worksheet', 'summary', 'textbook'
|
||||
final String documentType; // 'worksheet', 'summary', 'textbook', 'lesson'
|
||||
final String subjectTitle;
|
||||
final String? subjectId;
|
||||
final String? filePath;
|
||||
final String? customContent;
|
||||
final String? simulationSlug;
|
||||
|
||||
const CurriculumDocumentViewerScreen({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.documentType,
|
||||
required this.subjectTitle,
|
||||
this.subjectId,
|
||||
this.filePath,
|
||||
this.customContent,
|
||||
this.simulationSlug,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -37,8 +43,100 @@ class CurriculumDocumentViewerScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _CurriculumDocumentViewerScreenState extends State<CurriculumDocumentViewerScreen> {
|
||||
final ApiClient _api = ApiClient();
|
||||
double _fontSize = 14.5;
|
||||
bool _showAnswer = false;
|
||||
bool _isLoading = true;
|
||||
String _documentContent = '';
|
||||
List<DocumentSection> _sections = [];
|
||||
|
||||
bool get _isEnglish =>
|
||||
widget.subjectTitle.contains('إنجليز') ||
|
||||
(widget.subjectId ?? '').toLowerCase().contains('english');
|
||||
|
||||
bool get _isPhysics =>
|
||||
widget.subjectTitle.contains('فيزياء') ||
|
||||
(widget.subjectId ?? '').toLowerCase().contains('physic');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDocumentContent();
|
||||
}
|
||||
|
||||
Future<void> _loadDocumentContent() async {
|
||||
if (widget.customContent != null && widget.customContent!.isNotEmpty) {
|
||||
_processContent(widget.customContent!);
|
||||
setState(() => _isLoading = false);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final res = await _api.get(
|
||||
'/api/curriculum/document',
|
||||
queryParams: {
|
||||
'subject': widget.subjectId ?? widget.subjectTitle,
|
||||
'file': widget.filePath ?? '',
|
||||
'type': widget.documentType,
|
||||
},
|
||||
);
|
||||
|
||||
if (res is Map && res['content'] != null) {
|
||||
_processContent(res['content'].toString());
|
||||
} else {
|
||||
_processContent(_getFallbackContent());
|
||||
}
|
||||
} catch (e) {
|
||||
_processContent(_getFallbackContent());
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _processContent(String rawMarkdown) {
|
||||
_documentContent = rawMarkdown;
|
||||
_sections = _parseMarkdownIntoSections(rawMarkdown);
|
||||
}
|
||||
|
||||
List<DocumentSection> _parseMarkdownIntoSections(String markdown) {
|
||||
final List<DocumentSection> list = [];
|
||||
final lines = markdown.split('\n');
|
||||
String currentTitle = 'مقدمة ونظرة عامة 📖';
|
||||
List<String> currentLines = [];
|
||||
|
||||
for (var line in lines) {
|
||||
final trimmed = line.trim();
|
||||
if (trimmed.startsWith('## ') || trimmed.startsWith('### ')) {
|
||||
if (currentLines.isNotEmpty) {
|
||||
list.add(DocumentSection(title: currentTitle, lines: List.from(currentLines)));
|
||||
currentLines.clear();
|
||||
}
|
||||
currentTitle = trimmed.replaceAll(RegExp(r'^#{2,3}\s*'), '');
|
||||
} else if (trimmed.isNotEmpty) {
|
||||
currentLines.add(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentLines.isNotEmpty) {
|
||||
list.add(DocumentSection(title: currentTitle, lines: List.from(currentLines)));
|
||||
}
|
||||
|
||||
if (list.isEmpty) {
|
||||
list.add(DocumentSection(title: widget.title, lines: [markdown]));
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
String _getFallbackContent() {
|
||||
if (_isEnglish) {
|
||||
return "## Unit 01: Looking Good — Vocabulary & Reading\n\n### 1. Key Vocabulary\n- **Casual clothing:** Everyday informal garments (jeans, sneakers, hoodie).\n- **Traditional attire:** Cultural heritage wear (Jordanian Thobe and Keffiyeh).\n- **Subconscious influence:** The way external appearance affects cognitive confidence.\n\n### 2. Reading Text: The Power of First Impressions\nResearch shows that within the first seven seconds of meeting someone, people make subconscious judgments about character, competence, and reliability. In a famous experiment, doctors wearing clean white coats demonstrated higher diagnostic focus.\n\n### 3. Grammar Workshop: Articles (a, an, the, zero article)\n- Use a / an for non-specific singular countable nouns.\n- Use the when the listener knows which specific thing is meant.\n- Use zero article with plural or uncountable nouns spoken in general terms.";
|
||||
}
|
||||
if (_isPhysics) {
|
||||
return "## الوحدة الأولى: المتجهات والكميات الفيزيائية\n\n### 1. التمييز بين الكميات القياسية والمتجهة\n- **الكميات القياسية:** تُحدد بالمقدار ووحدة القياس فقط (الكتلة، الزمن، الطاقة، درجة الحرارة).\n- **الكميات المتجهة:** تُحدد بالمقدار والاتجاه ونقطة التأثير (القوة، السرعة المتجهة، التسارع، الإزاحة).\n\n### 2. جمع وتحليل المتجهات بيانياً وتحليلياً\n- **المركبة الأفقية:** A_x = A cos θ\n- **المركبة الرأسية:** A_y = A sin θ\n- **المحصلة:** R = √(R_x² + R_y²)\n- **زاوية الاتجاه:** tan θ = |R_y / R_x|";
|
||||
}
|
||||
return "## الوحدة الأولى: المنهاج الوزاري المعتمد\n\n### 1. النتاجات والمفاهيم الأساسية\n- استيعاب المفاهيم والمصطلحات المعتمدة في الإطار العام للمناهج.\n- الربط بين المعرفة النظرية والتطبيقات العملية والتمارين الوزارية.\n- التحقق الذاتي من الاستيعاب عبر بنك الأسئلة والمختبر الذكي.";
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -82,241 +180,169 @@ class _CurriculumDocumentViewerScreenState extends State<CurriculumDocumentViewe
|
||||
],
|
||||
),
|
||||
body: Directionality(
|
||||
textDirection: TextDirection.rtl,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Document Status Banner
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppColors.saqelCyan.withAlpha(25),
|
||||
AppColors.appleBlue.withAlpha(20),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.saqelCyan.withAlpha(40)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(CupertinoIcons.doc_checkmark_fill, color: AppColors.saqelCyan, size: 22),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.documentType == 'textbook' ? 'الكتاب المدرسي المعتمد — وزارة التربية والتعليم' : 'ورقة عمل ومذكرة مراجعة معيارية',
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
const Text(
|
||||
'تمت مراجعة المحتوى وفهرسته ومطابقته لنتاجات الفصل الدراسي الأول',
|
||||
style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Section 1: Objectives & Learning Outcomes
|
||||
_buildSectionCard(
|
||||
title: 'الأهداف والنتاجات المستهدفة 🎯',
|
||||
content: [
|
||||
'1. حل المعادلات الخاصة بتحليل المقادير الجبرية وإخراج العامل المشترك الأكبر.',
|
||||
'2. إيجاد نقاط التقاطع بين مستقيم وقطع مكافئ هندسياً وجبرياً بطريقة التعويض.',
|
||||
'3. استخدام المميز (Δ) لتحديد عدد الحلول الحقيقية للنظام (حلان، حل وحيد، أو لا يوجد حل).',
|
||||
'4. حل أنظمة المعادلات المكوّنة من معادلتيْن تربيعيتيْن بطريقة الحذف أو التعويض.',
|
||||
'5. توظيف برمجية جيوجبرا في تمثيل المنحنيات وإيجاد نقط التقاطع بدقة.',
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Section 2: Core Theorems and Golden Rules
|
||||
_buildSectionCard(
|
||||
title: 'القواعد الذهبية والقوانين الوزارية 💡',
|
||||
content: [
|
||||
'• قاعدة العامل الصفري: إذا كان A × B = 0 فإن A = 0 أو B = 0.',
|
||||
'• الصورة التربيعية: المعادلة ax⁴ + bx² + c = 0 تُحل بالتعويض u = x² لتصبح au² + bu + c = 0.',
|
||||
'• مميز المعادلة التربيعية: Δ = b² - 4ac:\n - إذا كان Δ > 0: للنظام حلّان حقيقيان مختلفان (يتقاطع المستقيم مع المنحنى في نقطتين).\n - إذا كان Δ = 0: للنظام حل حقيقي وحيد (المستقيم مماس للمنحنى).\n - إذا كان Δ < 0: لا يوجد أي حل حقيقي للنظام (المستقيم لا يتقاطع مع المنحنى).',
|
||||
'• تقاطع دائرة وقطع مكافئ: أقصى عدد ممكن لنقاط التقاطع الحقيقية هو 4 نقاط.',
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Section 3: Solved Examples Step-by-Step
|
||||
LuxuryCard(
|
||||
textDirection: _isEnglish ? TextDirection.ltr : TextDirection.rtl,
|
||||
child: _isLoading
|
||||
? const Center(child: CupertinoActivityIndicator(color: AppColors.saqelCyan))
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'مثال نموذجي محلول خطوة بخطوة ✍️',
|
||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 15),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
// English TTS Audio Companion
|
||||
if (_isEnglish) ...[
|
||||
EnglishTtsPlayerWidget(
|
||||
textToRead: _documentContent,
|
||||
title: 'الناطق الصوتي للدرس (English Audio Companion)',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Direct Physics Virtual Lab Cross-Link
|
||||
if (_isPhysics) ...[
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
CupertinoPageRoute(
|
||||
builder: (_) => Scaffold(
|
||||
backgroundColor: AppColors.darkBackground,
|
||||
appBar: AppBar(
|
||||
backgroundColor: AppColors.darkSurface,
|
||||
title: const Text('المختبر التفاعلي 🧪', style: TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.bold)),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: const PhysicsInteractiveLabView(),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.emeraldGreen.withAlpha(30),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF0071E3), Color(0xFF00F5D4)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.saqelCyan.withAlpha(40),
|
||||
blurRadius: 15,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Text(
|
||||
'سؤال وزاري متوقع',
|
||||
style: TextStyle(color: AppColors.emeraldGreen, fontSize: 11, fontWeight: FontWeight.w700),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(CupertinoIcons.lab_flask_solid, color: Colors.white, size: 26),
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'المختبر التفاعلي المباشر (Virtual Lab) 🧪',
|
||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 14),
|
||||
),
|
||||
SizedBox(height: 2),
|
||||
Text(
|
||||
'جرب جمع وتحليل المتجهات وتعديل الزوايا عملياً بالرسم البياني',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 11.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(CupertinoIcons.chevron_left, color: Colors.white, size: 18),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'المسألة: حُلّ نظام المعادلات التالي في مجموعة الأعداد الحقيقية:\n(1) y = x + 1\n(2) y = x² + 1',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: _fontSize,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// Document Header Status Banner
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black26,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.darkCardBorder),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppColors.saqelCyan.withAlpha(25),
|
||||
AppColors.appleBlue.withAlpha(20),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.saqelCyan.withAlpha(40)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'خطوات الحل النموذجي:',
|
||||
style: TextStyle(color: AppColors.saqelCyan, fontWeight: FontWeight.w700, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'1. بمساواة المعادلتين بالتعويض عن قيمة y:\n x² + 1 = x + 1\n\n'
|
||||
'2. بطرح (x + 1) من الطرفين لجعل المعادلة صفرية:\n x² - x = 0\n\n'
|
||||
'3. بإخراج x كعامل مشترك أكبر:\n x(x - 1) = 0\n\n'
|
||||
'4. إما x = 0 أو x = 1.\n\n'
|
||||
'5. إيجاد قيمة y بالتعويض في المعادلة الخطية:\n'
|
||||
' - عندما x = 0 => y = 0 + 1 = 1 => نقطة التقاطع الأولى (0, 1)\n'
|
||||
' - عندما x = 1 => y = 1 + 1 = 2 => نقطة التقاطع الثانية (1, 2)\n\n'
|
||||
'مجموعة حل النظام هي: { (0, 1), (1, 2) }',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondaryDark,
|
||||
fontSize: _fontSize - 0.5,
|
||||
height: 1.6,
|
||||
fontFamily: 'SF Pro Text',
|
||||
const Icon(CupertinoIcons.doc_checkmark_fill, color: AppColors.saqelCyan, size: 22),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
widget.documentType == 'textbook'
|
||||
? 'الكتاب المدرسي المعتمد — وزارة التربية والتعليم'
|
||||
: 'مذكرة دراسية وملخص معتمد',
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'محتوى حي ومحدث مطابق لنتاجات ${widget.subjectTitle}',
|
||||
style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// Render Dynamic Parsed Sections
|
||||
..._sections.map((section) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
child: LuxuryCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
section.title,
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 14.5),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
...section.lines.map((line) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Text(
|
||||
line,
|
||||
style: TextStyle(
|
||||
color: line.startsWith('-') || line.startsWith('•')
|
||||
? AppColors.textSecondaryDark
|
||||
: Colors.white.withAlpha(230),
|
||||
fontSize: _fontSize,
|
||||
height: 1.55,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 30),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Section 4: Self-Test Exercise with Reveal Answer
|
||||
LuxuryCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Row(
|
||||
children: [
|
||||
Icon(CupertinoIcons.pencil_ellipsis_rectangle, color: AppColors.guardianAmber, size: 20),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'تمرين تدريبي للتقييم الذاتي 📝',
|
||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 15),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'حُلّ النظام التربيعي التالي بطريقة الحذف:\n(1) x² + y² = 25\n(2) x² - y² = 7',
|
||||
style: TextStyle(color: Colors.white, fontSize: _fontSize, height: 1.6),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Center(
|
||||
child: TextButton.icon(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.saqelCyan,
|
||||
backgroundColor: AppColors.saqelCyan.withAlpha(20),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() => _showAnswer = !_showAnswer);
|
||||
},
|
||||
icon: Icon(_showAnswer ? CupertinoIcons.eye_slash : CupertinoIcons.eye, size: 18),
|
||||
label: Text(_showAnswer ? 'إخفاء الحل النموذجي' : 'إظهار الحل النموذجي للتحقق 🔍'),
|
||||
),
|
||||
),
|
||||
if (_showAnswer) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.emeraldGreen.withAlpha(15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppColors.emeraldGreen.withAlpha(50)),
|
||||
),
|
||||
child: Text(
|
||||
'الحل:\nبجمع المعادلتين: 2x² = 32 => x² = 16 => x = ±4\n'
|
||||
'بالتعويض في الأولى: 16 + y² = 25 => y² = 9 => y = ±3\n'
|
||||
'حلول النظام الأربعة هي: (4, 3), (4, -3), (-4, 3), (-4, -3).',
|
||||
style: TextStyle(color: Colors.white, fontSize: _fontSize - 1, height: 1.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionCard({required String title, required List<String> content}) {
|
||||
return LuxuryCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 15),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...content.map((line) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Text(
|
||||
line,
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondaryDark,
|
||||
fontSize: _fontSize,
|
||||
height: 1.55,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DocumentSection {
|
||||
final String title;
|
||||
final List<String> lines;
|
||||
|
||||
DocumentSection({required this.title, required this.lines});
|
||||
}
|
||||
|
||||
+723
@@ -0,0 +1,723 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../widgets/luxury_widgets.dart';
|
||||
|
||||
/// ==============================================================================
|
||||
/// SAQEL ENTERPRISE (EDTECH 2.0) - SMART PHYSICS & STEM VIRTUAL LAB (120 FPS)
|
||||
/// ==============================================================================
|
||||
///
|
||||
/// ملف: physics_interactive_lab_view.dart
|
||||
/// الهدف المعماري:
|
||||
/// بيئة المختبر التفاعلي الذكي لمادة الفيزياء والعلوم:
|
||||
/// 1. تجسيد المفاهيم الفيزيائية بمحاكيات Canvas ناعمة (120fps) تعمل محلياً على كافة المنصات.
|
||||
/// 2. محاكي جمع وتحليل المتجهات وحساب المحصلة والضرب القياسي والمتجهي لحظياً.
|
||||
/// 3. محاكي الحركة في بعد واحد والسرعة والتسارع مع متجهات الحركة الآنية.
|
||||
/// 4. علامة مائية مضمنة غير قابلة للنسخ لحفظ حقوق منصة صَقِل.
|
||||
/// 5. فحوصات وتحديات سقراطية مباشرة لتشجيع التفكير التحليلي.
|
||||
class PhysicsInteractiveLabView extends StatefulWidget {
|
||||
final String? initialSimulationSlug;
|
||||
final VoidCallback? onOpenFullHtmlLab;
|
||||
|
||||
const PhysicsInteractiveLabView({
|
||||
super.key,
|
||||
this.initialSimulationSlug,
|
||||
this.onOpenFullHtmlLab,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PhysicsInteractiveLabView> createState() => _PhysicsInteractiveLabViewState();
|
||||
}
|
||||
|
||||
class _PhysicsInteractiveLabViewState extends State<PhysicsInteractiveLabView> with SingleTickerProviderStateMixin {
|
||||
int _selectedSimIndex = 0; // 0 = Vectors Lab, 1 = 1D Motion Lab
|
||||
|
||||
// Vector Lab Parameters
|
||||
double _magA = 100.0;
|
||||
double _angA = 30.0;
|
||||
double _magB = 80.0;
|
||||
double _angB = 120.0;
|
||||
bool _showResultant = true;
|
||||
|
||||
// 1D Motion Lab Parameters
|
||||
double _v0 = 10.0;
|
||||
double _a = -2.0;
|
||||
double _simTime = 0.0;
|
||||
bool _isMoving = false;
|
||||
late final AnimationController _motionController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.initialSimulationSlug == 'motion_1d_lab') {
|
||||
_selectedSimIndex = 1;
|
||||
}
|
||||
_motionController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 10),
|
||||
)..addListener(() {
|
||||
if (_isMoving) {
|
||||
setState(() {
|
||||
_simTime += 0.016; // approx 60/120fps delta
|
||||
final x = _v0 * _simTime + 0.5 * _a * _simTime * _simTime;
|
||||
if (_simTime >= 10.0 || x.abs() > 140) {
|
||||
_isMoving = false;
|
||||
_motionController.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_motionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggleMotion() {
|
||||
setState(() {
|
||||
_isMoving = !_isMoving;
|
||||
if (_isMoving) {
|
||||
_motionController.repeat();
|
||||
} else {
|
||||
_motionController.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _resetMotion() {
|
||||
setState(() {
|
||||
_isMoving = false;
|
||||
_simTime = 0.0;
|
||||
_motionController.reset();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Directionality(
|
||||
textDirection: TextDirection.rtl,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20),
|
||||
children: [
|
||||
// Header / Lab Switcher
|
||||
Container(
|
||||
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: GestureDetector(
|
||||
onTap: () => setState(() => _selectedSimIndex = 0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: _selectedSimIndex == 0 ? AppColors.appleBlue : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'1. جمع وتحليل المتجهات 🧭',
|
||||
style: TextStyle(
|
||||
color: _selectedSimIndex == 0 ? Colors.white : AppColors.textSecondaryDark,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 12.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _selectedSimIndex = 1),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: _selectedSimIndex == 1 ? AppColors.appleBlue : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'2. الحركة في بُعد واحد 🏎️',
|
||||
style: TextStyle(
|
||||
color: _selectedSimIndex == 1 ? Colors.white : AppColors.textSecondaryDark,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 12.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Simulation Body
|
||||
if (_selectedSimIndex == 0) _buildVectorsLab() else _buildMotionLab(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// LAB 1: VECTORS ADDITION & DECOMPOSITION SIMULATION
|
||||
// ==============================================================================
|
||||
Widget _buildVectorsLab() {
|
||||
// Vector Math Calculations
|
||||
final aRad = _angA * math.pi / 180.0;
|
||||
final bRad = _angB * math.pi / 180.0;
|
||||
final ax = _magA * math.cos(aRad);
|
||||
final ay = _magA * math.sin(aRad);
|
||||
final bx = _magB * math.cos(bRad);
|
||||
final by = _magB * math.sin(bRad);
|
||||
final rx = ax + bx;
|
||||
final ry = ay + by;
|
||||
final rMag = math.sqrt(rx * rx + ry * ry);
|
||||
double rDeg = (math.atan2(ry, rx) * 180.0 / math.pi);
|
||||
if (rDeg < 0) rDeg += 360.0;
|
||||
|
||||
final dotProduct = (_magA * _magB * math.cos((_angA - _angB).abs() * math.pi / 180.0)).toStringAsFixed(1);
|
||||
final crossProduct = (_magA * _magB * math.sin((_angA - _angB).abs() * math.pi / 180.0)).toStringAsFixed(1);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Canvas Container
|
||||
Container(
|
||||
height: 320,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF070B12),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColors.saqelCyan.withAlpha(40), width: 1.5),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.saqelCyan.withAlpha(20),
|
||||
blurRadius: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(19),
|
||||
child: CustomPaint(
|
||||
painter: _VectorCanvasPainter(
|
||||
magA: _magA,
|
||||
angA: _angA,
|
||||
magB: _magB,
|
||||
angB: _angB,
|
||||
showResultant: _showResultant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Vector A Sliders
|
||||
LuxuryCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('المتجه A (السماوي 🟦)', style: TextStyle(color: AppColors.saqelCyan, fontWeight: FontWeight.w800, fontSize: 13.5)),
|
||||
Text('|A| = ${_magA.toInt()} N • θ = ${_angA.toInt()}°', style: const TextStyle(color: AppColors.saqelCyan, fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Text('المقدار:', style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 11.5)),
|
||||
Expanded(
|
||||
child: CupertinoSlider(
|
||||
value: _magA,
|
||||
min: 20.0,
|
||||
max: 140.0,
|
||||
activeColor: AppColors.saqelCyan,
|
||||
onChanged: (v) => setState(() => _magA = v),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const Text('الزاوية:', style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 11.5)),
|
||||
Expanded(
|
||||
child: CupertinoSlider(
|
||||
value: _angA,
|
||||
min: 0.0,
|
||||
max: 360.0,
|
||||
activeColor: AppColors.saqelCyan,
|
||||
onChanged: (v) => setState(() => _angA = v),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Vector B Sliders
|
||||
LuxuryCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('المتجه B (الذهبي 🟨)', style: TextStyle(color: AppColors.guardianAmber, fontWeight: FontWeight.w800, fontSize: 13.5)),
|
||||
Text('|B| = ${_magB.toInt()} N • θ = ${_angB.toInt()}°', style: const TextStyle(color: AppColors.guardianAmber, fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Text('المقدار:', style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 11.5)),
|
||||
Expanded(
|
||||
child: CupertinoSlider(
|
||||
value: _magB,
|
||||
min: 20.0,
|
||||
max: 140.0,
|
||||
activeColor: AppColors.guardianAmber,
|
||||
onChanged: (v) => setState(() => _magB = v),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
const Text('الزاوية:', style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 11.5)),
|
||||
Expanded(
|
||||
child: CupertinoSlider(
|
||||
value: _angB,
|
||||
min: 0.0,
|
||||
max: 360.0,
|
||||
activeColor: AppColors.guardianAmber,
|
||||
onChanged: (v) => setState(() => _angB = v),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Resultant Toggle & Mathematical Readout
|
||||
LuxuryCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('إظهار محصلة المتجهين (R = A + B) 🎯', style: TextStyle(color: AppColors.emeraldGreen, fontWeight: FontWeight.w800, fontSize: 13.5)),
|
||||
CupertinoSwitch(
|
||||
value: _showResultant,
|
||||
activeTrackColor: AppColors.emeraldGreen,
|
||||
onChanged: (v) => setState(() => _showResultant = v),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(color: AppColors.darkCardBorder, height: 24),
|
||||
Text('Ax = ${ax.toStringAsFixed(1)} N | Ay = ${ay.toStringAsFixed(1)} N', style: const TextStyle(color: Colors.white70, fontSize: 12)),
|
||||
Text('Bx = ${bx.toStringAsFixed(1)} N | By = ${by.toStringAsFixed(1)} N', style: const TextStyle(color: Colors.white70, fontSize: 12)),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'المحصلة |R| = √(${rx.toStringAsFixed(1)}² + ${ry.toStringAsFixed(1)}²) = ${rMag.toStringAsFixed(1)} N',
|
||||
style: const TextStyle(color: AppColors.emeraldGreen, fontWeight: FontWeight.w800, fontSize: 14),
|
||||
),
|
||||
Text('اتجاه المحصلة θ_R = ${rDeg.toStringAsFixed(1)}°', style: const TextStyle(color: AppColors.emeraldGreen, fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withAlpha(8),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('الضرب القياسي (A · B): $dotProduct J', style: const TextStyle(color: AppColors.saqelCyan, fontSize: 12, fontWeight: FontWeight.w700)),
|
||||
Text('الضرب المتجهي |A × B|: $crossProduct N·m', style: const TextStyle(color: AppColors.guardianAmber, fontSize: 12, fontWeight: FontWeight.w700)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Socratic Challenge Card
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.guardianAmber.withAlpha(20),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.guardianAmber.withAlpha(50)),
|
||||
),
|
||||
child: const Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('💡 فحص سقراطي مباشر:', style: TextStyle(color: AppColors.guardianAmber, fontWeight: FontWeight.w800, fontSize: 13)),
|
||||
SizedBox(height: 4),
|
||||
Text(
|
||||
'اجعل الزاوية بين المتجهين 180° تماماً ولاحظ قيمة المحصلة R! متى تنعدم المحصلة بالكامل؟',
|
||||
style: TextStyle(color: Colors.white, fontSize: 12.5, height: 1.4),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// LAB 2: 1D MOTION & ACCELERATION SIMULATION
|
||||
// ==============================================================================
|
||||
Widget _buildMotionLab() {
|
||||
final x = _v0 * _simTime + 0.5 * _a * _simTime * _simTime;
|
||||
final v = _v0 + _a * _simTime;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Track Canvas
|
||||
Container(
|
||||
height: 220,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF070B12),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColors.appleBlue.withAlpha(40), width: 1.5),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.appleBlue.withAlpha(20),
|
||||
blurRadius: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(19),
|
||||
child: CustomPaint(
|
||||
painter: _MotionTrackPainter(
|
||||
xPosition: x,
|
||||
velocity: v,
|
||||
acceleration: _a,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Motion Controls
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _isMoving ? AppColors.crimsonRed : AppColors.emeraldGreen,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
onPressed: _toggleMotion,
|
||||
icon: Icon(_isMoving ? CupertinoIcons.pause_fill : CupertinoIcons.play_arrow_solid, size: 18),
|
||||
label: Text(_isMoving ? 'إيقاف مؤقت ⏸' : 'تشغيل الحركة ▶', style: const TextStyle(fontWeight: FontWeight.w800)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: const BorderSide(color: AppColors.darkCardBorder),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
onPressed: _resetMotion,
|
||||
icon: const Icon(CupertinoIcons.arrow_counterclockwise, size: 16),
|
||||
label: const Text('إعادة ضبط', style: TextStyle(fontWeight: FontWeight.w700)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Sliders
|
||||
LuxuryCard(
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('السرعة الابتدائية (v₀):', style: TextStyle(color: AppColors.saqelCyan, fontWeight: FontWeight.w700, fontSize: 13)),
|
||||
Text('${_v0.toInt()} m/s', style: const TextStyle(color: AppColors.saqelCyan, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
CupertinoSlider(
|
||||
value: _v0,
|
||||
min: -15.0,
|
||||
max: 25.0,
|
||||
activeColor: AppColors.saqelCyan,
|
||||
onChanged: (v) => setState(() {
|
||||
_v0 = v;
|
||||
if (!_isMoving) _resetMotion();
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text('التسارع الثابت (a):', style: TextStyle(color: AppColors.guardianAmber, fontWeight: FontWeight.w700, fontSize: 13)),
|
||||
Text('${_a.toStringAsFixed(1)} m/s²', style: const TextStyle(color: AppColors.guardianAmber, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
CupertinoSlider(
|
||||
value: _a,
|
||||
min: -6.0,
|
||||
max: 6.0,
|
||||
activeColor: AppColors.guardianAmber,
|
||||
onChanged: (v) => setState(() {
|
||||
_a = v;
|
||||
if (!_isMoving) _resetMotion();
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Real-time Readout Card
|
||||
LuxuryCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('القراءات اللحظية للحركة (Kinematics Realtime):', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 13)),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('الزمن: ${_simTime.toStringAsFixed(2)} s', style: const TextStyle(color: AppColors.saqelCyan, fontWeight: FontWeight.w600)),
|
||||
Text('الموقع x: ${x.toStringAsFixed(2)} m', style: const TextStyle(color: AppColors.emeraldGreen, fontWeight: FontWeight.bold)),
|
||||
Text('السرعة v: ${v.toStringAsFixed(2)} m/s', style: const TextStyle(color: AppColors.guardianAmber, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text('القانون المطبق: x(t) = v₀t + ½at² • v(t) = v₀ + at', style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 11.5)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// CUSTOM PAINTER: VECTORS ON 2D CARTESIAN GRID
|
||||
// ==============================================================================
|
||||
class _VectorCanvasPainter extends CustomPainter {
|
||||
final double magA;
|
||||
final double angA;
|
||||
final double magB;
|
||||
final double angB;
|
||||
final bool showResultant;
|
||||
|
||||
_VectorCanvasPainter({
|
||||
required this.magA,
|
||||
required this.angA,
|
||||
required this.magB,
|
||||
required this.angB,
|
||||
required this.showResultant,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final cx = size.width / 2;
|
||||
final cy = size.height / 2;
|
||||
|
||||
// 1. Grid Background
|
||||
final gridPaint = Paint()
|
||||
..color = Colors.white.withAlpha(12)
|
||||
..strokeWidth = 1;
|
||||
|
||||
for (double x = 0; x < size.width; x += 25) {
|
||||
canvas.drawLine(Offset(x, 0), Offset(x, size.height), gridPaint);
|
||||
}
|
||||
for (double y = 0; y < size.height; y += 25) {
|
||||
canvas.drawLine(Offset(0, y), Offset(size.width, y), gridPaint);
|
||||
}
|
||||
|
||||
// 2. Axes
|
||||
final axisPaint = Paint()
|
||||
..color = Colors.white.withAlpha(40)
|
||||
..strokeWidth = 1.5;
|
||||
canvas.drawLine(Offset(0, cy), Offset(size.width, cy), axisPaint);
|
||||
canvas.drawLine(Offset(cx, 0), Offset(cx, size.height), axisPaint);
|
||||
|
||||
// 3. Permanent Watermark (Saqel Lab - Anti-Copy Protection)
|
||||
const watermarkSpan = TextSpan(
|
||||
text: 'منصة صَقِل التعليمية الذكية © Saqel Lab',
|
||||
style: TextStyle(color: Color(0x3300F5D4), fontSize: 11, fontWeight: FontWeight.bold),
|
||||
);
|
||||
final watermarkPainter = TextPainter(text: watermarkSpan, textDirection: TextDirection.rtl)..layout();
|
||||
watermarkPainter.paint(canvas, const Offset(12, 12));
|
||||
|
||||
// Scale factor: pixels per Newton
|
||||
const scale = 1.0;
|
||||
|
||||
// Vector A
|
||||
final aRad = angA * math.pi / 180.0;
|
||||
final ax = magA * math.cos(aRad) * scale;
|
||||
final ay = -magA * math.sin(aRad) * scale; // Screen Y inverted
|
||||
_drawArrow(canvas, Offset(cx, cy), Offset(cx + ax, cy + ay), AppColors.saqelCyan, 3.5, label: 'A (${magA.toInt()} N)');
|
||||
|
||||
// Vector B
|
||||
final bRad = angB * math.pi / 180.0;
|
||||
final bx = magB * math.cos(bRad) * scale;
|
||||
final by = -magB * math.sin(bRad) * scale;
|
||||
_drawArrow(canvas, Offset(cx, cy), Offset(cx + bx, cy + by), AppColors.guardianAmber, 3.5, label: 'B (${magB.toInt()} N)');
|
||||
|
||||
// Resultant Vector R
|
||||
if (showResultant) {
|
||||
final rx = ax + bx;
|
||||
final ry = ay + by;
|
||||
final rMag = math.sqrt(rx * rx + ry * ry) / scale;
|
||||
|
||||
// Parallelogram guide lines
|
||||
final guidePaint = Paint()
|
||||
..color = Colors.white.withAlpha(30)
|
||||
..strokeWidth = 1;
|
||||
canvas.drawLine(Offset(cx + ax, cy + ay), Offset(cx + rx, cy + ry), guidePaint);
|
||||
canvas.drawLine(Offset(cx + bx, cy + by), Offset(cx + rx, cy + ry), guidePaint);
|
||||
|
||||
_drawArrow(canvas, Offset(cx, cy), Offset(cx + rx, cy + ry), AppColors.emeraldGreen, 4.5, label: 'R (${rMag.toInt()} N)');
|
||||
}
|
||||
}
|
||||
|
||||
void _drawArrow(Canvas canvas, Offset from, Offset to, Color color, double width, {String? label}) {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..strokeWidth = width
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
canvas.drawLine(from, to, paint);
|
||||
|
||||
final dx = to.dx - from.dx;
|
||||
final dy = to.dy - from.dy;
|
||||
final angle = math.atan2(dy, dx);
|
||||
const headLen = 12.0;
|
||||
|
||||
final path = Path()
|
||||
..moveTo(to.dx, to.dy)
|
||||
..lineTo(to.dx - headLen * math.cos(angle - math.pi / 6), to.dy - headLen * math.sin(angle - math.pi / 6))
|
||||
..lineTo(to.dx - headLen * math.cos(angle + math.pi / 6), to.dy - headLen * math.sin(angle + math.pi / 6))
|
||||
..close();
|
||||
|
||||
final fillPaint = Paint()..color = color;
|
||||
canvas.drawPath(path, fillPaint);
|
||||
|
||||
if (label != null) {
|
||||
final textSpan = TextSpan(
|
||||
text: label,
|
||||
style: TextStyle(color: color, fontSize: 11.5, fontWeight: FontWeight.w800),
|
||||
);
|
||||
final textPainter = TextPainter(text: textSpan, textDirection: TextDirection.ltr)..layout();
|
||||
textPainter.paint(canvas, Offset(to.dx + 6, to.dy - 12));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _VectorCanvasPainter oldDelegate) => true;
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// CUSTOM PAINTER: 1D MOTION TRACK & CAR
|
||||
// ==============================================================================
|
||||
class _MotionTrackPainter extends CustomPainter {
|
||||
final double xPosition;
|
||||
final double velocity;
|
||||
final double acceleration;
|
||||
|
||||
_MotionTrackPainter({
|
||||
required this.xPosition,
|
||||
required this.velocity,
|
||||
required this.acceleration,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final w = size.width;
|
||||
final trackY = size.height * 0.6;
|
||||
final originX = w * 0.35;
|
||||
const scale = 2.0; // pixels per meter
|
||||
|
||||
// 1. Watermark
|
||||
const watermarkSpan = TextSpan(
|
||||
text: 'منصة صَقِل التعليمية الذكية © Saqel Lab',
|
||||
style: TextStyle(color: Color(0x3300F5D4), fontSize: 11, fontWeight: FontWeight.bold),
|
||||
);
|
||||
final watermarkPainter = TextPainter(text: watermarkSpan, textDirection: TextDirection.rtl)..layout();
|
||||
watermarkPainter.paint(canvas, const Offset(12, 12));
|
||||
|
||||
// 2. Track Line
|
||||
final trackPaint = Paint()
|
||||
..color = Colors.white.withAlpha(35)
|
||||
..strokeWidth = 2;
|
||||
canvas.drawLine(Offset(20, trackY), Offset(w - 20, trackY), trackPaint);
|
||||
|
||||
// 3. Markings (-40m to +80m)
|
||||
for (int m = -40; m <= 80; m += 20) {
|
||||
final px = originX + m * scale;
|
||||
if (px >= 20 && px <= w - 20) {
|
||||
final isOrigin = (m == 0);
|
||||
final markPaint = Paint()
|
||||
..color = isOrigin ? AppColors.saqelCyan : Colors.white.withAlpha(50)
|
||||
..strokeWidth = isOrigin ? 2 : 1;
|
||||
canvas.drawLine(Offset(px, trackY - 8), Offset(px, trackY + 8), markPaint);
|
||||
|
||||
final textSpan = TextSpan(
|
||||
text: '${m}m',
|
||||
style: TextStyle(color: isOrigin ? AppColors.saqelCyan : AppColors.textSecondaryDark, fontSize: 10),
|
||||
);
|
||||
final textPainter = TextPainter(text: textSpan, textDirection: TextDirection.ltr)..layout();
|
||||
textPainter.paint(canvas, Offset(px - textPainter.width / 2, trackY + 12));
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Vehicle Body
|
||||
final carX = (originX + xPosition * scale).clamp(30.0, w - 30.0);
|
||||
final carRect = RRect.fromRectAndRadius(
|
||||
Rect.fromCenter(center: Offset(carX, trackY - 14), width: 44, height: 22),
|
||||
const Radius.circular(6),
|
||||
);
|
||||
final carPaint = Paint()..color = AppColors.appleBlue;
|
||||
canvas.drawRRect(carRect, carPaint);
|
||||
final borderPaint = Paint()
|
||||
..color = AppColors.saqelCyan
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.5;
|
||||
canvas.drawRRect(carRect, borderPaint);
|
||||
|
||||
// Wheels
|
||||
final wheelPaint = Paint()..color = const Color(0xFF1E293B);
|
||||
canvas.drawCircle(Offset(carX - 12, trackY - 2), 5, wheelPaint);
|
||||
canvas.drawCircle(Offset(carX + 12, trackY - 2), 5, wheelPaint);
|
||||
|
||||
// 5. Velocity Vector Arrow
|
||||
if (velocity.abs() > 0.3) {
|
||||
final vLen = (velocity * 2.5).clamp(-50.0, 50.0);
|
||||
final vPaint = Paint()
|
||||
..color = AppColors.saqelCyan
|
||||
..strokeWidth = 2.5;
|
||||
canvas.drawLine(Offset(carX, trackY - 32), Offset(carX + vLen, trackY - 32), vPaint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _MotionTrackPainter oldDelegate) => true;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import '../../widgets/luxury_widgets.dart';
|
||||
import '../player/socratic_video_player_screen.dart';
|
||||
import '../exams/adaptive_exam_screen.dart';
|
||||
import 'curriculum_document_viewer_screen.dart';
|
||||
import 'physics_interactive_lab_view.dart';
|
||||
import '../../../data/models/exam_model.dart';
|
||||
import '../../../data/repositories/app_repositories.dart';
|
||||
|
||||
@@ -38,10 +39,16 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
|
||||
late TabController _tabController;
|
||||
late Future<List<ExamModel>> _examsFuture;
|
||||
|
||||
bool get _hasLab =>
|
||||
widget.subject.id.contains('physic') ||
|
||||
widget.subject.title.contains('فيزياء') ||
|
||||
widget.subject.id.contains('chem') ||
|
||||
widget.subject.id.contains('science');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 4, vsync: this);
|
||||
_tabController = TabController(length: _hasLab ? 5 : 4, vsync: this);
|
||||
_examsFuture = ExamRepository().getExams(courseId: 1, scope: 'unit_exam');
|
||||
}
|
||||
|
||||
@@ -72,12 +79,14 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
|
||||
indicatorWeight: 3,
|
||||
labelColor: Colors.white,
|
||||
unselectedLabelColor: AppColors.textSecondaryDark,
|
||||
isScrollable: _hasLab,
|
||||
labelStyle: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13),
|
||||
tabs: const [
|
||||
Tab(icon: Icon(CupertinoIcons.play_rectangle_fill, size: 18), text: 'الفيديوهات'),
|
||||
Tab(icon: Icon(CupertinoIcons.doc_text_fill, size: 18), text: 'أوراق العمل'),
|
||||
Tab(icon: Icon(CupertinoIcons.sparkles, size: 18), text: 'بنك الأسئلة'),
|
||||
Tab(icon: Icon(CupertinoIcons.book_fill, size: 18), text: 'الكتب المقررة'),
|
||||
tabs: [
|
||||
const Tab(icon: Icon(CupertinoIcons.play_rectangle_fill, size: 18), text: 'الفيديوهات'),
|
||||
if (_hasLab) const Tab(icon: Icon(CupertinoIcons.lab_flask_solid, size: 18), text: 'المختبر التفاعلي 🧪'),
|
||||
const Tab(icon: Icon(CupertinoIcons.sparkles, size: 18), text: 'بنك الأسئلة'),
|
||||
const Tab(icon: Icon(CupertinoIcons.doc_text_fill, size: 18), text: 'أوراق العمل'),
|
||||
const Tab(icon: Icon(CupertinoIcons.book_fill, size: 18), text: 'الكتب المقررة'),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -85,8 +94,9 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
|
||||
controller: _tabController,
|
||||
children: [
|
||||
_buildLessonsTab(context),
|
||||
_buildWorksheetsTab(context),
|
||||
if (_hasLab) const PhysicsInteractiveLabView(),
|
||||
_buildExamsTab(context),
|
||||
_buildWorksheetsTab(context),
|
||||
_buildTextbooksTab(context),
|
||||
],
|
||||
),
|
||||
@@ -263,7 +273,7 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
|
||||
IconButton(
|
||||
icon: const Icon(CupertinoIcons.arrow_down_circle_fill, color: AppColors.saqelCyan, size: 28),
|
||||
onPressed: () {
|
||||
_showResourceSheet(context, ws.title, 'ورقة عمل ومذكرة مراجعة', 'PDF جاهز للطباعة بدقة عالية');
|
||||
_showResourceSheet(context, ws.title, 'ورقة عمل ومذكرة مراجعة', 'PDF جاهز للطباعة بدقة عالية', filePath: ws.filePath);
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -446,7 +456,7 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
|
||||
IconButton(
|
||||
icon: const Icon(CupertinoIcons.eye_fill, color: AppColors.saqelCyan, size: 24),
|
||||
onPressed: () {
|
||||
_showResourceSheet(context, tb.title, 'الكتاب المدرسي المعتمد', 'نسخة وزارة التربية والتعليم المنقحة والمحدثة');
|
||||
_showResourceSheet(context, tb.title, 'الكتاب المدرسي المعتمد', 'نسخة وزارة التربية والتعليم المنقحة والمحدثة', filePath: tb.filePath);
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -457,7 +467,7 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
|
||||
);
|
||||
}
|
||||
|
||||
void _showResourceSheet(BuildContext context, String title, String subtitle, String description) {
|
||||
void _showResourceSheet(BuildContext context, String title, String subtitle, String description, {String? filePath}) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: AppColors.darkSurface,
|
||||
@@ -541,6 +551,8 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
|
||||
title: title,
|
||||
documentType: title.contains('كتاب') ? 'textbook' : 'worksheet',
|
||||
subjectTitle: widget.subject.title,
|
||||
subjectId: widget.subject.id,
|
||||
filePath: filePath,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -558,8 +570,99 @@ class _SubjectHubScreenState extends State<SubjectHubScreen> with SingleTickerPr
|
||||
);
|
||||
}
|
||||
|
||||
/// Empty state when no video exists yet for this lesson (Zero Mock Videos)
|
||||
void _showNoVideoAvailableSheet(BuildContext context, CurriculumLessonItemModel lesson) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: AppColors.darkSurface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.guardianAmber.withAlpha(25),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: const Icon(CupertinoIcons.videocam_fill, color: AppColors.guardianAmber, size: 26),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'لا يتوفر فيديو شرح لهذا الدرس حالياً 🎬',
|
||||
style: TextStyle(color: Colors.white, fontWeight: FontWeight.w800, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
lesson.title,
|
||||
style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'شروحات هذا الدرس قيد التصوير والمراجعة من قبل نخبة المعلمين المعتمدين وفريق منصة صَقِل.\nيمكنك حالياً دراسة نتاجات وملخص الدرس عبر تبويب الكتب والمذكرات، أو خوض الاختبار التكيفي.',
|
||||
style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 13, height: 1.5),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.appleBlue,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
Navigator.of(context).push(
|
||||
CupertinoPageRoute(
|
||||
builder: (_) => CurriculumDocumentViewerScreen(
|
||||
title: lesson.title,
|
||||
documentType: 'lesson',
|
||||
subjectTitle: widget.subject.title,
|
||||
subjectId: widget.subject.id,
|
||||
filePath: lesson.markdownFilePath,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(CupertinoIcons.book, size: 18),
|
||||
label: const Text('قراءة محتوى وملخص الدرس 📖', style: TextStyle(fontWeight: FontWeight.w800)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Multi-Teacher and AI Video Selector Bottom Sheet
|
||||
void _showLessonVideoSelector(BuildContext context, CurriculumLessonItemModel lesson) {
|
||||
if (!lesson.hasVideo) {
|
||||
_showNoVideoAvailableSheet(context, lesson);
|
||||
return;
|
||||
}
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: AppColors.darkSurface,
|
||||
|
||||
@@ -164,6 +164,16 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
|
||||
_isVideoInitialized = true;
|
||||
});
|
||||
_revealVideoControls();
|
||||
if (state.currentPositionSeconds > 3) {
|
||||
_videoController!.seekTo(Duration(seconds: state.currentPositionSeconds));
|
||||
if (context.mounted) {
|
||||
SaqelToast.showInfo(
|
||||
context,
|
||||
'تم استئناف المشاهدة من الدقيقة ${_formatTime(state.currentPositionSeconds)} ⏱️',
|
||||
title: 'استئناف المشاهدة',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (state.isPlaying && state.activeCheckpoint == null) {
|
||||
_videoController!.play();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_tts/flutter_tts.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/utils/saqel_toast.dart';
|
||||
|
||||
/// ==============================================================================
|
||||
/// SAQEL ENTERPRISE (EDTECH 2.0) - ENGLISH TEXT-TO-SPEECH (TTS) AUDIO COMPANION
|
||||
/// ==============================================================================
|
||||
///
|
||||
/// ويدجت الناطق الصوتي الذكي لنصوص وقراءات ومفردات اللغة الإنجليزية:
|
||||
/// 1. يعمل محلياً عبر محركات Apple AVFoundation و Google TTS فائقة الدقة.
|
||||
/// 2. تحكم في سرعة النطق (0.5x للمبتدئين، 0.8x معيارية، 1.0x طبيعية).
|
||||
/// 3. دعم التبديل بين اللهجة الأمريكية (en-US) والبريطانية (en-GB).
|
||||
/// 4. شريط تحكم عائم فاخر بأسلوب كوبرتينو الداكن.
|
||||
class EnglishTtsPlayerWidget extends StatefulWidget {
|
||||
final String textToRead;
|
||||
final String? title;
|
||||
|
||||
const EnglishTtsPlayerWidget({
|
||||
super.key,
|
||||
required this.textToRead,
|
||||
this.title,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EnglishTtsPlayerWidget> createState() => _EnglishTtsPlayerWidgetState();
|
||||
}
|
||||
|
||||
class _EnglishTtsPlayerWidgetState extends State<EnglishTtsPlayerWidget> {
|
||||
late FlutterTts _flutterTts;
|
||||
bool _isPlaying = false;
|
||||
double _speechRate = 0.45; // Moderate pace ideal for ESL learners
|
||||
String _selectedLanguage = 'en-US';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initTts();
|
||||
}
|
||||
|
||||
Future<void> _initTts() async {
|
||||
_flutterTts = FlutterTts();
|
||||
|
||||
await _flutterTts.setLanguage(_selectedLanguage);
|
||||
await _flutterTts.setSpeechRate(_speechRate);
|
||||
await _flutterTts.setVolume(1.0);
|
||||
await _flutterTts.setPitch(1.0);
|
||||
|
||||
_flutterTts.setStartHandler(() {
|
||||
if (mounted) setState(() => _isPlaying = true);
|
||||
});
|
||||
|
||||
_flutterTts.setCompletionHandler(() {
|
||||
if (mounted) setState(() => _isPlaying = false);
|
||||
});
|
||||
|
||||
_flutterTts.setCancelHandler(() {
|
||||
if (mounted) setState(() => _isPlaying = false);
|
||||
});
|
||||
|
||||
_flutterTts.setErrorHandler((msg) {
|
||||
if (mounted) {
|
||||
setState(() => _isPlaying = false);
|
||||
SaqelToast.showError(context, 'تعذر تشغيل الصوت: $msg', title: 'الناطق الصوتي');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_flutterTts.stop();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _togglePlayPause() async {
|
||||
if (_isPlaying) {
|
||||
await _flutterTts.stop();
|
||||
if (mounted) setState(() => _isPlaying = false);
|
||||
} else {
|
||||
if (widget.textToRead.trim().isEmpty) {
|
||||
SaqelToast.showInfo(context, 'لا يوجد نص للقراءة حالياً', title: 'تنبيه');
|
||||
return;
|
||||
}
|
||||
await _flutterTts.setLanguage(_selectedLanguage);
|
||||
await _flutterTts.setSpeechRate(_speechRate);
|
||||
await _flutterTts.speak(widget.textToRead);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cycleSpeed() async {
|
||||
setState(() {
|
||||
if (_speechRate <= 0.35) {
|
||||
_speechRate = 0.48; // Normal
|
||||
} else if (_speechRate <= 0.50) {
|
||||
_speechRate = 0.65; // Fast
|
||||
} else {
|
||||
_speechRate = 0.30; // Slow
|
||||
}
|
||||
});
|
||||
await _flutterTts.setSpeechRate(_speechRate);
|
||||
final label = _speechRate < 0.4 ? 'بطيء (0.5x)' : (_speechRate < 0.6 ? 'معياري (0.8x)' : 'طبيعي (1.0x)');
|
||||
if (mounted) SaqelToast.showInfo(context, 'سرعة القراءة: $label', title: 'سرعة النطق');
|
||||
}
|
||||
|
||||
Future<void> _toggleAccent() async {
|
||||
setState(() {
|
||||
_selectedLanguage = _selectedLanguage == 'en-US' ? 'en-GB' : 'en-US';
|
||||
});
|
||||
await _flutterTts.setLanguage(_selectedLanguage);
|
||||
final accentName = _selectedLanguage == 'en-US' ? 'أمريكية (US 🇺🇸)' : 'بريطانية (UK 🇬🇧)';
|
||||
if (mounted) SaqelToast.showInfo(context, 'تم اختيار اللهجة: $accentName', title: 'لهجة القراءة');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final speedLabel = _speechRate < 0.4 ? '0.5x' : (_speechRate < 0.6 ? '0.8x' : '1.0x');
|
||||
final accentLabel = _selectedLanguage == 'en-US' ? 'US 🇺🇸' : 'UK 🇬🇧';
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0F172A),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.saqelCyan.withAlpha(60)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.saqelCyan.withAlpha(20),
|
||||
blurRadius: 16,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Play / Stop Button
|
||||
GestureDetector(
|
||||
onTap: _togglePlayPause,
|
||||
child: Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: _isPlaying ? AppColors.crimsonRed : AppColors.saqelCyan,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: (_isPlaying ? AppColors.crimsonRed : AppColors.saqelCyan).withAlpha(80),
|
||||
blurRadius: 10,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
_isPlaying ? CupertinoIcons.stop_fill : CupertinoIcons.volume_up,
|
||||
color: Colors.black,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Title & Status
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
widget.title ?? 'الناطق الصوتي الذكي (English Audio Reader)',
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 12.5),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
_isPlaying ? 'جاري الاستماع بنقاء صوتي عالي... 🎧' : 'اضغط للاستماع للنطق السليم للدرس',
|
||||
style: TextStyle(
|
||||
color: _isPlaying ? AppColors.saqelCyan : AppColors.textSecondaryDark,
|
||||
fontSize: 11,
|
||||
fontWeight: _isPlaying ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Speed Control Chip
|
||||
GestureDetector(
|
||||
onTap: _cycleSpeed,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withAlpha(15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.white24),
|
||||
),
|
||||
child: Text(
|
||||
speedLabel,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
|
||||
// Accent Control Chip
|
||||
GestureDetector(
|
||||
onTap: _toggleAccent,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.saqelCyan.withAlpha(20),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppColors.saqelCyan.withAlpha(60)),
|
||||
),
|
||||
child: Text(
|
||||
accentLabel,
|
||||
style: const TextStyle(color: AppColors.saqelCyan, fontSize: 11, fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user