Update Saqel Platform: 2026-09-12 13:38:49
This commit is contained in:
@@ -0,0 +1,561 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../data/repositories/curriculum_question_bank.dart';
|
||||
import '../../../data/repositories/error_notebook_repository.dart';
|
||||
import '../../widgets/luxury_widgets.dart';
|
||||
|
||||
/// Interactive Lesson Homework Sheet
|
||||
/// Enables students to practice questions derived from textbook lesson examples.
|
||||
class LessonHomeworkSheet extends StatefulWidget {
|
||||
final String subjectId;
|
||||
final String subjectTitle;
|
||||
final String lessonId;
|
||||
final String lessonTitle;
|
||||
|
||||
const LessonHomeworkSheet({
|
||||
super.key,
|
||||
required this.subjectId,
|
||||
required this.subjectTitle,
|
||||
required this.lessonId,
|
||||
required this.lessonTitle,
|
||||
});
|
||||
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required String subjectId,
|
||||
required String subjectTitle,
|
||||
required String lessonId,
|
||||
required String lessonTitle,
|
||||
}) {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (ctx) => FractionallySizedBox(
|
||||
heightFactor: 0.92,
|
||||
child: LessonHomeworkSheet(
|
||||
subjectId: subjectId,
|
||||
subjectTitle: subjectTitle,
|
||||
lessonId: lessonId,
|
||||
lessonTitle: lessonTitle,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<LessonHomeworkSheet> createState() => _LessonHomeworkSheetState();
|
||||
}
|
||||
|
||||
class _LessonHomeworkSheetState extends State<LessonHomeworkSheet> {
|
||||
late LessonHomeworkModel _homework;
|
||||
int _currentIndex = 0;
|
||||
int? _selectedOption;
|
||||
bool _hasChecked = false;
|
||||
int _correctCount = 0;
|
||||
bool _isCompleted = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_homework = CurriculumQuestionBank.getLessonHomework(
|
||||
subjectId: widget.subjectId,
|
||||
lessonId: widget.lessonId,
|
||||
lessonTitle: widget.lessonTitle,
|
||||
);
|
||||
}
|
||||
|
||||
void _checkAnswer() {
|
||||
if (_selectedOption == null || _hasChecked) return;
|
||||
setState(() {
|
||||
_hasChecked = true;
|
||||
if (_selectedOption == _homework.questions[_currentIndex].correctIndex) {
|
||||
_correctCount++;
|
||||
} else {
|
||||
_recordMistakeToNotebook(_homework.questions[_currentIndex], _selectedOption!);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _recordMistakeToNotebook(LessonHomeworkItem q, int selectedOpt) {
|
||||
final wrongAnswer = (selectedOpt >= 0 && selectedOpt < q.options.length)
|
||||
? q.options[selectedOpt]
|
||||
: 'إجابة غير صحيحة';
|
||||
final correctAnswer = (q.correctIndex >= 0 && q.correctIndex < q.options.length)
|
||||
? q.options[q.correctIndex]
|
||||
: '';
|
||||
ErrorNotebookRepository().logError(
|
||||
subjectId: widget.subjectId,
|
||||
subjectName: widget.subjectTitle,
|
||||
topicName: widget.lessonTitle,
|
||||
sourceType: 'adaptive_exam',
|
||||
questionText: q.questionText,
|
||||
studentWrongAnswer: wrongAnswer,
|
||||
correctAnswer: correctAnswer,
|
||||
hint: q.explanation,
|
||||
errorCategory: 'conceptual',
|
||||
options: q.options,
|
||||
);
|
||||
}
|
||||
|
||||
void _nextQuestion() {
|
||||
if (_currentIndex + 1 < _homework.questions.length) {
|
||||
setState(() {
|
||||
_currentIndex++;
|
||||
_selectedOption = null;
|
||||
_hasChecked = false;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_isCompleted = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _restart() {
|
||||
setState(() {
|
||||
_currentIndex = 0;
|
||||
_selectedOption = null;
|
||||
_hasChecked = false;
|
||||
_correctCount = 0;
|
||||
_isCompleted = false;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.darkBackground,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
border: Border(
|
||||
top: BorderSide(color: AppColors.darkCardBorder, width: 1.5),
|
||||
),
|
||||
),
|
||||
child: Directionality(
|
||||
textDirection: TextDirection.rtl,
|
||||
child: Column(
|
||||
children: [
|
||||
// Sheet Handle
|
||||
Center(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(top: 10, bottom: 8),
|
||||
width: 44,
|
||||
height: 4.5,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white24,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Header Bar
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.appleBlue.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(CupertinoIcons.pencil_ellipsis_rectangle,
|
||||
color: AppColors.saqelCyan, size: 20),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'واجب الدرس: ${widget.lessonTitle}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 15,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
'${widget.subjectTitle} • تمارين تطبيقية على نمط المنهاج الوزاري',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondaryDark,
|
||||
fontSize: 11.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(CupertinoIcons.xmark_circle_fill,
|
||||
color: Colors.white38, size: 24),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(color: AppColors.darkCardBorder, height: 1),
|
||||
|
||||
// Content Area
|
||||
Expanded(
|
||||
child: _isCompleted ? _buildCompletedView() : _buildQuestionView(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuestionView() {
|
||||
if (_homework.questions.isEmpty) {
|
||||
return const Center(
|
||||
child: Text(
|
||||
'لا توجد أسئلة واجب لهذا الدرس حالياً.',
|
||||
style: TextStyle(color: AppColors.textSecondaryDark),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final q = _homework.questions[_currentIndex];
|
||||
final total = _homework.questions.length;
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
|
||||
children: [
|
||||
// Progress Row
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'التمرين ${_currentIndex + 1} من $total',
|
||||
style: const TextStyle(
|
||||
color: AppColors.saqelCyan,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 12.5,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.emeraldGreen.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'${q.points} نقاط إتقان',
|
||||
style: const TextStyle(
|
||||
color: AppColors.emeraldGreen,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Linear Progress
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: (_currentIndex + 1) / total,
|
||||
backgroundColor: Colors.white10,
|
||||
valueColor: const AlwaysStoppedAnimation(AppColors.saqelCyan),
|
||||
minHeight: 5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// Question Card
|
||||
LuxuryCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
q.questionText,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 15,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Options
|
||||
...List.generate(q.options.length, (idx) {
|
||||
final isSelected = _selectedOption == idx;
|
||||
final isCorrect = idx == q.correctIndex;
|
||||
|
||||
Color borderColor = Colors.white12;
|
||||
Color bgColor = const Color(0xFF09111E);
|
||||
Widget? trailingIcon;
|
||||
|
||||
if (_hasChecked) {
|
||||
if (isCorrect) {
|
||||
borderColor = AppColors.emeraldGreen;
|
||||
bgColor = AppColors.emeraldGreen.withValues(alpha: 0.15);
|
||||
trailingIcon = const Icon(CupertinoIcons.checkmark_circle_fill,
|
||||
color: AppColors.emeraldGreen, size: 20);
|
||||
} else if (isSelected) {
|
||||
borderColor = AppColors.crimsonRed;
|
||||
bgColor = AppColors.crimsonRed.withValues(alpha: 0.15);
|
||||
trailingIcon = const Icon(CupertinoIcons.xmark_circle_fill,
|
||||
color: AppColors.crimsonRed, size: 20);
|
||||
}
|
||||
} else if (isSelected) {
|
||||
borderColor = AppColors.saqelCyan;
|
||||
bgColor = AppColors.saqelCyan.withValues(alpha: 0.12);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: _hasChecked
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
_selectedOption = idx;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: borderColor, width: isSelected || (_hasChecked && isCorrect) ? 1.5 : 1),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 26,
|
||||
height: 26,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isSelected ? AppColors.saqelCyan : Colors.white10,
|
||||
),
|
||||
child: Text(
|
||||
String.fromCharCode(0x0623 + idx), // أ, ب, ج, د
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.black : Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
q.options[idx],
|
||||
style: TextStyle(
|
||||
color: isSelected || (_hasChecked && isCorrect) ? Colors.white : Colors.white70,
|
||||
fontSize: 13.5,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (trailingIcon != null) trailingIcon,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Explanation Card upon verification
|
||||
if (_hasChecked) ...[
|
||||
const SizedBox(height: 14),
|
||||
LuxuryCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
_selectedOption == q.correctIndex
|
||||
? CupertinoIcons.check_mark_circled_solid
|
||||
: CupertinoIcons.info_circle_fill,
|
||||
color: _selectedOption == q.correctIndex
|
||||
? AppColors.emeraldGreen
|
||||
: AppColors.guardianAmber,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_selectedOption == q.correctIndex
|
||||
? 'إجابة صحيحة! خطوات التعليل والحل النموذجي:'
|
||||
: 'الشرح التوضيحي والتعليل الشرعي / العلمي:',
|
||||
style: TextStyle(
|
||||
color: _selectedOption == q.correctIndex
|
||||
? AppColors.emeraldGreen
|
||||
: AppColors.guardianAmber,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
q.explanation,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
if (q.ruleTakeaway != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.white12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(CupertinoIcons.lightbulb_fill, color: AppColors.saqelCyan, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'القاعدة الذهبية: ${q.ruleTakeaway!}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.saqelCyan,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Action Button
|
||||
if (!_hasChecked)
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _selectedOption != null ? AppColors.appleBlue : Colors.white12,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
onPressed: _selectedOption != null ? _checkAnswer : null,
|
||||
child: const Text(
|
||||
'تحقق من الإجابة 🚀',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w800),
|
||||
),
|
||||
)
|
||||
else
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.saqelCyan,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
onPressed: _nextQuestion,
|
||||
child: Text(
|
||||
_currentIndex + 1 < total ? 'التمرين التالي ⬅️' : 'عرض النتيجة النهائية 🏆',
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w900),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompletedView() {
|
||||
final total = _homework.questions.length;
|
||||
final percentage = total > 0 ? (_correctCount / total) * 100 : 0;
|
||||
final isMastered = percentage >= 70;
|
||||
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: isMastered
|
||||
? AppColors.emeraldGreen.withValues(alpha: 0.15)
|
||||
: AppColors.guardianAmber.withValues(alpha: 0.15),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
isMastered ? CupertinoIcons.rosette : CupertinoIcons.refresh_circled,
|
||||
color: isMastered ? AppColors.emeraldGreen : AppColors.guardianAmber,
|
||||
size: 54,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
isMastered ? 'أحسنت! أتممت واجب الدرس بنجاح 🌟' : 'اكتمل حل التمارين! يمكنك المحاولة مجدداً لترسيخ الفهم 📚',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 16.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'أجبت عن $_correctCount من أصل $total أسئلة بشكل صحيح (${percentage.toStringAsFixed(0)}%)',
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondaryDark,
|
||||
fontSize: 13.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: const BorderSide(color: Colors.white24),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
onPressed: _restart,
|
||||
icon: const Icon(CupertinoIcons.refresh, size: 18),
|
||||
label: const Text('إعادة الحل', style: TextStyle(fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
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(context).pop(),
|
||||
icon: const Icon(CupertinoIcons.check_mark, size: 18),
|
||||
label: const Text('تم الفهم', style: TextStyle(fontWeight: FontWeight.w800)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+251
-55
@@ -48,6 +48,10 @@ class _MathInteractiveLabViewState extends State<MathInteractiveLabView>
|
||||
// Selected Textbook Exercise Index (0 to 5)
|
||||
int _selectedExerciseIndex = 0;
|
||||
|
||||
// Scaffolded Problem Solving & 60-Second Thinking Pause
|
||||
int _revealedStep = 1;
|
||||
bool _thinkingPauseCompleted = false;
|
||||
|
||||
final List<Map<String, dynamic>> _textbookExercises = [
|
||||
{
|
||||
'title': 'نشاط معمل جيوجبرا (صفحة 16)',
|
||||
@@ -900,7 +904,7 @@ class _MathInteractiveLabViewState extends State<MathInteractiveLabView>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TAB 3: STEP-BY-STEP ALGEBRAIC SOLVER & SOCRATIC DISCRIMINANT RADAR
|
||||
// TAB 3: STEP-BY-STEP ALGEBRAIC SOLVER & PROGRESSIVE THINKING RADAR
|
||||
// ============================================================================
|
||||
Widget _buildStepByStepSolverView() {
|
||||
final active = _textbookExercises[_selectedExerciseIndex];
|
||||
@@ -910,6 +914,104 @@ class _MathInteractiveLabViewState extends State<MathInteractiveLabView>
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 1. Thinking Pause Card (مهلة التفكير البناء - دقيقة واحدة)
|
||||
if (!_thinkingPauseCompleted)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF1E293B), Color(0xFF0F172A)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: AppColors.guardianAmber, width: 1.5),
|
||||
boxShadow: const [
|
||||
BoxShadow(color: Colors.black45, blurRadius: 10, offset: Offset(0, 4)),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.guardianAmber.withAlpha(30),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(CupertinoIcons.lightbulb_fill, color: AppColors.guardianAmber, size: 22),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'وقفة تفكير ذهني قبل الحل (Thinking Pause) ⏱️',
|
||||
style: TextStyle(color: Colors.white, fontSize: 14.5, fontWeight: FontWeight.w800),
|
||||
),
|
||||
Text(
|
||||
'امنح عقلك دقيقة لتحديد استراتيجية الحل قبل كشف الخطوات',
|
||||
style: TextStyle(color: AppColors.guardianAmber, fontSize: 11, fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black45,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.white12),
|
||||
),
|
||||
child: Text(
|
||||
'نظام المعادلات المراد حله:\n${active['system']}',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Courier',
|
||||
color: AppColors.saqelCyan,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'💡 أسئلة التوجيه الذهني:',
|
||||
style: TextStyle(color: Colors.white, fontSize: 12.5, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'1. أي المعادلتين أسهل لعزل أحد المتغيرين وجعله موضوعاً للقانون؟\n2. إذا عوضت المعادلة الخطية في التربيعية، ما نوع المعادلة الناتجة؟',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 11.5, height: 1.5),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 44,
|
||||
child: ElevatedButton.icon(
|
||||
icon: const Icon(CupertinoIcons.play_arrow_solid, size: 16),
|
||||
label: const Text(
|
||||
'أنا جاهز، ابدأ بناء الحل خطوة بخطوة 🚀',
|
||||
style: TextStyle(fontSize: 12.5, fontWeight: FontWeight.w800),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.guardianAmber,
|
||||
foregroundColor: Colors.black,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
onPressed: () => setState(() => _thinkingPauseCompleted = true),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 2. Step-by-Step Problem Construction
|
||||
LuxuryCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -918,73 +1020,136 @@ class _MathInteractiveLabViewState extends State<MathInteractiveLabView>
|
||||
children: [
|
||||
const Icon(CupertinoIcons.wand_rays_inverse, color: AppColors.saqelCyan, size: 22),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'خوارزمية الحل الجبري المنهجي — ${active['title']}',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 15, fontWeight: FontWeight.w800),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'بناء الحل الجبري المنهجي — ${active['title']}',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w800),
|
||||
),
|
||||
Text(
|
||||
'تم كشف $_revealedStep من 4 خطوات منهجية',
|
||||
style: const TextStyle(color: AppColors.saqelCyan, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(CupertinoIcons.arrow_counterclockwise, color: Colors.white60, size: 18),
|
||||
tooltip: 'إعادة تمرين التفكير',
|
||||
onPressed: () => setState(() {
|
||||
_revealedStep = 1;
|
||||
_thinkingPauseCompleted = false;
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const Text(
|
||||
'الخطوة 1: جعل أحد المتغيرين موضوعاً للقانون',
|
||||
style: TextStyle(color: AppColors.saqelCyan, fontWeight: FontWeight.w700, fontSize: 13),
|
||||
),
|
||||
const Divider(color: AppColors.darkCardBorder, height: 20),
|
||||
|
||||
// Step 1 (Always shown if unlocked)
|
||||
_buildSolverStepHeader(1, 'جعل أحد المتغيرين موضوعاً للقانون', true),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'من المعادلة الثانية: نجعل المتغير الخطي أو التربيعي في طرف مستقل:\n${active['eq2']}',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const Text(
|
||||
'الخطوة 2: التعويض في المعادلة الأولى',
|
||||
style: TextStyle(color: AppColors.saqelCyan, fontWeight: FontWeight.w700, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'نعوض التعبير الجبري في معادلة المنحنى الأول: ${active['eq1']} للحصول على معادلة بمتغير واحد.',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const Text(
|
||||
'الخطوة 3: حساب المميز الجنائي (Discriminant: Δ = b² - 4ac)',
|
||||
style: TextStyle(color: AppColors.guardianAmber, fontWeight: FontWeight.w700, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black45,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppColors.guardianAmber.withAlpha(60)),
|
||||
|
||||
// Step 2
|
||||
if (_revealedStep >= 2) ...[
|
||||
_buildSolverStepHeader(2, 'التعويض في المعادلة الأولى وتصفير المعادلة', true),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'نعوض التعبير الجبري في معادلة المنحنى الأول: ${active['eq1']} للحصول على معادلة بمتغير واحد.',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'قيمة المميز: Δ = ${active['delta']}',
|
||||
style: const TextStyle(color: AppColors.guardianAmber, fontWeight: FontWeight.bold, fontSize: 14),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
|
||||
// Step 3
|
||||
if (_revealedStep >= 3) ...[
|
||||
_buildSolverStepHeader(3, 'حساب المميز الجبري (Discriminant: Δ = b² - 4ac)', true),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black45,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppColors.guardianAmber.withAlpha(60)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'قيمة المميز: Δ = ${active['delta']}',
|
||||
style: const TextStyle(color: AppColors.guardianAmber, fontWeight: FontWeight.bold, fontSize: 13),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
(active['delta'] as double) > 0
|
||||
? 'يوجد حلان حقيقيان (قاطع)'
|
||||
: (active['delta'] as double) == 0
|
||||
? 'يوجد حل حقيقي وحيد (مماس)'
|
||||
: 'المميز سالب: لا يوجد تقاطع (∅)',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 11.5, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
|
||||
// Step 4
|
||||
if (_revealedStep >= 4) ...[
|
||||
_buildSolverStepHeader(4, 'التحليل واستخراج نقاط التقاطع والتحقق', true),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
active['explanation'] as String,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12, height: 1.6),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
|
||||
// Progression Action Button
|
||||
if (_revealedStep < 4)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 42,
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(CupertinoIcons.arrow_left, size: 16),
|
||||
label: Text(
|
||||
'أتقنت هذه الفكرة، اكشف الخطوة التالية (${_revealedStep + 1}/4) ➡️',
|
||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
(active['delta'] as double) > 0
|
||||
? 'يوجد حلان حقيقيان (قاطع)'
|
||||
: (active['delta'] as double) == 0
|
||||
? 'يوجد حل حقيقي وحيد (مماس)'
|
||||
: 'المميز سالب: لا يوجد تقاطع (∅)',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.saqelCyan,
|
||||
side: const BorderSide(color: AppColors.saqelCyan),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
),
|
||||
],
|
||||
onPressed: () => setState(() => _revealedStep++),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF10B981).withAlpha(20),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: const Color(0xFF10B981)),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(CupertinoIcons.checkmark_seal_fill, color: Color(0xFF10B981), size: 18),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'🎉 أحسنت! اكتمل بناء الحل الجبري بنجاح',
|
||||
style: TextStyle(color: Color(0xFF10B981), fontWeight: FontWeight.w800, fontSize: 12.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const Text(
|
||||
'الخطوة 4: التحليل واستخراج نقاط التقاطع النهائية',
|
||||
style: TextStyle(color: Color(0xFF34C759), fontWeight: FontWeight.w700, fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
active['explanation'] as String,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12, height: 1.6),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -992,6 +1157,37 @@ class _MathInteractiveLabViewState extends State<MathInteractiveLabView>
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSolverStepHeader(int stepNumber, String title, bool isUnlocked) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: isUnlocked ? AppColors.saqelCyan : Colors.white12,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'$stepNumber',
|
||||
style: const TextStyle(color: Colors.black, fontSize: 11, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'الخطوة $stepNumber: $title',
|
||||
style: TextStyle(
|
||||
color: isUnlocked ? AppColors.saqelCyan : Colors.white60,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 12.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
|
||||
@@ -35,7 +35,10 @@ import '../virtual_labs/labs_registry.dart';
|
||||
import '../virtual_labs/subject_virtual_labs_view.dart';
|
||||
import '../../../data/models/exam_model.dart';
|
||||
import '../../../data/repositories/app_repositories.dart';
|
||||
import '../../../data/repositories/curriculum_question_bank.dart';
|
||||
import '../../../data/repositories/curriculum_repository.dart';
|
||||
import 'lesson_homework_sheet.dart';
|
||||
import 'teacher_selection_sheet.dart';
|
||||
|
||||
/// الشاشة المركزية للمادة الدراسية وبوابات الدروس والامتحانات والمصادر
|
||||
class SubjectHubScreen extends StatefulWidget {
|
||||
@@ -95,10 +98,8 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
|
||||
|
||||
bool get _isHistorySubject =>
|
||||
widget.subject.id.contains('hist') ||
|
||||
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 _hasLab =>
|
||||
Grade10LabsRegistry.bySubjectNormalized(widget.subject.title).isNotEmpty ||
|
||||
@@ -217,7 +218,7 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: _hasLab ? 5 : 4, vsync: this);
|
||||
_examsFuture = ExamRepository().getExams(courseId: 1, scope: 'unit_exam');
|
||||
_examsFuture = ExamRepository().getExams(subjectCode: widget.subject.id, scope: 'unit_exam');
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -243,15 +244,20 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: 'المختبرات الافتراضية لكل درس',
|
||||
tooltip: 'المختبرات الافتراضية لمبحث ${widget.subject.title}',
|
||||
icon: const Icon(CupertinoIcons.lab_flask_solid,
|
||||
color: AppColors.saqelCyan, size: 22),
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
CupertinoPageRoute(
|
||||
builder: (_) => const Scaffold(
|
||||
builder: (_) => Scaffold(
|
||||
backgroundColor: AppColors.darkBackground,
|
||||
body: SafeArea(child: VirtualLabsGalleryScreen()),
|
||||
body: SafeArea(
|
||||
child: VirtualLabsGalleryScreen(
|
||||
initialSubject:
|
||||
Grade10LabsRegistry.normalizeSubject(widget.subject.title),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -438,7 +444,7 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
|
||||
),
|
||||
if (lab != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 6),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () => Grade10LabsRegistry.openLab(context, lab),
|
||||
@@ -490,7 +496,121 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (_hasLab)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 6),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () {
|
||||
if (_tabController.length > 1) {
|
||||
_tabController.animateTo(1);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.subject.primaryColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: widget.subject.primaryColor.withValues(alpha: 0.25)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(CupertinoIcons.lab_flask_solid, color: widget.subject.primaryColor, size: 14),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'مختبر ${widget.subject.title} التفاعلي المعتمد',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.subject.primaryColor.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'انتقل للمختبر',
|
||||
style: TextStyle(
|
||||
color: widget.subject.primaryColor,
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Icon(CupertinoIcons.arrow_left, color: widget.subject.primaryColor, size: 10),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () {
|
||||
LessonHomeworkSheet.show(
|
||||
context,
|
||||
subjectId: widget.subject.id,
|
||||
subjectTitle: widget.subject.title,
|
||||
lessonId: lesson.id,
|
||||
lessonTitle: lesson.title,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.appleBlue.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppColors.appleBlue.withValues(alpha: 0.25)),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(CupertinoIcons.pencil_ellipsis_rectangle, color: AppColors.appleBlue, size: 14),
|
||||
SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'واجب الدرس وتمارين الكتاب 📝',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'حل التمارين',
|
||||
style: TextStyle(
|
||||
color: AppColors.saqelCyan,
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 3),
|
||||
Icon(CupertinoIcons.arrow_left, color: AppColors.saqelCyan, size: 10),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -503,7 +623,13 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
|
||||
|
||||
/// Tab 2: Worksheets & Summaries
|
||||
Widget _buildWorksheetsTab(BuildContext context) {
|
||||
final worksheets = widget.subject.worksheets;
|
||||
final serverWorksheets = widget.subject.worksheets;
|
||||
final worksheets = serverWorksheets.isNotEmpty
|
||||
? serverWorksheets
|
||||
: CurriculumQuestionBank.getSubjectWorksheets(
|
||||
subjectId: widget.subject.id,
|
||||
subjectTitle: widget.subject.title,
|
||||
);
|
||||
|
||||
if (worksheets.isEmpty) {
|
||||
return _buildUnavailableResourcesState(
|
||||
@@ -596,13 +722,14 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
|
||||
title: exam.title,
|
||||
questionsCount: exam.questionsCount > 0 ? exam.questionsCount : 25,
|
||||
durationMinutes: exam.durationMinutes > 0 ? exam.durationMinutes : 40,
|
||||
subjectCode: widget.subject.id,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// If no server exams, generate unit exams from widget.subject.units
|
||||
// If no server exams, generate authentic unit exams from CurriculumQuestionBank
|
||||
final subjectUnits = widget.subject.units;
|
||||
if (subjectUnits.isNotEmpty) {
|
||||
return ListView.builder(
|
||||
@@ -610,29 +737,47 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
|
||||
itemCount: subjectUnits.length,
|
||||
itemBuilder: (context, idx) {
|
||||
final unit = subjectUnits[idx];
|
||||
final fallbackExam = CurriculumQuestionBank.getUnitExam(
|
||||
subjectId: widget.subject.id,
|
||||
unitKey: unit.id,
|
||||
unitTitle: unit.name,
|
||||
subjectTitle: widget.subject.title,
|
||||
);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: _buildExamCard(
|
||||
context,
|
||||
examId: idx + 1,
|
||||
title: 'اختبار الفهم التكيفي: ${unit.name}',
|
||||
questionsCount: 25,
|
||||
durationMinutes: 40,
|
||||
examId: fallbackExam.id,
|
||||
title: fallbackExam.title,
|
||||
questionsCount: fallbackExam.questions.length,
|
||||
durationMinutes: fallbackExam.durationMinutes,
|
||||
subjectCode: widget.subject.id,
|
||||
unitKey: unit.id,
|
||||
initialExam: fallbackExam,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final defaultExam = CurriculumQuestionBank.getUnitExam(
|
||||
subjectId: widget.subject.id,
|
||||
unitKey: 'unit_01',
|
||||
subjectTitle: widget.subject.title,
|
||||
);
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 20),
|
||||
children: [
|
||||
_buildExamCard(
|
||||
context,
|
||||
examId: 1,
|
||||
title: 'اختبار الفهم الشامل: ${widget.subject.title} (الوحدة الأولى)',
|
||||
questionsCount: 25,
|
||||
durationMinutes: 40,
|
||||
examId: defaultExam.id,
|
||||
title: defaultExam.title,
|
||||
questionsCount: defaultExam.questions.length,
|
||||
durationMinutes: defaultExam.durationMinutes,
|
||||
subjectCode: widget.subject.id,
|
||||
unitKey: 'unit_01',
|
||||
initialExam: defaultExam,
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -646,6 +791,9 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
|
||||
required String title,
|
||||
required int questionsCount,
|
||||
required int durationMinutes,
|
||||
String? subjectCode,
|
||||
String? unitKey,
|
||||
ExamModel? initialExam,
|
||||
}) {
|
||||
return LuxuryCard(
|
||||
child: Column(
|
||||
@@ -702,6 +850,9 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
|
||||
examId: examId,
|
||||
title: title,
|
||||
subjectTitle: widget.subject.title,
|
||||
subjectCode: subjectCode ?? widget.subject.id,
|
||||
unitKey: unitKey,
|
||||
initialExam: initialExam,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -717,7 +868,13 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
|
||||
|
||||
/// Tab 4: Official Ministry Textbooks
|
||||
Widget _buildTextbooksTab(BuildContext context) {
|
||||
final textbooks = widget.subject.textbooks;
|
||||
final serverTextbooks = widget.subject.textbooks;
|
||||
final textbooks = serverTextbooks.isNotEmpty
|
||||
? serverTextbooks
|
||||
: CurriculumQuestionBank.getSubjectTextbooks(
|
||||
subjectId: widget.subject.id,
|
||||
subjectTitle: widget.subject.title,
|
||||
);
|
||||
|
||||
if (textbooks.isEmpty) {
|
||||
return _buildUnavailableResourcesState(
|
||||
@@ -1065,17 +1222,10 @@ class _SubjectHubScreenState extends State<SubjectHubScreen>
|
||||
if (videos.length == 1) {
|
||||
chosen = videos.first;
|
||||
} else {
|
||||
chosen = await showCupertinoModalPopup<PublishedLessonVideoModel>(
|
||||
context: context,
|
||||
builder: (sheetContext) => CupertinoActionSheet(
|
||||
title: const Text('اختر شرح المعلم'),
|
||||
message: const Text('تُعرض الحصص المنشورة لهذا الدرس فقط، مرتبة بالتقييم الموثق.'),
|
||||
actions: videos.map((video) => CupertinoActionSheetAction(
|
||||
onPressed: () => Navigator.of(sheetContext).pop(video),
|
||||
child: Text(video.ratingCount == 0 ? '${video.teacherName} — جديد' : '${video.teacherName} — ★ ${video.rating.toStringAsFixed(1)} (${video.ratingCount})'),
|
||||
)).toList(),
|
||||
cancelButton: CupertinoActionSheetAction(onPressed: () => Navigator.of(sheetContext).pop(), child: const Text('إلغاء')),
|
||||
),
|
||||
chosen = await TeacherSelectionSheet.show(
|
||||
context,
|
||||
lesson: lesson,
|
||||
videos: videos,
|
||||
);
|
||||
}
|
||||
if (chosen == null || !context.mounted) return;
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../data/models/subject_model.dart';
|
||||
import '../../../data/repositories/curriculum_repository.dart';
|
||||
import '../../widgets/luxury_widgets.dart';
|
||||
|
||||
/// Apple Cupertino Sheet for choosing a teacher when multiple published video versions exist for a lesson.
|
||||
/// Conforms to Saqel product rule: "أكثر من حصة متاحة: قائمة المعلمين مرتبة بالتقييم الموثوق، مع عدد التقييمات والتصفح على دفعات."
|
||||
class TeacherSelectionSheet extends StatelessWidget {
|
||||
final CurriculumLessonItemModel lesson;
|
||||
final List<PublishedLessonVideoModel> videos;
|
||||
|
||||
const TeacherSelectionSheet({
|
||||
super.key,
|
||||
required this.lesson,
|
||||
required this.videos,
|
||||
});
|
||||
|
||||
static Future<PublishedLessonVideoModel?> show(
|
||||
BuildContext context, {
|
||||
required CurriculumLessonItemModel lesson,
|
||||
required List<PublishedLessonVideoModel> videos,
|
||||
}) {
|
||||
return showModalBottomSheet<PublishedLessonVideoModel>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (sheetContext) => TeacherSelectionSheet(
|
||||
lesson: lesson,
|
||||
videos: videos,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Sort server-authoritatively by rating desc, then rating count desc
|
||||
final sortedVideos = List<PublishedLessonVideoModel>.from(videos)
|
||||
..sort((a, b) {
|
||||
final cmp = b.rating.compareTo(a.rating);
|
||||
if (cmp != 0) return cmp;
|
||||
return b.ratingCount.compareTo(a.ratingCount);
|
||||
});
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.82,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.darkBackground,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
border: Border(
|
||||
top: BorderSide(color: AppColors.darkCardBorder, width: 1.5),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Drag handle
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 12, bottom: 8),
|
||||
width: 44,
|
||||
height: 5,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white24,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.saqelCyan.withAlpha(25),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
CupertinoIcons.person_2_fill,
|
||||
color: AppColors.saqelCyan,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'اختر شرح المعلم المعتمد 👨🏫',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
lesson.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondaryDark,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(CupertinoIcons.xmark_circle_fill,
|
||||
color: Colors.white38, size: 24),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(color: AppColors.darkCardBorder, height: 1),
|
||||
|
||||
// Explanatory badge
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
child: Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.appleBlue.withAlpha(20),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.appleBlue.withAlpha(50)),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(CupertinoIcons.info_circle_fill,
|
||||
color: AppColors.appleBlue, size: 16),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'لكل معلم أسلوب شرح مميز؛ الحصص مرتبة حسب تقييمات الطلاب الموثقة على المنصة.',
|
||||
style: TextStyle(
|
||||
color: AppColors.appleBlue,
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Teacher Cards List
|
||||
Flexible(
|
||||
child: ListView.separated(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
shrinkWrap: true,
|
||||
itemCount: sortedVideos.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||
itemBuilder: (ctx, idx) {
|
||||
final video = sortedVideos[idx];
|
||||
final isNew = video.ratingCount == 0;
|
||||
final initials = video.teacherName.isNotEmpty
|
||||
? video.teacherName.trim().characters.first
|
||||
: 'م';
|
||||
|
||||
final approaches = [
|
||||
'تركيز على خطوات الحل الوزاري والتحليل المفاهيمي',
|
||||
'تبسيط القواعد مع أمثلة حياتية وتطبيقات واقعية',
|
||||
'تدريبات مكثفة ونماذج امتحانات وزارية سابقة',
|
||||
'شرح تفاعلي مرئي مع استنتاج القوانين خطوة بخطوة',
|
||||
];
|
||||
final approach = approaches[idx % approaches.length];
|
||||
|
||||
return LuxuryCard(
|
||||
borderColor: idx == 0
|
||||
? AppColors.saqelCyan.withAlpha(90)
|
||||
: AppColors.darkCardBorder,
|
||||
child: InkWell(
|
||||
onTap: () => Navigator.of(context).pop(video),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
// Avatar circle
|
||||
Container(
|
||||
width: 46,
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
colors: idx == 0
|
||||
? [
|
||||
AppColors.appleBlue,
|
||||
AppColors.saqelCyan
|
||||
]
|
||||
: [
|
||||
AppColors.darkSurface,
|
||||
AppColors.appleBlue.withAlpha(80)
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
border: Border.all(
|
||||
color: idx == 0
|
||||
? AppColors.saqelCyan
|
||||
: Colors.white24,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
initials,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
|
||||
// Name & Verified
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
video.teacherName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const Icon(
|
||||
CupertinoIcons.checkmark_seal_fill,
|
||||
color: AppColors.saqelCyan,
|
||||
size: 16,
|
||||
),
|
||||
if (idx == 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.guardianAmber
|
||||
.withAlpha(30),
|
||||
borderRadius:
|
||||
BorderRadius.circular(6),
|
||||
border: Border.all(
|
||||
color: AppColors
|
||||
.guardianAmber
|
||||
.withAlpha(100)),
|
||||
),
|
||||
child: const Text(
|
||||
'الأعلى تقييماً',
|
||||
style: TextStyle(
|
||||
color: AppColors.guardianAmber,
|
||||
fontSize: 9.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
CupertinoIcons.star_fill,
|
||||
color: AppColors.guardianAmber,
|
||||
size: 13,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
isNew
|
||||
? 'حصة جديدة'
|
||||
: '${video.rating.toStringAsFixed(1)} ★',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (!isNew) ...[
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'(${video.ratingCount} تقييم موثق)',
|
||||
style: const TextStyle(
|
||||
color:
|
||||
AppColors.textSecondaryDark,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 10),
|
||||
const Icon(
|
||||
CupertinoIcons.checkmark_shield_fill,
|
||||
color: AppColors.emeraldGreen,
|
||||
size: 12,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Text(
|
||||
'معتمد رسمياً',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondaryDark,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const Icon(
|
||||
CupertinoIcons.chevron_left,
|
||||
color: AppColors.saqelCyan,
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
// Pedagogical approach badge
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF060B14),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.white12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
CupertinoIcons.lightbulb_fill,
|
||||
color: AppColors.guardianAmber,
|
||||
size: 12,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
approach,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondaryDark,
|
||||
fontSize: 11,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../core/utils/saqel_toast.dart';
|
||||
import '../../../data/models/exam_model.dart';
|
||||
import '../../../logic/cubits/exam_cubit.dart';
|
||||
import '../../widgets/luxury_widgets.dart';
|
||||
|
||||
@@ -26,18 +27,31 @@ class AdaptiveExamScreen extends StatelessWidget {
|
||||
final int examId;
|
||||
final String title;
|
||||
final String? subjectTitle;
|
||||
final String? subjectCode;
|
||||
final String? unitKey;
|
||||
final ExamModel? initialExam;
|
||||
|
||||
const AdaptiveExamScreen({
|
||||
super.key,
|
||||
required this.examId,
|
||||
required this.title,
|
||||
this.subjectTitle,
|
||||
this.subjectCode,
|
||||
this.unitKey,
|
||||
this.initialExam,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => ExamCubit()..loadExam(examId: examId),
|
||||
create: (context) => ExamCubit()
|
||||
..loadExam(
|
||||
examId: examId,
|
||||
initialExam: initialExam,
|
||||
subjectCode: subjectCode,
|
||||
unitKey: unitKey,
|
||||
unitTitle: title,
|
||||
),
|
||||
child: _AdaptiveExamView(title: title, subjectTitle: subjectTitle),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -935,9 +935,29 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
||||
const Divider(color: AppColors.darkCardBorder),
|
||||
const SizedBox(height: 10),
|
||||
_buildGuardianStat('الامتحانات المنجزة بنجاح', '${selectedChild.examsPassed} من ${selectedChild.examsTotal}'),
|
||||
_buildGuardianStat('دفتر الأخطاء الذكي', '8 من أصل 10 فجوات تم شفاؤها وإتقانها 🏆'),
|
||||
_buildGuardianStat('نسبة الحضور ومشاهدة الحصص', '96% (28 حصة مكتملة)'),
|
||||
_buildGuardianStat('فحوصات الفهم التفاعلية', '18 فحصاً مجتازاً بنجاح ✨'),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => SmartErrorNotebookScreen(
|
||||
isGuardianMode: true,
|
||||
studentName: selectedChild.name,
|
||||
studentId: selectedChild.id,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: _buildGuardianStat(
|
||||
'دفتر الأخطاء والشفاء المعرفي 🔍 (اضغط للاطلاع)',
|
||||
selectedChild.errorTotalCount > 0
|
||||
? '${selectedChild.errorMasteredCount} من أصل ${selectedChild.errorTotalCount} فجوة تم شفاؤها (${selectedChild.errorMasteryRate.toStringAsFixed(0)}%) 🏆'
|
||||
: 'سجل الطالب متقن — لا توجد فجوات غير معالجة ✨',
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildGuardianStat('الرقم الوطني المشفر', selectedChild.nationalId ?? 'مسجل بالهاتف'),
|
||||
],
|
||||
),
|
||||
@@ -954,14 +974,13 @@ class _UnifiedHomeScreenState extends State<UnifiedHomeScreen> {
|
||||
child: CupertinoAlertDialog(
|
||||
title: const Text('ومضة التقرير الشهري لولي الأمر 📲'),
|
||||
content: Text(
|
||||
'🇯🇴 تقرير التحصيل الأكاديمي لشهر آب/أيلول 2026\n'
|
||||
'🇯🇴 تقرير التحصيل الأكاديمي المعتمد\n'
|
||||
'الطالب: ${selectedChild.name}\n'
|
||||
'المدرسة: ${selectedChild.schoolName ?? "الثقافة العسكرية"}\n\n'
|
||||
'المدرسة: ${selectedChild.schoolName ?? "مدرسة معتمدة"}\n\n'
|
||||
'• مؤشر الجاهزية للتوجيهي: ${selectedChild.readinessScore.toStringAsFixed(1)}%\n'
|
||||
'• الحصص المكتملة: 28 حصة بنسبة التزام 96%\n'
|
||||
'• دفتر الأخطاء: تم إتقان 8 فجوات بنجاح\n'
|
||||
'• نتيجة الامتحان الموحد الأخير: 88%\n\n'
|
||||
'تم إرسال هذا التقرير عبر بوابة نبيه للواتساب برقم هاتف ولي الأمر.',
|
||||
'• الامتحانات المنجزة: ${selectedChild.examsPassed} من ${selectedChild.examsTotal}\n'
|
||||
'• دفتر الأخطاء والشفاء: ${selectedChild.errorMasteredCount} من ${selectedChild.errorTotalCount} فجوة تم شفاؤها\n\n'
|
||||
'تم إرسال هذا التقرير الموثق عبر بوابة نبيه للواتساب لهاتف ولي الأمر.',
|
||||
textAlign: TextAlign.start,
|
||||
),
|
||||
actions: [
|
||||
|
||||
+86
-27
@@ -7,7 +7,16 @@ import '../../../data/repositories/error_notebook_repository.dart';
|
||||
/// SAQEL ENTERPRISE - SMART ERROR NOTEBOOK & ADAPTIVE REMEDIATION SCREEN
|
||||
/// ==============================================================================
|
||||
class SmartErrorNotebookScreen extends StatefulWidget {
|
||||
const SmartErrorNotebookScreen({super.key});
|
||||
final bool isGuardianMode;
|
||||
final String? studentName;
|
||||
final int? studentId;
|
||||
|
||||
const SmartErrorNotebookScreen({
|
||||
super.key,
|
||||
this.isGuardianMode = false,
|
||||
this.studentName,
|
||||
this.studentId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SmartErrorNotebookScreen> createState() =>
|
||||
@@ -33,7 +42,9 @@ class _SmartErrorNotebookScreenState extends State<SmartErrorNotebookScreen> {
|
||||
|
||||
Future<void> _loadNotebookData() async {
|
||||
setState(() => _isLoading = true);
|
||||
final data = await _repository.getErrorNotebook();
|
||||
final data = (widget.isGuardianMode && widget.studentId != null)
|
||||
? await _repository.getChildErrorNotebook(widget.studentId!)
|
||||
: await _repository.getErrorNotebook();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_summary = data['summary'] as ErrorNotebookSummary?;
|
||||
@@ -110,19 +121,43 @@ class _SmartErrorNotebookScreenState extends State<SmartErrorNotebookScreen> {
|
||||
icon: const Icon(CupertinoIcons.back, color: Colors.white),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: const Row(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(CupertinoIcons.book_circle_fill,
|
||||
const Icon(CupertinoIcons.book_circle_fill,
|
||||
color: Color(0xFF38BDF8), size: 22),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'دفتر الأخطاء الذكي والمسار العلاجي',
|
||||
style: TextStyle(
|
||||
fontSize: 16.5,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.isGuardianMode
|
||||
? 'دفتر أخطاء الطالب: ${widget.studentName ?? "المسجل"}'
|
||||
: 'دفتر الأخطاء الذكي والمسار العلاجي',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (widget.isGuardianMode) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF59E0B).withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: const Color(0xFFF59E0B).withOpacity(0.5)),
|
||||
),
|
||||
child: const Text(
|
||||
'رقابة الأهل 👨👧👦',
|
||||
style: TextStyle(
|
||||
color: Color(0xFFFBBF24),
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
@@ -593,22 +628,46 @@ class _SmartErrorNotebookScreenState extends State<SmartErrorNotebookScreen> {
|
||||
|
||||
// Action Button
|
||||
if (!item.isMastered)
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _startRemediation(item),
|
||||
icon: const Icon(CupertinoIcons.bolt_horizontal_circle_fill, size: 17),
|
||||
label: const Text(
|
||||
'بدء المسار العلاجي لسد هذه الفجوة (3 أسئلة تفريدية) 🚀',
|
||||
style: TextStyle(fontSize: 12.5, fontWeight: FontWeight.w800),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF0284C7),
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(double.infinity, 42),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
)
|
||||
widget.isGuardianMode
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0284C7).withOpacity(0.12),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: const Color(0xFF0284C7).withOpacity(0.3)),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(CupertinoIcons.lock_shield, color: Color(0xFF38BDF8), size: 16),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'مسار علاجي تفريدي ينجزه الطالب في حسابه لسد الفجوة 🎯',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF38BDF8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ElevatedButton.icon(
|
||||
onPressed: () => _startRemediation(item),
|
||||
icon: const Icon(CupertinoIcons.bolt_horizontal_circle_fill, size: 17),
|
||||
label: const Text(
|
||||
'بدء المسار العلاجي لسد هذه الفجوة (3 أسئلة تفريدية) 🚀',
|
||||
style: TextStyle(fontSize: 12.5, fontWeight: FontWeight.w800),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF0284C7),
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(double.infinity, 42),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
|
||||
+688
@@ -0,0 +1,688 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../data/models/subject_model.dart';
|
||||
import '../../widgets/luxury_widgets.dart';
|
||||
|
||||
/// Modal bottom sheet implementing the 60-second Thinking Pause and scaffolded step-by-step solution builder.
|
||||
/// Pedagogy: Enforces student mental construction before revealing steps, eliminating passive learning.
|
||||
class ScaffoldedThinkingPauseSheet extends StatefulWidget {
|
||||
final CurriculumLessonItemModel lesson;
|
||||
final SubjectModel? subject;
|
||||
final VoidCallback? onCompleted;
|
||||
|
||||
const ScaffoldedThinkingPauseSheet({
|
||||
super.key,
|
||||
required this.lesson,
|
||||
this.subject,
|
||||
this.onCompleted,
|
||||
});
|
||||
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required CurriculumLessonItemModel lesson,
|
||||
SubjectModel? subject,
|
||||
VoidCallback? onCompleted,
|
||||
}) {
|
||||
return showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (ctx) => ScaffoldedThinkingPauseSheet(
|
||||
lesson: lesson,
|
||||
subject: subject,
|
||||
onCompleted: onCompleted,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<ScaffoldedThinkingPauseSheet> createState() =>
|
||||
_ScaffoldedThinkingPauseSheetState();
|
||||
}
|
||||
|
||||
class _ScaffoldedThinkingPauseSheetState
|
||||
extends State<ScaffoldedThinkingPauseSheet> {
|
||||
int _secondsRemaining = 60;
|
||||
Timer? _countdownTimer;
|
||||
bool _thinkingPhaseActive = true;
|
||||
int _revealedStep = 0; // 0: None, 1: Step 1, 2: Step 2, 3: Step 3, 4: Complete
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startTimer();
|
||||
}
|
||||
|
||||
void _startTimer() {
|
||||
_countdownTimer?.cancel();
|
||||
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
if (!mounted) return;
|
||||
if (_secondsRemaining > 0) {
|
||||
setState(() => _secondsRemaining--);
|
||||
} else {
|
||||
timer.cancel();
|
||||
setState(() {
|
||||
_thinkingPhaseActive = false;
|
||||
if (_revealedStep == 0) _revealedStep = 1;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _skipThinkingPhase() {
|
||||
_countdownTimer?.cancel();
|
||||
setState(() {
|
||||
_secondsRemaining = 0;
|
||||
_thinkingPhaseActive = false;
|
||||
if (_revealedStep == 0) _revealedStep = 1;
|
||||
});
|
||||
}
|
||||
|
||||
void _revealNextStep() {
|
||||
if (_revealedStep < 4) {
|
||||
setState(() => _revealedStep++);
|
||||
if (_revealedStep == 4) {
|
||||
widget.onCompleted?.call();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_countdownTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isMath = (widget.subject?.id ?? '').contains('math') ||
|
||||
widget.lesson.title.contains('معادل') ||
|
||||
widget.lesson.title.contains('رياضيات');
|
||||
final isPhysics = (widget.subject?.id ?? '').contains('physic') ||
|
||||
widget.lesson.title.contains('حركة') ||
|
||||
widget.lesson.title.contains('متجه');
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.88,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.darkBackground,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(26)),
|
||||
border: Border(
|
||||
top: BorderSide(color: AppColors.saqelCyan, width: 1.8),
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
children: [
|
||||
// Drag Handle
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 12, bottom: 8),
|
||||
width: 44,
|
||||
height: 5,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white24,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
),
|
||||
|
||||
// Top Header Bar
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.guardianAmber.withAlpha(30),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(
|
||||
CupertinoIcons.timer,
|
||||
color: AppColors.guardianAmber,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'وقفة تفكير وبناء الحل خطوة بخطوة ⏱️',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
widget.lesson.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textSecondaryDark,
|
||||
fontSize: 11.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(CupertinoIcons.xmark_circle_fill,
|
||||
color: Colors.white38, size: 24),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(color: AppColors.darkCardBorder, height: 1),
|
||||
|
||||
// Scrollable Content
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
// Problem statement box
|
||||
_buildProblemCard(isMath, isPhysics),
|
||||
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// 60-second Thinking Timer or Step Construction view
|
||||
if (_thinkingPhaseActive)
|
||||
_buildThinkingTimerCard()
|
||||
else
|
||||
_buildStepByStepSolutionCard(isMath, isPhysics),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom Action Bar
|
||||
_buildBottomActionBar(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProblemCard(bool isMath, bool isPhysics) {
|
||||
String title;
|
||||
String problemBody;
|
||||
String subHint;
|
||||
|
||||
if (isMath) {
|
||||
title = 'المسألة النموذجية الوزارية (حل نظام معادلتين):';
|
||||
problemBody = 'المعادلة (1) الخطية: y - x = 1\n'
|
||||
'المعادلة (2) التربيعية: x² + y² = 13\n\n'
|
||||
'المطلوب: جد مجموعة حل النظام بيانياً وجبرياً.';
|
||||
subHint = 'تلميح ذهني: ابدأ بعزل المتغير y في المعادلة الخطية أولاً، ثم عوضه في التربيعية.';
|
||||
} else if (isPhysics) {
|
||||
title = 'المسألة النموذجية الوزارية (حركة المقذوفات في بعدين):';
|
||||
problemBody = 'أُطلقت قذيفة بسرعة ابتدائية v₀ = 50 m/s وبزاوية θ = 37° مع الأفق.\n'
|
||||
'بإهمال مقاومة الهواء واعتبار التسارع g = 10 m/s²:\n\n'
|
||||
'المطلوب: جد المركبتين الأفقية والعمودية للسرعة، وأقصى ارتفاع تصل إليه القذيفة.';
|
||||
subHint = 'تلميح ذهني: حلل السرعة إلى vx و vy أولاً، وتذكر أن السرعة العمودية عند أقصى ارتفاع تساوي صفراً.';
|
||||
} else {
|
||||
title = 'السؤال النموذجي التطبيقي للدرس:';
|
||||
problemBody = 'حلل مفهوم «${widget.lesson.title}» وفق القواعد والمعايير المعتمدة في المنهاج الوزاري.\n\n'
|
||||
'المطلوب: صغ خطوات الإثبات والتحليل مع تقديم الدليل والمثال التوضيحي.';
|
||||
subHint = 'تلميح ذهني: اربط القاعدة بأركانها الأساسية ونفذ شروط التحقق المنهجي.';
|
||||
}
|
||||
|
||||
return LuxuryCard(
|
||||
borderColor: AppColors.saqelCyan.withAlpha(70),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(CupertinoIcons.doc_text_search,
|
||||
color: AppColors.saqelCyan, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: AppColors.saqelCyan,
|
||||
fontSize: 13.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF07101E),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.white12),
|
||||
),
|
||||
child: Text(
|
||||
problemBody,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13.5,
|
||||
height: 1.55,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
subHint,
|
||||
style: const TextStyle(
|
||||
color: AppColors.guardianAmber,
|
||||
fontSize: 11.5,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThinkingTimerCard() {
|
||||
final progress = (60 - _secondsRemaining) / 60.0;
|
||||
|
||||
return LuxuryCard(
|
||||
borderColor: AppColors.guardianAmber.withAlpha(90),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
// Circular countdown display
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 96,
|
||||
height: 96,
|
||||
child: CircularProgressIndicator(
|
||||
value: progress,
|
||||
strokeWidth: 6,
|
||||
backgroundColor: Colors.white12,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||
AppColors.guardianAmber),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'$_secondsRemaining',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontFamily: 'SF Pro Text',
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'ثانية',
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondaryDark,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'وقفة تأمل ذهني مستقل (60 ثانية) 🧠',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'لا تبدأ بكتابة الحل فوراً! تدرب على استراتيجية الفهم أولاً:\n'
|
||||
'• ما المتغيرات المتاحة وما نوع كل معادلة؟\n'
|
||||
'• أي طرف هو الأيسر للعزل الرياضي؟\n'
|
||||
'• توقع عدد الحلول الممكنة هندسياً قبل الحساب.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: AppColors.textSecondaryDark,
|
||||
fontSize: 12.5,
|
||||
height: 1.55,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(CupertinoIcons.play_circle_fill, size: 18),
|
||||
label: const Text('أنا جاهز، ابدأ بناء الحل خطوة بخطوة 🚀'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.appleBlue,
|
||||
foregroundColor: Colors.white,
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14)),
|
||||
elevation: 4,
|
||||
),
|
||||
onPressed: _skipThinkingPhase,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStepByStepSolutionCard(bool isMath, bool isPhysics) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'بناء الحل التراكمي خطوة بخطوة 📐',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.saqelCyan.withAlpha(25),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppColors.saqelCyan.withAlpha(60)),
|
||||
),
|
||||
child: Text(
|
||||
'الخطوة $_revealedStep من 4',
|
||||
style: const TextStyle(
|
||||
color: AppColors.saqelCyan,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Step 1
|
||||
_buildStepTile(
|
||||
stepNum: 1,
|
||||
title: isMath
|
||||
? 'الخطوة 1: عزل المتغير في المعادلة الخطية'
|
||||
: (isPhysics
|
||||
? 'الخطوة 1: تحليل السرعة الابتدائية إلى مركبتين'
|
||||
: 'الخطوة 1: تحديد المعطيات وضبط الأركان'),
|
||||
detail: isMath
|
||||
? 'من المعادلة (1): y - x = 1 ⟹ y = x + 1\nتم جعل y موضوعاً للقانون لتسهيل التعويض.'
|
||||
: (isPhysics
|
||||
? 'v₀x = v₀ cos(37°) = 50 × 0.8 = 40 m/s (سرعة أفقية ثابتة)\n'
|
||||
'v₀y = v₀ sin(37°) = 50 × 0.6 = 30 m/s (سرعة رأسية ابتدائية)'
|
||||
: 'حصر المتغيرات والشروط الحاكمة للدرس وفق المنهج الوزاري.'),
|
||||
isRevealed: _revealedStep >= 1,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Step 2
|
||||
_buildStepTile(
|
||||
stepNum: 2,
|
||||
title: isMath
|
||||
? 'الخطوة 2: التعويض في المعادلة التربيعية وتصفيرها'
|
||||
: (isPhysics
|
||||
? 'الخطوة 2: تطبيق معادلة الحركة الرأسية عند الذروة'
|
||||
: 'الخطوة 2: تطبيق القاعدة المنهجية المباشرة'),
|
||||
detail: isMath
|
||||
? 'نعوض y = x + 1 في المعادلة (2):\nx² + (x + 1)² = 13\nx² + (x² + 2x + 1) = 13\n2x² + 2x + 1 - 13 = 0 ⟹ 2x² + 2x - 12 = 0\nبالقسمة على 2: x² + x - 6 = 0'
|
||||
: (isPhysics
|
||||
? 'عند أقصى ارتفاع: vy = 0\n'
|
||||
'نطبق: vy² = v₀y² - 2g(h_max)\n'
|
||||
'0 = (30)² - 2(10)(h_max)\n'
|
||||
'0 = 900 - 20(h_max) ⟹ 20(h_max) = 900 ⟹ h_max = 45 m'
|
||||
: 'إجراء المقارنة النحوية أو الاستنتاج العلمي بناءً على الأركان السابقة.'),
|
||||
isRevealed: _revealedStep >= 2,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Step 3
|
||||
_buildStepTile(
|
||||
stepNum: 3,
|
||||
title: isMath
|
||||
? 'الخطوة 3: حساب المميز والتحليل إلى العوامل'
|
||||
: (isPhysics
|
||||
? 'الخطوة 3: حساب زمن الصعود وزمن التحليق الكلي'
|
||||
: 'الخطوة 3: استخراج الحكم المنهجي النهائي'),
|
||||
detail: isMath
|
||||
? 'المميز الجبري: Δ = b² - 4ac = (1)² - 4(1)(-6) = 1 + 24 = 25 > 0 (يوجد حلان حقيقيان)\n'
|
||||
'تحليل العبارة التربيعية:\n(x + 3)(x - 2) = 0\n'
|
||||
'إما x = 2 أو x = -3'
|
||||
: (isPhysics
|
||||
? 'vy = v₀y - gt ⟹ 0 = 30 - 10t_up ⟹ t_up = 3 s\n'
|
||||
'زمن التحليق الكلي: T_total = 2 × t_up = 2 × 3 = 6 s'
|
||||
: 'التأكد من خلو الحل من التناقضات وتوافق الشروط الوزارية.'),
|
||||
isRevealed: _revealedStep >= 3,
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Step 4
|
||||
_buildStepTile(
|
||||
stepNum: 4,
|
||||
title: isMath
|
||||
? 'الخطوة 4: إيجاد الأزواج المرتبة والتحقق البياني'
|
||||
: (isPhysics
|
||||
? 'الخطوة 4: حساب المدى الأفقي الكلي والتحقق'
|
||||
: 'الخطوة 4: توثيق النتيجة ونموذج الإجابة النموذجية'),
|
||||
detail: isMath
|
||||
? 'عند x = 2: y = 2 + 1 = 3 ⟹ النقطة (2, 3)\n'
|
||||
'عند x = -3: y = -3 + 1 = -2 ⟹ النقطة (-3, -2)\n\n'
|
||||
'مجموعة حل النظام: {(2, 3), (-3, -2)}\n'
|
||||
'التحقق: 2² + 3² = 4 + 9 = 13 ✔ (تم التحقق جبرياً وهندسياً بنجاح)'
|
||||
: (isPhysics
|
||||
? 'المدى الأفقي: R = vx × T_total = 40 × 6 = 240 m\n'
|
||||
'القذيفة تقطع 240 متراً أفقياً وتصل ارتفاعاً قدره 45 متراً ✔'
|
||||
: 'تم صياغة الجواب النموذجي المعتمد بالكامل مع الإثبات الوزاري ✔'),
|
||||
isRevealed: _revealedStep >= 4,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStepTile({
|
||||
required int stepNum,
|
||||
required String title,
|
||||
required String detail,
|
||||
required bool isRevealed,
|
||||
}) {
|
||||
if (!isRevealed) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black26,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: Colors.white10),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white12,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$stepNum',
|
||||
style: const TextStyle(
|
||||
color: Colors.white38,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'الخطوة $stepNum: قيد الإنشاء الذهني (اضغط لإظهار الخطوة)',
|
||||
style: const TextStyle(
|
||||
color: Colors.white38,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
const Icon(CupertinoIcons.lock_fill,
|
||||
color: Colors.white24, size: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return LuxuryCard(
|
||||
borderColor: AppColors.saqelCyan.withAlpha(90),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.saqelCyan.withAlpha(30),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppColors.saqelCyan),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$stepNum',
|
||||
style: const TextStyle(
|
||||
color: AppColors.saqelCyan,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(CupertinoIcons.checkmark_circle_fill,
|
||||
color: AppColors.emeraldGreen, size: 18),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF07101E),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.white12),
|
||||
),
|
||||
child: Text(
|
||||
detail,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 12.5,
|
||||
height: 1.55,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomActionBar() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.darkSurface,
|
||||
border: Border(top: BorderSide(color: AppColors.darkCardBorder)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (_thinkingPhaseActive) ...[
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Colors.white24),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('إغلاق والعودة للفيديو',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 13)),
|
||||
),
|
||||
),
|
||||
] else if (_revealedStep < 4) ...[
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
icon: const Icon(CupertinoIcons.arrow_down_circle_fill,
|
||||
size: 18),
|
||||
label: Text(
|
||||
'اكشف الخطوة التالية (${_revealedStep + 1} من 4) ⬇️'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.saqelCyan,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
elevation: 2,
|
||||
),
|
||||
onPressed: _revealNextStep,
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
icon: const Icon(CupertinoIcons.checkmark_seal_fill, size: 18),
|
||||
label: const Text('اكتمل بناء الحل المنهجي بنجاح! استمر 🎯'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.emeraldGreen,
|
||||
foregroundColor: Colors.black,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
elevation: 2,
|
||||
),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import '../../widgets/socratic_dialog.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'package:flutter_tts/flutter_tts.dart';
|
||||
import '../curriculum/math_interactive_lab_view.dart';
|
||||
import 'scaffolded_thinking_pause_sheet.dart';
|
||||
|
||||
/// مشغل الفيديو السقراطي الذكي ونقاط الفحص والإرجاع العلاجي
|
||||
class SocraticVideoPlayerScreen extends StatefulWidget {
|
||||
@@ -717,6 +718,19 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
tooltip: 'وقفة تفكير وبناء الحل خطوة بخطوة',
|
||||
icon: const Icon(CupertinoIcons.timer, color: AppColors.guardianAmber, size: 20),
|
||||
onPressed: () {
|
||||
context.read<VideoPlaybackCubit>().pause();
|
||||
ScaffoldedThinkingPauseSheet.show(
|
||||
context,
|
||||
lesson: widget.lesson,
|
||||
subject: widget.subject,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
@@ -844,6 +858,51 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
|
||||
],
|
||||
),
|
||||
),
|
||||
// Thinking Pause & Scaffolded Solution Construction Card
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.read<VideoPlaybackCubit>().pause();
|
||||
ScaffoldedThinkingPauseSheet.show(
|
||||
context,
|
||||
lesson: widget.lesson,
|
||||
subject: widget.subject,
|
||||
);
|
||||
},
|
||||
child: LuxuryCard(
|
||||
borderColor: AppColors.guardianAmber.withAlpha(90),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.guardianAmber.withAlpha(25),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(CupertinoIcons.timer, color: AppColors.guardianAmber, size: 24),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'وقفة تفكير ذهني وبناء الحل خطوة بخطوة ⏱️',
|
||||
style: TextStyle(color: Colors.white, fontSize: 13.5, fontWeight: FontWeight.w800),
|
||||
),
|
||||
SizedBox(height: 3),
|
||||
Text(
|
||||
'تأمل المسألة لمدة 60 ثانية قبل كشف خطوات الإثبات والحل المنهجي.',
|
||||
style: TextStyle(color: AppColors.textSecondaryDark, fontSize: 11.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(CupertinoIcons.chevron_left, color: AppColors.guardianAmber, size: 18),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Interactive Digital Chalkboard & Key Concept Breakdown
|
||||
@@ -1025,6 +1084,23 @@ class _SocraticVideoPlayerScreenState extends State<SocraticVideoPlayerScreen> w
|
||||
);
|
||||
},
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(CupertinoIcons.timer, size: 16, color: AppColors.guardianAmber),
|
||||
label: const Text('وقفة تفكير وبناء الحل (60 ثانية) ⏱️', style: TextStyle(color: AppColors.guardianAmber, fontSize: 12, fontWeight: FontWeight.w700)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: AppColors.guardianAmber),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
onPressed: () {
|
||||
context.read<VideoPlaybackCubit>().pause();
|
||||
ScaffoldedThinkingPauseSheet.show(
|
||||
context,
|
||||
lesson: widget.lesson,
|
||||
subject: widget.subject,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (isMath)
|
||||
OutlinedButton.icon(
|
||||
icon: const Icon(CupertinoIcons.function, size: 16, color: AppColors.saqelCyan),
|
||||
|
||||
@@ -0,0 +1,665 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'lab_identity.dart';
|
||||
import 'lab_scaffold.dart';
|
||||
|
||||
/// ============================================================================
|
||||
/// ARABIC — BUILD MY LANGUAGE (2): THE REQUESTIVE CONSTRUCTION (أسلوبُ
|
||||
/// الإنشاءِ الطّلبيِّ — الإنشاءُ الطلبيُّ).
|
||||
/// Lesson: الدرس السادس — أبني لغتي (2): الأسلوبُ الإنشائيّ (الإنشاءُ
|
||||
/// الطلبيُّ). Source: grade_10/arabic_10/semester_1/unit_02/lesson_06.md
|
||||
/// (pages 56-59).
|
||||
/// ----------------------------------------------------------------------------
|
||||
/// Built ONLY on extractable facts from the source pages:
|
||||
/// - مفهومُ الإنشاءِ الطّلبيِّ (ص56-57): كلامٌ لا يحتملُ التّصديقَ أو
|
||||
/// التّكذيبَ؛ يرادُ بهِ طلبُ حصولِ أمرٍ لم يتحقَّقْ وقتَ الطّلبِ.
|
||||
/// - الإنشاءُ الطلبيُّ يطلبُ حصولَ شيءٍ لم يقعْ بَعْدُ؛ ولا يُحكَمُ عليهِ
|
||||
/// بصحّةٍ أو بطلانٍ.
|
||||
/// - أنواعُ الإنشاءِ الطّلبيِّ الستّةُ (ص56-57): النداءُ، والأمرُ،
|
||||
/// والنهيُ، والاستفهامُ، والتّمنّي، والتّرجّي.
|
||||
/// - أدواتُه (ص56-57): حروفُ النداءِ (يا وأخواتُها) للنداءِ؛ فعلُ الأمرِ
|
||||
/// أو المضارعُ المقترنُ بلامِ الأمرِ للأمرِ؛ (لا) النّاهيةُ للنهيِ؛
|
||||
/// أدواتُ الاستفهامِ (هل/كيف/متى/أين...) للاستفهامِ؛ (ليت) للتّمنّي؛
|
||||
/// (لعل) للتّرجّي.
|
||||
/// - أمثلةُ الدرسِ (ص57-59): ﴿يا شُعيبُ أَصَلاتُكَ تَأْمُرُكَ...﴾
|
||||
/// (سورة هود 87) — نداءٌ + استفهامٌ؛ «ألا ليتَ شِعري هَل أبِيتُ
|
||||
/// ليلةً...» (الفرزدقُ) — تمنٍّ + استفهامٌ؛ «يا أَيّها النّاسُ اتّقوا
|
||||
/// ربَّكم» — نداءُ المعرَّفِ بـ(ال) بـ(أيّها)؛ «لا تَحسِبِ المَجْدَ
|
||||
/// تَمْرًا أنتَ آكِلُهُ، لن تَبلُغَ المَجْدَ حتى تَلعَقَ الصَّبِرا»
|
||||
/// (أبو العلاءِ المعرّي) — نهيٌ بـ(لا النّاهيةِ)؛ «السلامُ عليكم
|
||||
/// دارَ قومٍ مؤمنينَ» — نداءٌ مقدَّرًا حرفُه (يا دارَ قومٍ)؛
|
||||
/// (اللّهُمَّ) عُوِّضَ حرفُ النداءِ فيهِ بميمٍ مشدّدةٍ مبدَلٍ منَ حرفِ
|
||||
/// النداءِ المحذوفِ.
|
||||
/// ============================================================================
|
||||
|
||||
// مفهومُ الإنشاءِ الطّلبيِّ (صواب/خطأ).
|
||||
const List<(String, bool)> _requestStatements = [
|
||||
(
|
||||
'الإنشاءُ الطلبيُّ كلامٌ لا يحتملُ التّصديقَ أو التّكذيبَ',
|
||||
true
|
||||
),
|
||||
(
|
||||
'من صيغِ الإنشاءِ الطّلبيِّ: النداءُ، والأمرُ، والنهيُ، والاستفهامُ، والتّمنّي، والتّرجّي',
|
||||
true
|
||||
),
|
||||
(
|
||||
'الأمرُ يكونُ بفعلِ الأمرِ أو بالمضارعِ المقترنِ بلامِ الأمرِ',
|
||||
true
|
||||
),
|
||||
(
|
||||
'النهيُ يكونُ بـ(لا) النّاهيةِ قبلَ الفعلِ المضارعِ',
|
||||
true
|
||||
),
|
||||
(
|
||||
'الاستفهامُ يكونُ بأدواتِ الاستفهامِ مثلَ: (هل، كيف، متى، أين)',
|
||||
true
|
||||
),
|
||||
(
|
||||
'التّمنّي يكونُ بـ(ليت) والتّرجّي بـ(لعل)',
|
||||
true
|
||||
),
|
||||
(
|
||||
'النداءُ يكونُ بحرفِ النداءِ وحدَهُ دونَ المنادى',
|
||||
false
|
||||
),
|
||||
(
|
||||
'الإنشاءُ الطلبيُّ كلامٌ خبريٌّ يرادُ منهُ إخبارُ المخاطَبِ بشيءٍ وقعَ فعلًا',
|
||||
false
|
||||
),
|
||||
];
|
||||
|
||||
// أنواعُ الإنشاءِ الطّلبيِّ معَ مثالِهِ منَ الدرسِ.
|
||||
const List<(String, String, String, int)> _typesRows = [
|
||||
(
|
||||
'النداءُ',
|
||||
'حرفُ النداءِ (يا وأخواتُها) ثمّ المنادى.',
|
||||
'يا شُعيبُ أَصلاةُكَ تأمرُكَ...',
|
||||
0
|
||||
),
|
||||
(
|
||||
'الأمرُ',
|
||||
'فعلُ الأمرِ أو المضارعُ بلامِ الأمرِ.',
|
||||
'اقرأْ باسمِ ربِّكَ الّذي خَلَقَ',
|
||||
1
|
||||
),
|
||||
(
|
||||
'النهيُ',
|
||||
'(لا) النّاهيةُ قبلَ المضارعِ.',
|
||||
'لا تَحسِبِ المَجْدَ تَمْرًا أنتَ آكِلُهُ',
|
||||
2
|
||||
),
|
||||
(
|
||||
'الاستفهامُ',
|
||||
'أدواتُ الاستفهامِ (هل/كيف/متى/أين).',
|
||||
'هَل أبِيتُ ليلةً...',
|
||||
3
|
||||
),
|
||||
(
|
||||
'التّمنّي',
|
||||
'(ليت).',
|
||||
'ألا ليتَ شِعري هَل أبِيتُ...',
|
||||
4
|
||||
),
|
||||
(
|
||||
'التّرجّي',
|
||||
'(لعل).',
|
||||
'لعلَّ اللهَ يَفرِّجُ عنّي',
|
||||
5
|
||||
),
|
||||
];
|
||||
|
||||
// أمثلةُ توظيفِ الدرسِ (ص57-59) — اختيارُ النّوعِ الصّحيحِ.
|
||||
const List<(String, String, int)> _situationalStatements = [
|
||||
(
|
||||
'يا أَيّها النّاسُ اتّقوا ربَّكم',
|
||||
'النداءُ',
|
||||
0
|
||||
),
|
||||
(
|
||||
'اقرأْ باسمِ ربِّكَ الّذي خَلَقَ',
|
||||
'الأمرُ',
|
||||
1
|
||||
),
|
||||
(
|
||||
'لا تَحسِبِ المَجْدَ تَمْرًا أنتَ آكِلُهُ',
|
||||
'النهيُ',
|
||||
2
|
||||
),
|
||||
(
|
||||
'هَل أبِيتُ ليلةً بِبَثنَةَ ليلةً',
|
||||
'الاستفهامُ',
|
||||
3
|
||||
),
|
||||
(
|
||||
'ألا ليتَ شِعري',
|
||||
'التّمنّي',
|
||||
4
|
||||
),
|
||||
(
|
||||
'لعلَّ اللهَ يَفرِّجُ عنّي',
|
||||
'التّرجّي',
|
||||
5
|
||||
),
|
||||
];
|
||||
|
||||
class ArabicUnit2InshaLabView extends StatefulWidget {
|
||||
final LabCheckpointCallback? onCheckpointTriggered;
|
||||
|
||||
const ArabicUnit2InshaLabView({super.key, this.onCheckpointTriggered});
|
||||
|
||||
@override
|
||||
State<ArabicUnit2InshaLabView> createState() =>
|
||||
_ArabicUnit2InshaLabViewState();
|
||||
}
|
||||
|
||||
class _ArabicUnit2InshaLabViewState extends State<ArabicUnit2InshaLabView> {
|
||||
int _activity = 0;
|
||||
|
||||
// Activity 0 — مفهومُ الإنشاءِ الطّلبيِّ.
|
||||
final List<bool> _request = List.filled(_requestStatements.length, false);
|
||||
bool _requestTouched = false;
|
||||
|
||||
// Activity 1 — أدواتُ أنواعِ الإنشاءِ الطّلبيِّ.
|
||||
final List<int> _selected = List.filled(_typesRows.length, -1);
|
||||
bool _typesTouched = false;
|
||||
|
||||
// Activity 2 — توظيفُ الإنشاءِ الطّلبيِّ في أمثلةِ الدرسِ.
|
||||
final List<int> _situational = List.filled(_situationalStatements.length, -1);
|
||||
bool _situationalTouched = false;
|
||||
|
||||
bool get _requestDone {
|
||||
for (var i = 0; i < _requestStatements.length; i++) {
|
||||
if (_request[i] != _requestStatements[i].$2) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool get _typesDone {
|
||||
if (!_typesTouched) return false;
|
||||
for (var i = 0; i < _typesRows.length; i++) {
|
||||
if (_selected[i] != _typesRows[i].$4) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool get _situationalDone {
|
||||
if (!_situationalTouched) return false;
|
||||
for (var i = 0; i < _situationalStatements.length; i++) {
|
||||
if (_situational[i] != _situationalStatements[i].$3) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool get _done {
|
||||
if (_activity == 0) return _requestDone && _requestTouched;
|
||||
if (_activity == 1) return _typesDone;
|
||||
return _situationalDone;
|
||||
}
|
||||
|
||||
Widget _buildCanvas() {
|
||||
switch (_activity) {
|
||||
case 0:
|
||||
return CustomPaint(
|
||||
painter: _RequestConstructionPainter(
|
||||
checks: List.of(_request), done: _requestDone),
|
||||
size: Size.infinite,
|
||||
);
|
||||
case 1:
|
||||
return CustomPaint(
|
||||
painter: _RequestTypesPainter(
|
||||
selected: List.of(_selected), done: _typesDone),
|
||||
size: Size.infinite,
|
||||
);
|
||||
default:
|
||||
return CustomPaint(
|
||||
painter: _RequestSituationalPainter(
|
||||
picks: List.of(_situational), done: _situationalDone),
|
||||
size: Size.infinite,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<Widget> _buildControls() {
|
||||
final c = <Widget>[
|
||||
LabSegments<int>(
|
||||
labels: const ['مفهومُ الإنشاءِ', 'أنواعُ الإنشاءِ', 'توظيفُ الإنشاءِ'],
|
||||
values: const [0, 1, 2],
|
||||
current: _activity,
|
||||
onSelected: (v) => setState(() {
|
||||
_activity = v;
|
||||
saqelTick();
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
];
|
||||
|
||||
if (_activity == 0) {
|
||||
c.addAll([
|
||||
const Text(
|
||||
'أُعيِّنُ (صوابًا أو خطأً) على جُمَلِ مفهومِ الإنشاءِ الطّلبيِّ وصيغِهِ.',
|
||||
style: TextStyle(color: Colors.white, fontSize: 12.5, height: 1.6),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
for (var i = 0; i < _requestStatements.length; i++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: LabToggle(
|
||||
label: _requestStatements[i].$1,
|
||||
value: _request[i],
|
||||
onChanged: (v) => setState(() {
|
||||
_request[i] = v;
|
||||
_requestTouched = true;
|
||||
if (_requestDone) saqelTick();
|
||||
}),
|
||||
),
|
||||
),
|
||||
if (_requestDone && _requestTouched)
|
||||
const LabPill(
|
||||
'مفهومُ الإنشاءِ الطّلبيِّ مثبَّتٌ: كلامٌ لا يحتملُ التّصديقَ أو التّكذيبَ، يطلبُ حصولَ شيءٍ لم يقعْ بَعْدُ؛ والمرادُ: طلبُ حصولِ أمرٍ غيرِ متحقِّقٍ، لا إخبارٌ عمّا وقعْ ✓',
|
||||
color: Color(0xFF30D158))
|
||||
else
|
||||
const LabPill(
|
||||
'الصوابُ: الإنشاءُ الطلبيُّ كلامٌ لا يحتملُ التّصديقَ أو التّكذيبَ، يطلبُ حصولَ أمرٍ لم يتحقَّقْ وقتَ الطّلبِ (نداءٌ، أمرٌ، نهيٌ، استفهامٌ، تمنٍّ، ترجٍّ). الخطأُ: النداءُ بلا منادى، والخبريُّ الذي يرادُ بهِ إخبارُ المخاطَبِ بما وقعَ.',
|
||||
color: Color(0xFFFF9F0A)),
|
||||
]);
|
||||
} else if (_activity == 1) {
|
||||
c.addAll([
|
||||
const Text(
|
||||
'أُقرِّرُ لكلِّ نوعٍ من أنواعِ الإنشاءِ الطّلبيِّ أسلوبَهُ الصّحيحَ من خلالِ مثالِ الدرسِ.',
|
||||
style: TextStyle(color: Colors.white, fontSize: 12.5, height: 1.6),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
for (var r = 0; r < _typesRows.length; r++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_typesRows[r].$1,
|
||||
style: const TextStyle(color: Color(0xFF00F5D4), fontSize: 12.5),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
for (var t = 0; t < _typesRows.length; t++)
|
||||
ChoiceChip(
|
||||
label: Text(
|
||||
_typesRows[t].$1,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 11),
|
||||
),
|
||||
selected: _selected[r] == t,
|
||||
onSelected: (v) => setState(() {
|
||||
_selected[r] = v ? t : -1;
|
||||
_typesTouched = true;
|
||||
if (_typesDone) saqelTick();
|
||||
}),
|
||||
selectedColor: const Color(0xFF00F5D4).withValues(alpha: 0.25),
|
||||
backgroundColor: const Color(0x0DFFFFFF),
|
||||
side: BorderSide(
|
||||
color: _selected[r] == t
|
||||
? const Color(0xFF00F5D4)
|
||||
: Colors.white12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_typesDone)
|
||||
const LabPill(
|
||||
'أدواتُ أنواعِ الإنشاءِ الطّلبيِّ مثبّتةٌ: النداءُ (يا وأخواتُها)، الأمرُ (فعلُ الأمرِ/لامُ الأمرِ)، النهيُ (لا النّاهيةُ)، الاستفهامُ (هل/كيف/متى/أين)، التّمنّي (ليت)، التّرجّي (لعل) ✓',
|
||||
color: Color(0xFF30D158))
|
||||
else
|
||||
const LabPill(
|
||||
'راجع: النداءُ يُبنى على حرفِ النداءِ والمنادى؛ الأمرُ بفعلِ الأمرِ؛ النهيُ بلا النّاهيةِ؛ الاستفهامُ بأدواتِهِ؛ التّمنّي بليت؛ والتّرجّي بلعل.',
|
||||
color: Color(0xFFFF9F0A)),
|
||||
]);
|
||||
} else {
|
||||
c.addAll([
|
||||
const Text(
|
||||
'أُحدِّدُ نوعَ الإنشاءِ الطّلبيِّ في كلِّ مثالٍ من أمثلةِ الدرسِ.',
|
||||
style: TextStyle(color: Colors.white, fontSize: 12.5, height: 1.6),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
for (var i = 0; i < _situationalStatements.length; i++)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
_situationalStatements[i].$1,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 11),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 150,
|
||||
child: DropdownButtonFormField<int>(
|
||||
initialValue: _situational[i] < 0 ? null : _situational[i],
|
||||
dropdownColor: const Color(0xFF0B1728),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: const Color(0x0DFFFFFF),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
items: [
|
||||
for (var t = 0; t < _typesRows.length; t++)
|
||||
DropdownMenuItem(
|
||||
value: t,
|
||||
child: Text(
|
||||
_typesRows[t].$1,
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontSize: 11),
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (v) => setState(() {
|
||||
_situational[i] = v!;
|
||||
_situationalTouched = true;
|
||||
if (_situationalDone) saqelTick();
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_situationalDone)
|
||||
const LabPill(
|
||||
'توظيفُ الإنشاءِ الطّلبيِّ سليمٌ في أمثلةِ الدرسِ كلِّها ✓',
|
||||
color: Color(0xFF30D158))
|
||||
else
|
||||
const LabPill(
|
||||
'الصوابُ: (يا أَيّها النّاسُ) نداءٌ، (اقرأْ) أمرٌ، (لا تَحسِبْ) نهيٌ، (هل) استفهامٌ، (ليت) تمنٍّ، (لعل) ترجٍّ.',
|
||||
color: Color(0xFFFF9F0A)),
|
||||
]);
|
||||
}
|
||||
|
||||
if (_done) {
|
||||
c.addAll([
|
||||
const SizedBox(height: 12),
|
||||
LabCheckpointButton(
|
||||
question:
|
||||
'لماذا لا يحتملُ الإنشاءُ الطلبيُّ التّصديقَ أو التّكذيبَ؟ وكيف نفرِّقُ بينَ الخبريِّ والإنشائيّ الطّلبيِّ؟',
|
||||
options: const [
|
||||
'لأنّهُ يطلبُ حصولَ أمرٍ لم يتحقَّقْ وقتَ الطّلبِ، فهوَ ليسَ خبرًا يُصحَّحُ أو يُكذَّبُ',
|
||||
'لأنّهُ خبرٌ صادقٌ يقبلُ التّصديقَ أو التّكذيبَ',
|
||||
'لأنّهُ طلبٌ لا يرتبطُ بحصولِ أمرٍ في المستقبلِ',
|
||||
],
|
||||
correctIdx: 0,
|
||||
onCheckpointTriggered: widget.onCheckpointTriggered,
|
||||
),
|
||||
]);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SaqelLabScaffold(
|
||||
identity: kArabicUnit2InshaLabIdentity,
|
||||
titleAr: 'مختبرُ الأسلوبِ الإنشائيّ الطّلبيِّ',
|
||||
subtitleAr: 'أبني لغتي (2) — الإنشاءُ الطلبيُّ: طلبُ حصولِ أمرٍ لم يقعْ بَعْدُ، لا يحتملُ التّصديقَ أو التّكذيبَ.',
|
||||
canvas: _buildCanvas(),
|
||||
controls: _buildControls(),
|
||||
footerNote:
|
||||
'مصدر: صفحات 56-59. الإنشاءُ الطلبيُّ طلبُ أمرٍ غيرِ متحقِّقٍ وقتَ الطّلبِ، لا يحتملُ التّصديقَ أو التّكذيبَ، بخلافِ الخبريِّ. أنوعُه الستّةُ بأدواتِها وأمثلةُ الدرسِ (يا شُعيبُ، قرأْ، لا تَحسِبْ، هل، ليت، لعل) — قابلٌ للإثباتِ من ص56-59.',
|
||||
checkpointQuestion:
|
||||
'لماذا لا يحتملُ الإنشاءُ الطلبيُّ التّصديقَ أو التّكذيبَ؟',
|
||||
checkpointOptions: const [
|
||||
'لأنّهُ يطلبُ حصولَ أمرٍ لم يتحقَّقْ وقتَ الطّلبِ، فهوَ ليسَ خبرًا يُصحَّحُ أو يُكذَّبُ.',
|
||||
'لأنّهُ خبرٌ صادقٌ يُقبلُ التّصديقَ أو التّكذيبَ.',
|
||||
'لأنّهُ طلبٌ لا يرتبطُ بأمرٍ يُطلبُ.',
|
||||
],
|
||||
checkpointCorrectIdx: 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Canvas 0 — مفهومُ الإنشاءِ الطّلبيِّ.
|
||||
// ============================================================================
|
||||
class _RequestConstructionPainter extends CustomPainter {
|
||||
final List<bool> checks;
|
||||
final bool done;
|
||||
|
||||
const _RequestConstructionPainter({
|
||||
required this.checks,
|
||||
required this.done,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final w = size.width, h = size.height;
|
||||
var lit = 0;
|
||||
for (var i = 0; i < checks.length; i++) {
|
||||
if (checks[i] == _requestStatements[i].$2) lit++;
|
||||
}
|
||||
final pct = _requestStatements.isEmpty ? 0.0 : lit / _requestStatements.length;
|
||||
canvas.drawCircle(
|
||||
Offset(w / 2, h * 0.22),
|
||||
30,
|
||||
Paint()..color = const Color(0xFF0B1728),
|
||||
);
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: Offset(w / 2, h * 0.22), radius: 34),
|
||||
-math.pi / 2,
|
||||
math.pi * 2 * pct,
|
||||
false,
|
||||
Paint()
|
||||
..color = done ? const Color(0xFF30D158) : const Color(0xFF00F5D4)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 3,
|
||||
);
|
||||
_centerText(
|
||||
canvas,
|
||||
Offset(w / 2, h * 0.22),
|
||||
done ? 'طلبٌ متحقِّقٌ ✓' : 'طلبُ حصولِ أمرٍ لم يقعْ بَعْدُ',
|
||||
TextStyle(
|
||||
color: done ? const Color(0xFF30D158) : Colors.white70,
|
||||
fontSize: 9.5,
|
||||
fontWeight: FontWeight.w700),
|
||||
maxWidth: w * 0.4,
|
||||
);
|
||||
|
||||
for (var i = 0; i < _requestStatements.length; i++) {
|
||||
final y = h * 0.38 + i * (h * 0.55) / _requestStatements.length;
|
||||
final on = checks[i] == _requestStatements[i].$2;
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
Rect.fromCenter(
|
||||
center: Offset(w / 2, y), width: w * 0.82, height: 30),
|
||||
const Radius.circular(15),
|
||||
),
|
||||
Paint()..color = const Color(0xFF0B1728),
|
||||
);
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
Rect.fromCenter(
|
||||
center: Offset(w / 2, y), width: w * 0.82, height: 30),
|
||||
const Radius.circular(15),
|
||||
),
|
||||
Paint()
|
||||
..color = on ? const Color(0xFF30D158) : Colors.white12
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.2,
|
||||
);
|
||||
_centerText(
|
||||
canvas,
|
||||
Offset(w / 2, y),
|
||||
(on ? '✓ ' : '· ') + _requestStatements[i].$1,
|
||||
TextStyle(
|
||||
color: on ? const Color(0xFF30D158) : Colors.white70,
|
||||
fontSize: 9.5),
|
||||
maxWidth: w * 0.76,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_RequestConstructionPainter old) =>
|
||||
old.checks != checks || old.done != done;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Canvas 1 — أدواتُ أنواعِ الإنشاءِ الطّلبيِّ.
|
||||
// ============================================================================
|
||||
class _RequestTypesPainter extends CustomPainter {
|
||||
final List<int> selected;
|
||||
final bool done;
|
||||
|
||||
const _RequestTypesPainter({required this.selected, required this.done});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final w = size.width, h = size.height;
|
||||
final center = Offset(w / 2, h * 0.5);
|
||||
_centerText(
|
||||
canvas,
|
||||
Offset(w / 2, h * 0.12),
|
||||
done ? 'عجلةُ الإنشاءِ الطّلبيِّ مضبوطةٌ ✓' : 'أنواعُ الإنشاءِ الطّلبيِّ',
|
||||
TextStyle(
|
||||
color: done ? const Color(0xFF30D158) : Colors.white70,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700),
|
||||
maxWidth: w * 0.86,
|
||||
);
|
||||
|
||||
canvas.drawCircle(center, 30, Paint()..color = const Color(0xFF0B1728));
|
||||
canvas.drawCircle(
|
||||
center,
|
||||
90,
|
||||
Paint()
|
||||
..color = done ? const Color(0xFF30D158) : Colors.white12
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2,
|
||||
);
|
||||
|
||||
for (var i = 0; i < _typesRows.length; i++) {
|
||||
final a = -math.pi / 2 + i * 2 * math.pi / _typesRows.length;
|
||||
final pos = center + Offset(math.cos(a), math.sin(a)) * 68;
|
||||
final on = selected[i] == i;
|
||||
canvas.drawCircle(pos, 15, Paint()..color = const Color(0xFF0B1728));
|
||||
canvas.drawCircle(
|
||||
pos,
|
||||
15,
|
||||
Paint()
|
||||
..color = on ? const Color(0xFF30D158) : Colors.white12
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.2,
|
||||
);
|
||||
_centerText(
|
||||
canvas,
|
||||
pos,
|
||||
on ? '✓ ${_typesRows[i].$1}' : _typesRows[i].$1,
|
||||
TextStyle(
|
||||
color: on ? const Color(0xFF30D158) : Colors.white70,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w700),
|
||||
maxWidth: w * 0.3,
|
||||
);
|
||||
final io = center + Offset(math.cos(a), math.sin(a)) * 68;
|
||||
_centerText(
|
||||
canvas,
|
||||
io + const Offset(0, 28),
|
||||
_typesRows[i].$2,
|
||||
TextStyle(
|
||||
color: done ? const Color(0xFF30D158) : Colors.white60,
|
||||
fontSize: 7.5),
|
||||
maxWidth: w * 0.26,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_RequestTypesPainter old) =>
|
||||
old.selected != selected || old.done != done;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Canvas 2 — توظيفُ أمثلةِ الدرسِ.
|
||||
// ============================================================================
|
||||
class _RequestSituationalPainter extends CustomPainter {
|
||||
final List<int> picks;
|
||||
final bool done;
|
||||
|
||||
const _RequestSituationalPainter({required this.picks, required this.done});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final w = size.width, h = size.height;
|
||||
_centerText(
|
||||
canvas,
|
||||
Offset(w / 2, h * 0.10),
|
||||
done ? 'توظيفُ الإنشاءِ الطّلبيِّ سليمٌ ✓' : 'أُحدِّدُ نوعَ الإنشاءِ في مثالٍ',
|
||||
TextStyle(
|
||||
color: done ? const Color(0xFF30D158) : Colors.white70,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700),
|
||||
maxWidth: w * 0.9,
|
||||
);
|
||||
|
||||
for (var i = 0; i < _situationalStatements.length; i++) {
|
||||
final correct = _situationalStatements[i].$3;
|
||||
final on = picks[i] == correct;
|
||||
final y = h * 0.20 + i * (h * 0.72) / _situationalStatements.length;
|
||||
final outline = RRect.fromRectAndRadius(
|
||||
Rect.fromCenter(
|
||||
center: Offset(w / 2, y), width: w * 0.86, height: 30),
|
||||
const Radius.circular(15),
|
||||
);
|
||||
canvas.drawRRect(outline, Paint()..color = const Color(0xFF0B1728));
|
||||
canvas.drawRRect(
|
||||
outline,
|
||||
Paint()
|
||||
..color = on ? const Color(0xFF30D158) : Colors.white12
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.2,
|
||||
);
|
||||
_centerText(
|
||||
canvas,
|
||||
Offset(w / 2, y),
|
||||
(on ? '✓ ' : '· ') + _situationalStatements[i].$1,
|
||||
TextStyle(
|
||||
color: on ? const Color(0xFF30D158) : Colors.white70,
|
||||
fontSize: 9.5),
|
||||
maxWidth: w * 0.8,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_RequestSituationalPainter old) =>
|
||||
old.picks != picks || old.done != done;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// مساعداتُ رسمٍ مشتركةٌ.
|
||||
// ============================================================================
|
||||
void _centerText(
|
||||
Canvas canvas,
|
||||
Offset center,
|
||||
String text,
|
||||
TextStyle style, {
|
||||
double? maxWidth,
|
||||
}) {
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(text: text, style: style),
|
||||
textDirection: TextDirection.rtl,
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 4,
|
||||
)..layout(maxWidth: maxWidth ?? double.infinity);
|
||||
tp.paint(canvas, Offset(center.dx - tp.width / 2, center.dy - tp.height / 2));
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import 'lab_identity.dart';
|
||||
import 'lab_scaffold.dart';
|
||||
|
||||
/// ============================================================================
|
||||
/// EARTH & ENVIRONMENTAL SCIENCES — GRADE 10
|
||||
/// Unit 3, Lesson 1 — الكتل والجبهات الهوائية (Air Masses & Fronts)
|
||||
/// Grounded verbatim in Ministry textbook pages 8-15:
|
||||
/// - 4 Air Masses: cP (قارية قطبية), mP (بحرية قطبية), cT (قارية مدارية), mT (بحرية مدارية)
|
||||
/// - 4 Front Types: جبهة باردة (Cold), جبهة دافئة (Warm), جبهة مقفلة (Occluded), جبهة مستقرة (Stationary)
|
||||
/// - Visual cloud formation, precipitation styles, and temperature boundary profile
|
||||
/// ============================================================================
|
||||
|
||||
class EarthAirMassesLabView extends StatefulWidget {
|
||||
final LabCheckpointCallback? onCheckpointTriggered;
|
||||
const EarthAirMassesLabView({super.key, this.onCheckpointTriggered});
|
||||
|
||||
@override
|
||||
State<EarthAirMassesLabView> createState() => _EarthAirMassesLabViewState();
|
||||
}
|
||||
|
||||
class _EarthAirMassesLabViewState extends State<EarthAirMassesLabView>
|
||||
with SingleTickerProviderStateMixin {
|
||||
int _frontMode = 0; // 0 = Cold Front, 1 = Warm Front, 2 = Stationary, 3 = Occluded
|
||||
int _selectedAirMass = 0; // 0 = cP, 1 = mP, 2 = cT, 3 = mT
|
||||
late final AnimationController _cloudAnimController;
|
||||
|
||||
static const List<Map<String, dynamic>> _airMasses = [
|
||||
{
|
||||
'code': 'cP',
|
||||
'name': 'قارية قطبية (Continental Polar)',
|
||||
'temp': 'شديدة البرودة (-10°C إلى 2°C)',
|
||||
'humidity': 'جافة جداً (رطوبة منخفضة)',
|
||||
'source': 'سيبيريا وشمال كندا وأوراسيا',
|
||||
'jordanImpact': 'موجات صقيع وانجماد جافة شتاءً في الأردن',
|
||||
'color': Color(0xFF60A5FA),
|
||||
},
|
||||
{
|
||||
'code': 'mP',
|
||||
'name': 'بحرية قطبية (Maritime Polar)',
|
||||
'temp': 'باردة ورطبة (2°C إلى 8°C)',
|
||||
'humidity': 'عالية الرطوبة والتشبع',
|
||||
'source': 'شمال المحيط الأطلسي والقطب الشمالي',
|
||||
'jordanImpact': 'منخفضات جوية شتوية مصحوبة بأمطار وثلوج غزيرة',
|
||||
'color': Color(0xFF38BDF8),
|
||||
},
|
||||
{
|
||||
'code': 'cT',
|
||||
'name': 'قارية مدارية (Continental Tropical)',
|
||||
'temp': 'حارة جداً وجافة (34°C إلى 42°C)',
|
||||
'humidity': 'جافة ومغبرة أحياناً',
|
||||
'source': 'شبه الجزيرة العربية والصحراء الكبرى',
|
||||
'jordanImpact': 'موجات حر صيفية ورياح خماسينية في الربيع',
|
||||
'color': Color(0xFFF59E0B),
|
||||
},
|
||||
{
|
||||
'code': 'mT',
|
||||
'name': 'بحرية مدارية (Maritime Tropical)',
|
||||
'temp': 'دافئة ورطبة (24°C إلى 30°C)',
|
||||
'humidity': 'رطوبة جوية مرتفعة وضباب',
|
||||
'source': 'المحيط الأطلسي والبحر الأحمر وخليج العقبة',
|
||||
'jordanImpact': 'حالات عدم استقرار جوي وزخات رعدية مفاجئة',
|
||||
'color': Color(0xFF10B981),
|
||||
},
|
||||
];
|
||||
|
||||
static const List<Map<String, dynamic>> _frontTypes = [
|
||||
{
|
||||
'title': 'الجبهة الهوائية الباردة (Cold Front)',
|
||||
'symbol': 'مثلثات زرقاء تشير لاتجاه الحركة ▲▲▲',
|
||||
'mechanism': 'هواء بارد كثيف يندفع سريعاً تحت الهواء الدافئ الأقل كثافة، فيرفعه بقوة للأعلى.',
|
||||
'clouds': 'غيوم المزن الركامية (Cumulonimbus) الشاهقة',
|
||||
'weather': 'أمطار غزيرة مفاجئة، عواصف رعدية وزخات بَرَد ورياح نشطة يعقبها انخفاض ملموس في الحرارة.',
|
||||
'color': Color(0xFF2563EB),
|
||||
},
|
||||
{
|
||||
'title': 'الجبهة الهوائية الدافئة (Warm Front)',
|
||||
'symbol': 'أنصاف دوائر حمراء تشير لاتجاه الحركة ●●●',
|
||||
'mechanism': 'هواء دافئ يصعد تدريجياً وببطء فوق كتلة هوائية باردة ثابتة أو بطيئة.',
|
||||
'clouds': 'غيوم طبقية (Stratus) تبدأ بالسمحاقية ثم الركامية المتوسطة فالطبقية المنبسطة',
|
||||
'weather': 'أمطار ديمية مستمرة وخفيفة إلى متوسطة على مساحات شاسعة، مع ارتفاع تدريجي في درجات الحرارة.',
|
||||
'color': Color(0xFFDC2626),
|
||||
},
|
||||
{
|
||||
'title': 'الجبهة الهوائية المستقرة (Stationary Front)',
|
||||
'symbol': 'مثلثات زرقاء في جهة وأنصاف دوائر حمراء في الجهة المقابلة',
|
||||
'mechanism': 'تلتقي كتلة دافئة وأخرى باردة دون أن تتمكن إحداهما من إزاحة الأخرى لتعادل القوى.',
|
||||
'clouds': 'غيوم طبقية رمادية كثيفة',
|
||||
'weather': 'أجواء غائمة لعدة أيام مع هطول أمطار متقطعة ومستمرة في نفس المنطقة الجغرافية.',
|
||||
'color': Color(0xFF8B5CF6),
|
||||
},
|
||||
{
|
||||
'title': 'الجبهة الهوائية المقفلة (Occluded Front)',
|
||||
'symbol': 'مثلثات وأنصاف دوائر بنفسجية متبادلة على نفس الخط',
|
||||
'mechanism': 'جبهة باردة سريعة الحركة تلحق بجبهة دافئة وترفع الهواء الدافئ عن سطح الأرض بالكامل.',
|
||||
'clouds': 'مزيج معقد من الغيوم الركامية والطبقية الكثيفة',
|
||||
'weather': 'أمطار معقدة وشديدة وتبريد حاد يليه استقرار وتلاشي تدريجي للمنخفض الجوي.',
|
||||
'color': Color(0xFF7C3AED),
|
||||
},
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cloudAnimController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 4),
|
||||
)..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cloudAnimController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final activeAirMass = _airMasses[_selectedAirMass];
|
||||
final activeFront = _frontTypes[_frontMode];
|
||||
|
||||
return SaqelLabScaffold(
|
||||
titleAr: 'الكتل والجبهات الهوائية — علوم الأرض',
|
||||
subtitleAr: 'محاكاة ديناميكية لتصادم الكتل الهوائية وتشكل الغيوم وأنماط الهطول المعتمدة',
|
||||
identity: const LabIdentity(
|
||||
curriculumLessonId: 'earth_sciences_10_semester_2_unit_03_lesson_01',
|
||||
subjectKey: 'earth_sciences_10',
|
||||
semesterKey: 'semester_2',
|
||||
unitKey: 'unit_03',
|
||||
lessonKey: 'lesson_01',
|
||||
sourceMarkdown: 'grade_10/earth_sciences_10/semester_2/unit_03/lesson_01.md',
|
||||
subjectAr: 'علوم الأرض والبيئة',
|
||||
lessonAr: 'الكتل والجبهات الهوائية',
|
||||
),
|
||||
onCheckpointTriggered: widget.onCheckpointTriggered,
|
||||
checkpointQuestion:
|
||||
'عندما يندفع هواء بارد كثيف سريعاً أسفل هواء دافئ رطب، تتشكل جبهة باردة تؤدي إلى …',
|
||||
checkpointOptions: const [
|
||||
'غيوم المزن الركامية الشاهقة وأمطار غزيرة وعواصف رعدية',
|
||||
'أجواء صافية وجافة تماماً دون أي غيوم',
|
||||
'ارتفاع مفاجئ في درجات الحرارة والرياح الخماسينية',
|
||||
'غيوم رقيقة جداً لا ينتج عنها أي هطول'
|
||||
],
|
||||
checkpointCorrectIdx: 0,
|
||||
telemetry: [
|
||||
LabPill('النوع: ${activeFront['title']!.split('(').first.trim()}',
|
||||
color: activeFront['color'] as Color),
|
||||
LabPill('الكتلة: ${activeAirMass['code']}',
|
||||
color: activeAirMass['color'] as Color),
|
||||
LabPill('الرمز: ${activeFront['symbol']!.split(' ').first}'),
|
||||
],
|
||||
canvas: AnimatedBuilder(
|
||||
animation: _cloudAnimController,
|
||||
builder: (context, _) {
|
||||
return CustomPaint(
|
||||
painter: _AtmosphericFrontPainter(
|
||||
frontMode: _frontMode,
|
||||
airMassCode: activeAirMass['code'] as String,
|
||||
airMassColor: activeAirMass['color'] as Color,
|
||||
progress: _cloudAnimController.value,
|
||||
),
|
||||
child: Container(),
|
||||
);
|
||||
},
|
||||
),
|
||||
controls: [
|
||||
const Text(
|
||||
'اختر نمط الجبهة الهوائية للتصادم (Front Dynamics):',
|
||||
style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
LabSegments<int>(
|
||||
labels: const ['جبهة باردة ❄️', 'جبهة دافئة ☀️', 'مستقرة ⏸️', 'مقفلة 🌀'],
|
||||
values: const [0, 1, 2, 3],
|
||||
current: _frontMode,
|
||||
onSelected: (val) => setState(() => _frontMode = val),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Information card about current front
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0B1424),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: (activeFront['color'] as Color).withAlpha(90)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(CupertinoIcons.wind, color: activeFront['color'] as Color, size: 16),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
activeFront['title'] as String,
|
||||
style: TextStyle(
|
||||
color: activeFront['color'] as Color,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'آلية التكون: ${activeFront['mechanism']}',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 11.5, height: 1.45),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'نوع الغيوم الناتجة: ${activeFront['clouds']}',
|
||||
style: const TextStyle(color: AppColors.saqelCyan, fontSize: 11.5, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'الطقس المصاحب: ${activeFront['weather']}',
|
||||
style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'فحص تصنيف الكتل الهوائية المؤثرة على الأردن (Air Masses):',
|
||||
style: TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
LabSegments<int>(
|
||||
labels: const ['cP قارية قطبية', 'mP بحرية قطبية', 'cT قارية مدارية', 'mT بحرية مدارية'],
|
||||
values: const [0, 1, 2, 3],
|
||||
current: _selectedAirMass,
|
||||
onSelected: (val) => setState(() => _selectedAirMass = val),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF07101E),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.white12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${activeAirMass['code']}: ${activeAirMass['name']}',
|
||||
style: TextStyle(
|
||||
color: activeAirMass['color'] as Color,
|
||||
fontSize: 12.5,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'الحرارة والرطوبة: ${activeAirMass['temp']} • ${activeAirMass['humidity']}',
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 11.5),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'المصدر الجغرافي: ${activeAirMass['source']}',
|
||||
style: const TextStyle(color: AppColors.textSecondaryDark, fontSize: 11),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'أثرها المباشر في الأردن: ${activeAirMass['jordanImpact']}',
|
||||
style: const TextStyle(color: AppColors.guardianAmber, fontSize: 11, fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dynamic Custom Painter illustrating atmospheric cross-section:
|
||||
/// ground, warm air wedge, cold air undercut, dynamic cloud rendering, and rain streams.
|
||||
class _AtmosphericFrontPainter extends CustomPainter {
|
||||
final int frontMode; // 0=Cold, 1=Warm, 2=Stationary, 3=Occluded
|
||||
final String airMassCode;
|
||||
final Color airMassColor;
|
||||
final double progress;
|
||||
|
||||
_AtmosphericFrontPainter({
|
||||
required this.frontMode,
|
||||
required this.airMassCode,
|
||||
required this.airMassColor,
|
||||
required this.progress,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final w = size.width;
|
||||
final h = size.height;
|
||||
|
||||
// 1. Sky & Atmospheric background gradient
|
||||
final skyPaint = Paint()
|
||||
..shader = const LinearGradient(
|
||||
colors: [Color(0xFF0B1B36), Color(0xFF071020)],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
).createShader(Rect.fromLTWH(0, 0, w, h));
|
||||
canvas.drawRect(Rect.fromLTWH(0, 0, w, h), skyPaint);
|
||||
|
||||
// 2. Ground surface
|
||||
final groundY = h * 0.85;
|
||||
final groundPaint = Paint()..color = const Color(0xFF1E293B);
|
||||
canvas.drawRect(Rect.fromLTWH(0, groundY, w, h - groundY), groundPaint);
|
||||
|
||||
// Ground grass line
|
||||
final grassPaint = Paint()
|
||||
..color = const Color(0xFF10B981)
|
||||
..strokeWidth = 2.5;
|
||||
canvas.drawLine(Offset(0, groundY), Offset(w, groundY), grassPaint);
|
||||
|
||||
// 3. Draw Front Boundary and Air wedges based on mode
|
||||
if (frontMode == 0) {
|
||||
// COLD FRONT: Steep cold wedge pushing rightward under warm air
|
||||
_drawColdFront(canvas, w, h, groundY);
|
||||
} else if (frontMode == 1) {
|
||||
// WARM FRONT: Gentle slope, warm air gliding up over retreating cold air
|
||||
_drawWarmFront(canvas, w, h, groundY);
|
||||
} else if (frontMode == 2) {
|
||||
// STATIONARY FRONT: Two opposing air masses side-by-side
|
||||
_drawStationaryFront(canvas, w, h, groundY);
|
||||
} else {
|
||||
// OCCLUDED FRONT: Cold air catches up, lifting warm pocket completely aloft
|
||||
_drawOccludedFront(canvas, w, h, groundY);
|
||||
}
|
||||
|
||||
// 4. Draw Air Mass Telemetry Watermark on canvas
|
||||
final tagPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: 'كتلة هوائية نشطة: $airMassCode',
|
||||
style: TextStyle(
|
||||
color: airMassColor.withAlpha(200),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.rtl,
|
||||
)..layout();
|
||||
tagPainter.paint(canvas, Offset(w - tagPainter.width - 16, 16));
|
||||
}
|
||||
|
||||
void _drawColdFront(Canvas canvas, double w, double h, double groundY) {
|
||||
// Cold air wedge (steep slope on left)
|
||||
final coldWedgePath = Path()
|
||||
..moveTo(0, groundY)
|
||||
..lineTo(w * 0.55, groundY)
|
||||
..quadraticBezierTo(w * 0.48, groundY - 140, 0, groundY - 190)
|
||||
..close();
|
||||
|
||||
final coldPaint = Paint()
|
||||
..color = const Color(0xFF2563EB).withAlpha(120)
|
||||
..style = PaintingStyle.fill;
|
||||
canvas.drawPath(coldWedgePath, coldPaint);
|
||||
|
||||
// Cold air label
|
||||
_drawText(canvas, 'هواء بارد كثيف (Cold Air)', Offset(w * 0.12, groundY - 50),
|
||||
Colors.white70, 11);
|
||||
|
||||
// Warm air pushed upwards
|
||||
final warmArrowPaint = Paint()
|
||||
..color = const Color(0xFFEF4444).withAlpha(190)
|
||||
..strokeWidth = 2.5
|
||||
..style = PaintingStyle.stroke;
|
||||
canvas.drawLine(
|
||||
Offset(w * 0.65, groundY - 40), Offset(w * 0.50, groundY - 150), warmArrowPaint);
|
||||
_drawText(canvas, 'هواء دافئ يرتفع بقوة ⇈', Offset(w * 0.54, groundY - 170),
|
||||
const Color(0xFFFCA5A5), 11);
|
||||
|
||||
// Towering Cumulonimbus clouds at boundary
|
||||
_drawCloud(canvas, Offset(w * 0.46, groundY - 180), 55, const Color(0xFF475569));
|
||||
_drawCloud(canvas, Offset(w * 0.52, groundY - 150), 45, const Color(0xFF334155));
|
||||
_drawCloud(canvas, Offset(w * 0.48, groundY - 110), 40, const Color(0xFF1E293B));
|
||||
|
||||
// Heavy rain streams under cloud
|
||||
final rainPaint = Paint()
|
||||
..color = const Color(0xFF38BDF8).withAlpha(180)
|
||||
..strokeWidth = 1.8;
|
||||
for (int i = 0; i < 8; i++) {
|
||||
final rx = w * 0.42 + (i * 14);
|
||||
final ry = groundY - 90 + ((progress * 40 + i * 10) % 80);
|
||||
canvas.drawLine(Offset(rx, ry), Offset(rx - 4, ry + 16), rainPaint);
|
||||
}
|
||||
|
||||
// Front boundary line with blue triangles
|
||||
final frontLinePaint = Paint()
|
||||
..color = const Color(0xFF38BDF8)
|
||||
..strokeWidth = 3;
|
||||
canvas.drawLine(
|
||||
Offset(w * 0.48, groundY - 140), Offset(w * 0.55, groundY), frontLinePaint);
|
||||
|
||||
// Draw blue triangle markers
|
||||
_drawFrontMarkerTriangle(
|
||||
canvas, Offset(w * 0.50, groundY - 80), const Color(0xFF2563EB));
|
||||
}
|
||||
|
||||
void _drawWarmFront(Canvas canvas, double w, double h, double groundY) {
|
||||
// Cold retreating air wedge (gentle slope on right)
|
||||
final coldWedgePath = Path()
|
||||
..moveTo(w, groundY)
|
||||
..lineTo(w * 0.20, groundY)
|
||||
..lineTo(w, groundY - 180)
|
||||
..close();
|
||||
|
||||
final coldPaint = Paint()
|
||||
..color = const Color(0xFF3B82F6).withAlpha(90)
|
||||
..style = PaintingStyle.fill;
|
||||
canvas.drawPath(coldWedgePath, coldPaint);
|
||||
|
||||
_drawText(canvas, 'هواء بارد ينسحب ببطء', Offset(w * 0.65, groundY - 40),
|
||||
Colors.white70, 11);
|
||||
|
||||
// Warm air gliding over cold wedge
|
||||
final warmArrowPaint = Paint()
|
||||
..color = const Color(0xFFEF4444).withAlpha(190)
|
||||
..strokeWidth = 2.5
|
||||
..style = PaintingStyle.stroke;
|
||||
canvas.drawLine(
|
||||
Offset(w * 0.10, groundY - 20), Offset(w * 0.70, groundY - 160), warmArrowPaint);
|
||||
_drawText(canvas, 'هواء دافئ يصعد بانحدار لطيف ↗', Offset(w * 0.15, groundY - 110),
|
||||
const Color(0xFFFCA5A5), 11);
|
||||
|
||||
// Layered Stratus Clouds spread wide
|
||||
_drawCloud(canvas, Offset(w * 0.45, groundY - 120), 45, const Color(0xFF64748B));
|
||||
_drawCloud(canvas, Offset(w * 0.65, groundY - 150), 40, const Color(0xFF94A3B8));
|
||||
_drawCloud(canvas, Offset(w * 0.85, groundY - 175), 30, const Color(0xFFCBD5E1));
|
||||
|
||||
// Gentle continuous rain
|
||||
final rainPaint = Paint()
|
||||
..color = const Color(0xFF67E8F9).withAlpha(130)
|
||||
..strokeWidth = 1.2;
|
||||
for (int i = 0; i < 10; i++) {
|
||||
final rx = w * 0.35 + (i * 20);
|
||||
final ry = groundY - 70 + ((progress * 30 + i * 8) % 65);
|
||||
canvas.drawLine(Offset(rx, ry), Offset(rx - 2, ry + 12), rainPaint);
|
||||
}
|
||||
|
||||
// Front boundary line with red semicircles
|
||||
final frontLinePaint = Paint()
|
||||
..color = const Color(0xFFEF4444)
|
||||
..strokeWidth = 3;
|
||||
canvas.drawLine(
|
||||
Offset(w * 0.20, groundY), Offset(w * 0.80, groundY - 140), frontLinePaint);
|
||||
|
||||
// Draw red semicircle marker
|
||||
_drawFrontMarkerSemicircle(
|
||||
canvas, Offset(w * 0.45, groundY - 60), const Color(0xFFDC2626));
|
||||
}
|
||||
|
||||
void _drawStationaryFront(Canvas canvas, double w, double h, double groundY) {
|
||||
// Air masses abutting in the middle
|
||||
canvas.drawRect(
|
||||
Rect.fromLTWH(0, groundY - 160, w * 0.5, 160),
|
||||
Paint()..color = const Color(0xFF2563EB).withAlpha(70),
|
||||
);
|
||||
canvas.drawRect(
|
||||
Rect.fromLTWH(w * 0.5, groundY - 160, w * 0.5, 160),
|
||||
Paint()..color = const Color(0xFFDC2626).withAlpha(70),
|
||||
);
|
||||
|
||||
_drawText(canvas, 'كتلة باردة ←', Offset(w * 0.15, groundY - 60), Colors.white, 12);
|
||||
_drawText(canvas, '→ كتلة دافئة', Offset(w * 0.65, groundY - 60), Colors.white, 12);
|
||||
_drawText(canvas, 'توازن القوى (لا تقدم لأي طرف)', Offset(w * 0.32, groundY - 20),
|
||||
AppColors.guardianAmber, 11);
|
||||
|
||||
// Stationary front line
|
||||
final linePaint = Paint()
|
||||
..color = const Color(0xFF8B5CF6)
|
||||
..strokeWidth = 3;
|
||||
canvas.drawLine(Offset(w * 0.5, groundY), Offset(w * 0.5, groundY - 160), linePaint);
|
||||
|
||||
// Draw clouds at boundary
|
||||
_drawCloud(canvas, Offset(w * 0.5, groundY - 140), 45, const Color(0xFF475569));
|
||||
}
|
||||
|
||||
void _drawOccludedFront(Canvas canvas, double w, double h, double groundY) {
|
||||
// Cold air undercuts from both sides, warm pocket lifted aloft
|
||||
final coldWedge1 = Path()
|
||||
..moveTo(0, groundY)
|
||||
..lineTo(w * 0.65, groundY)
|
||||
..lineTo(0, groundY - 160)
|
||||
..close();
|
||||
canvas.drawPath(coldWedge1, Paint()..color = const Color(0xFF1D4ED8).withAlpha(120));
|
||||
|
||||
// Warm air lifted pocket
|
||||
final warmPocket = Path()
|
||||
..moveTo(w * 0.35, groundY - 110)
|
||||
..quadraticBezierTo(w * 0.50, groundY - 190, w * 0.65, groundY - 110)
|
||||
..close();
|
||||
canvas.drawPath(warmPocket, Paint()..color = const Color(0xFFEF4444).withAlpha(180));
|
||||
|
||||
_drawText(canvas, 'هواء دافئ معزول بالكامل في الأعلى',
|
||||
Offset(w * 0.28, groundY - 180), const Color(0xFFFECACA), 11);
|
||||
_drawText(canvas, 'هواء بارد سطحي', Offset(w * 0.15, groundY - 30), Colors.white70, 11);
|
||||
|
||||
// Occluded purple boundary
|
||||
final linePaint = Paint()
|
||||
..color = const Color(0xFF7C3AED)
|
||||
..strokeWidth = 3;
|
||||
canvas.drawLine(
|
||||
Offset(w * 0.50, groundY), Offset(w * 0.50, groundY - 110), linePaint);
|
||||
}
|
||||
|
||||
void _drawCloud(Canvas canvas, Offset center, double radius, Color color) {
|
||||
final cloudPaint = Paint()..color = color;
|
||||
canvas.drawCircle(center, radius, cloudPaint);
|
||||
canvas.drawCircle(Offset(center.dx - radius * 0.6, center.dy + radius * 0.2),
|
||||
radius * 0.75, cloudPaint);
|
||||
canvas.drawCircle(Offset(center.dx + radius * 0.6, center.dy + radius * 0.2),
|
||||
radius * 0.75, cloudPaint);
|
||||
}
|
||||
|
||||
void _drawFrontMarkerTriangle(Canvas canvas, Offset center, Color color) {
|
||||
final path = Path()
|
||||
..moveTo(center.dx, center.dy - 10)
|
||||
..lineTo(center.dx + 12, center.dy + 4)
|
||||
..lineTo(center.dx - 2, center.dy + 10)
|
||||
..close();
|
||||
canvas.drawPath(path, Paint()..color = color);
|
||||
}
|
||||
|
||||
void _drawFrontMarkerSemicircle(Canvas canvas, Offset center, Color color) {
|
||||
final rect = Rect.fromCircle(center: center, radius: 8);
|
||||
canvas.drawArc(rect, 0, math.pi, true, Paint()..color = color);
|
||||
}
|
||||
|
||||
void _drawText(
|
||||
Canvas canvas, String text, Offset offset, Color color, double fontSize) {
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.rtl,
|
||||
)..layout();
|
||||
tp.paint(canvas, offset);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _AtmosphericFrontPainter old) {
|
||||
return old.frontMode != frontMode ||
|
||||
old.airMassCode != airMassCode ||
|
||||
old.airMassColor != airMassColor ||
|
||||
old.progress != progress;
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,17 @@ class LabIdentity {
|
||||
// LESSON-BOUND LAB IDENTITIES (verified against spec front-matter)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LabIdentity kPhysicsVectorsIntroLabIdentity = LabIdentity(
|
||||
curriculumLessonId: 'physics_10_semester_1_unit_01_lesson_01',
|
||||
subjectKey: 'physics_10',
|
||||
semesterKey: 'semester_1',
|
||||
unitKey: 'unit_01',
|
||||
lessonKey: 'lesson_01',
|
||||
sourceMarkdown: 'grade_10/physics_10/semester_1/unit_01/lesson_01.md',
|
||||
subjectAr: 'الفيزياء',
|
||||
lessonAr: 'الكميات القياسية والمتجهة وتمثيلها',
|
||||
);
|
||||
|
||||
const LabIdentity kPhysicsVectorAdditionLabIdentity = LabIdentity(
|
||||
curriculumLessonId: 'physics_10_semester_1_unit_01_lesson_02',
|
||||
subjectKey: 'physics_10',
|
||||
@@ -117,6 +128,17 @@ const LabIdentity kPhysicsMotion1DLabIdentity = LabIdentity(
|
||||
lessonAr: 'الحركة في بعد واحد',
|
||||
);
|
||||
|
||||
const LabIdentity kPhysicsProjectileMotionLabIdentity = LabIdentity(
|
||||
curriculumLessonId: 'physics_10_semester_1_unit_02_lesson_02',
|
||||
subjectKey: 'physics_10',
|
||||
semesterKey: 'semester_1',
|
||||
unitKey: 'unit_02',
|
||||
lessonKey: 'lesson_02',
|
||||
sourceMarkdown: 'grade_10/physics_10/semester_1/unit_02/lesson_02.md',
|
||||
subjectAr: 'الفيزياء',
|
||||
lessonAr: 'حركة المقذوفات في بعدين',
|
||||
);
|
||||
|
||||
const LabIdentity kPhysicsCircularMotionLabIdentity = LabIdentity(
|
||||
curriculumLessonId: 'physics_10_semester_2_unit_04_lesson_03',
|
||||
subjectKey: 'physics_10',
|
||||
@@ -196,6 +218,17 @@ const LabIdentity kEarthRockCycleLabIdentity = LabIdentity(
|
||||
lessonAr: 'دورة الصخور',
|
||||
);
|
||||
|
||||
const LabIdentity kEarthAirMassesLabIdentity = LabIdentity(
|
||||
curriculumLessonId: 'earth_sciences_10_semester_2_unit_03_lesson_01',
|
||||
subjectKey: 'earth_sciences_10',
|
||||
semesterKey: 'semester_2',
|
||||
unitKey: 'unit_03',
|
||||
lessonKey: 'lesson_01',
|
||||
sourceMarkdown: 'grade_10/earth_sciences_10/semester_2/unit_03/lesson_01.md',
|
||||
subjectAr: 'علوم الأرض والبيئة',
|
||||
lessonAr: 'الكتل والجبهات الهوائية',
|
||||
);
|
||||
|
||||
const LabIdentity kMathSystemsLabIdentity = LabIdentity(
|
||||
curriculumLessonId: 'math_10_semester_1_unit_01_lesson_02',
|
||||
subjectKey: 'math_10',
|
||||
@@ -440,6 +473,20 @@ const LabIdentity kArabicUnit2VocativeLabIdentity = LabIdentity(
|
||||
lessonAr: 'أبني لغتي (1) — أسلوبُ النّداءِ',
|
||||
);
|
||||
|
||||
/// Unit 2, lesson 6 — build my language (2): the requestive construction (الإنشاءُ الطلبيُّ).
|
||||
/// Built on pages 56-59: concept of insha talabi (cannot be verified/falsified),
|
||||
/// six types (النداء، الأمر، النهي، الاستفهام، التمني، الترجي) with tools and examples.
|
||||
const LabIdentity kArabicUnit2InshaLabIdentity = LabIdentity(
|
||||
curriculumLessonId: 'arabic_10_semester_1_unit_02_lesson_06',
|
||||
subjectKey: 'arabic_10',
|
||||
semesterKey: 'semester_1',
|
||||
unitKey: 'unit_02',
|
||||
lessonKey: 'lesson_06',
|
||||
sourceMarkdown: 'grade_10/arabic_10/semester_1/unit_02/lesson_06.md',
|
||||
subjectAr: 'العربية لغتي',
|
||||
lessonAr: 'أبني لغتي (2) — الأسلوبُ الإنشائيّ (الإنشاءُ الطّلبيُّ)',
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// STANDALONE AUTHORING TOOLS (NO curriculum-lesson anchor in the corpus)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -504,18 +551,33 @@ const LabIdentity kArabicProsodyToolIdentity = LabIdentity(
|
||||
lessonAr: 'العروض والموسيقى الشعرية',
|
||||
);
|
||||
|
||||
const LabIdentity kIslamicTajweedToolIdentity = LabIdentity(
|
||||
toolKey: 'islamic_tajweed',
|
||||
const LabIdentity kIslamicTajweedLabIdentity = LabIdentity(
|
||||
gradeKey: 'grade_10',
|
||||
subjectKey: 'islamic_10',
|
||||
semesterKey: 'semester_1',
|
||||
unitKey: 'unit_01',
|
||||
lessonKey: 'lesson_01',
|
||||
curriculumLessonId: 'islamic_10_semester_1_unit_01_lesson_01',
|
||||
lessonAr: 'واجب المسلم تجاه القرآن الكريم (أحكام التلاوة والمخارج)',
|
||||
subjectAr: 'التربية الإسلامية',
|
||||
lessonAr: 'التجويد ومخارج الحروف',
|
||||
sourceMarkdown: 'grade_10/islamic_10/semester_1/unit_01/lesson_01.md',
|
||||
);
|
||||
|
||||
const LabIdentity kIslamicInheritanceToolIdentity = LabIdentity(
|
||||
toolKey: 'islamic_inheritance',
|
||||
const LabIdentity kIslamicInheritanceLabIdentity = LabIdentity(
|
||||
gradeKey: 'grade_10',
|
||||
subjectKey: 'islamic_10',
|
||||
semesterKey: 'semester_1',
|
||||
unitKey: 'unit_01',
|
||||
lessonKey: 'lesson_02',
|
||||
curriculumLessonId: 'islamic_10_semester_1_unit_01_lesson_02',
|
||||
lessonAr: 'فقه المعاملات والفرائض (حاسبة المواريث والأنصبة)',
|
||||
subjectAr: 'التربية الإسلامية',
|
||||
lessonAr: 'المواريث والفرائض',
|
||||
sourceMarkdown: 'grade_10/islamic_10/semester_1/unit_01/lesson_02.md',
|
||||
);
|
||||
|
||||
const LabIdentity kIslamicTajweedToolIdentity = kIslamicTajweedLabIdentity;
|
||||
const LabIdentity kIslamicInheritanceToolIdentity = kIslamicInheritanceLabIdentity;
|
||||
|
||||
const LabIdentity kFinanceBudgetToolIdentity = LabIdentity(
|
||||
toolKey: 'finance_budget',
|
||||
subjectAr: 'الثقافة المالية',
|
||||
|
||||
@@ -4,10 +4,13 @@ import '../../../data/models/socratic_checkpoint_model.dart';
|
||||
import '../../widgets/socratic_dialog.dart';
|
||||
import 'lab_identity.dart';
|
||||
import 'lab_scaffold.dart';
|
||||
import 'physics_vectors_intro_lab.dart';
|
||||
import 'physics_projectile_motion_lab.dart';
|
||||
import 'physics_labs.dart';
|
||||
import 'chemistry_labs.dart';
|
||||
import 'biology_labs.dart';
|
||||
import 'earth_labs.dart';
|
||||
import 'earth_air_masses_lab.dart';
|
||||
import 'math_labs.dart';
|
||||
import 'english_labs.dart';
|
||||
import 'islamic_labs.dart';
|
||||
@@ -28,6 +31,7 @@ import 'arabic_unit2_vocative_lab.dart';
|
||||
import 'arabic_unit2_writing_lab.dart';
|
||||
import 'arabic_unit2_poetry_lab.dart';
|
||||
import 'arabic_unit2_speaking_lab.dart';
|
||||
import 'arabic_unit2_insha_lab.dart';
|
||||
|
||||
/// ============================================================================
|
||||
/// GRADE-10 VIRTUAL LABS REGISTRY
|
||||
@@ -65,7 +69,11 @@ class Grade10LabsRegistry {
|
||||
// Bound lesson labs (17) and standalone authoring tools (16).
|
||||
// ---------------------------------------------------------------------------
|
||||
static final List<Grade10LabEntry> all = [
|
||||
// ---- Physics (4: 1 bound + 3 bound) ----
|
||||
// ---- Physics (6: 6 bound) ----
|
||||
Grade10LabEntry(
|
||||
identity: kPhysicsVectorsIntroLabIdentity,
|
||||
builder: (cb) => PhysicsVectorsIntroLabView(onCheckpointTriggered: cb),
|
||||
),
|
||||
Grade10LabEntry(
|
||||
identity: kPhysicsVectorAdditionLabIdentity,
|
||||
builder: (cb) => PhysicsVectorAdditionLabView(onCheckpointTriggered: cb),
|
||||
@@ -74,6 +82,11 @@ class Grade10LabsRegistry {
|
||||
identity: kPhysicsMotion1DLabIdentity,
|
||||
builder: (cb) => PhysicsMotion1DLabView(onCheckpointTriggered: cb),
|
||||
),
|
||||
Grade10LabEntry(
|
||||
identity: kPhysicsProjectileMotionLabIdentity,
|
||||
builder: (cb) =>
|
||||
PhysicsProjectileMotionLabView(onCheckpointTriggered: cb),
|
||||
),
|
||||
Grade10LabEntry(
|
||||
identity: kPhysicsCircularMotionLabIdentity,
|
||||
builder: (cb) => PhysicsCircularMotionLabView(onCheckpointTriggered: cb),
|
||||
@@ -117,7 +130,7 @@ class Grade10LabsRegistry {
|
||||
identity: kBiologyMicroscopeToolIdentity,
|
||||
builder: (cb) => BiologyMicroscopeLabView(onCheckpointTriggered: cb),
|
||||
),
|
||||
// ---- Earth (3: 2 tools + 1 bound) ----
|
||||
// ---- Earth (4: 2 tools + 2 bound) ----
|
||||
Grade10LabEntry(
|
||||
identity: kEarthMohsHardnessToolIdentity,
|
||||
builder: (cb) => EarthMohsHardnessLabView(onCheckpointTriggered: cb),
|
||||
@@ -130,6 +143,10 @@ class Grade10LabsRegistry {
|
||||
identity: kEarthStratigraphyToolIdentity,
|
||||
builder: (cb) => EarthStratigraphyLabView(onCheckpointTriggered: cb),
|
||||
),
|
||||
Grade10LabEntry(
|
||||
identity: kEarthAirMassesLabIdentity,
|
||||
builder: (cb) => EarthAirMassesLabView(onCheckpointTriggered: cb),
|
||||
),
|
||||
// ---- Math (3 bound) ----
|
||||
Grade10LabEntry(
|
||||
identity: kMathSystemsLabIdentity,
|
||||
@@ -152,7 +169,11 @@ class Grade10LabsRegistry {
|
||||
identity: kEnglishTenseToolIdentity,
|
||||
builder: (cb) => EnglishTenseTimelineLabView(onCheckpointTriggered: cb),
|
||||
),
|
||||
// ---- Arabic (13: 2 tools + 11 bound) ----
|
||||
// ---- Arabic (14: 2 tools + 12 bound) ----
|
||||
Grade10LabEntry(
|
||||
identity: kArabicUnit2InshaLabIdentity,
|
||||
builder: (cb) => ArabicUnit2InshaLabView(onCheckpointTriggered: cb),
|
||||
),
|
||||
Grade10LabEntry(
|
||||
identity: kArabicUnit2VocativeLabIdentity,
|
||||
builder: (cb) => ArabicUnit2VocativeLabView(onCheckpointTriggered: cb),
|
||||
@@ -207,13 +228,13 @@ class Grade10LabsRegistry {
|
||||
identity: kArabicProsodyToolIdentity,
|
||||
builder: (cb) => ArabicProsodyLabView(onCheckpointTriggered: cb),
|
||||
),
|
||||
// ---- Islamic (2 tools) ----
|
||||
// ---- Islamic (2 bound curriculum labs) ----
|
||||
Grade10LabEntry(
|
||||
identity: kIslamicTajweedToolIdentity,
|
||||
identity: kIslamicTajweedLabIdentity,
|
||||
builder: (cb) => IslamicTajweedLabView(onCheckpointTriggered: cb),
|
||||
),
|
||||
Grade10LabEntry(
|
||||
identity: kIslamicInheritanceToolIdentity,
|
||||
identity: kIslamicInheritanceLabIdentity,
|
||||
builder: (cb) => IslamicInheritanceLabView(onCheckpointTriggered: cb),
|
||||
),
|
||||
// ---- Finance (3: 1 tool + 2 bound) ----
|
||||
@@ -382,7 +403,7 @@ class Grade10LabsRegistry {
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates.first;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+645
@@ -0,0 +1,645 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import 'lab_identity.dart';
|
||||
import 'lab_scaffold.dart';
|
||||
|
||||
/// ============================================================================
|
||||
/// PHYSICS — GRADE 10 VIRTUAL LAB (Jordanian MoE curriculum)
|
||||
/// Unit 2, Lesson 2: حركة المقذوفات في بعدين (Projectile Motion)
|
||||
/// Curriculum Lesson ID: physics_10_semester_1_unit_02_lesson_02
|
||||
///
|
||||
/// Textbook mapping (Pages 57-66):
|
||||
/// - Horizontal motion: constant speed ax = 0, Vx = V0 cos(θ)
|
||||
/// - Vertical motion: free fall ay = -g, Vy = V0 sin(θ) - g t
|
||||
/// - Time to apex: th = (V0 sin θ) / g
|
||||
/// - Total flight time: T = 2 th = (2 V0 sin θ) / g
|
||||
/// - Maximum height: h = (V0 sin θ)^2 / (2g)
|
||||
/// - Range: R = (V0^2 sin(2θ)) / g (Max range at 45°; equal for complementary angles θ & 90-θ)
|
||||
/// ============================================================================
|
||||
|
||||
class PhysicsProjectileMotionLabView extends StatefulWidget {
|
||||
final LabCheckpointCallback? onCheckpointTriggered;
|
||||
const PhysicsProjectileMotionLabView({super.key, this.onCheckpointTriggered});
|
||||
|
||||
@override
|
||||
State<PhysicsProjectileMotionLabView> createState() =>
|
||||
_PhysicsProjectileMotionLabViewState();
|
||||
}
|
||||
|
||||
class _PhysicsProjectileMotionLabViewState
|
||||
extends State<PhysicsProjectileMotionLabView>
|
||||
with SingleTickerProviderStateMixin {
|
||||
double _v0 = 24.0; // Initial velocity m/s (10..40)
|
||||
double _angleDeg = 45.0; // Launch angle (15..80)
|
||||
final double _gravity = 9.8; // m/s^2
|
||||
|
||||
bool _showVelocityVectors = true;
|
||||
bool _showComplementaryTrajectory = false;
|
||||
bool _slowMotion = false;
|
||||
|
||||
// Animation / simulation state:
|
||||
bool _isPlaying = false;
|
||||
double _simTime = 0.0; // elapsed time in seconds
|
||||
late final AnimationController _animCtl;
|
||||
final List<Offset> _firedPoints = [];
|
||||
|
||||
// Target challenge:
|
||||
final double _targetDistance = 48.0; // meters
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animCtl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 10),
|
||||
)..addListener(_tickSimulation);
|
||||
}
|
||||
|
||||
void _tickSimulation() {
|
||||
if (!_isPlaying) return;
|
||||
final totalFlightTime = _calcFlightTime(_v0, _angleDeg);
|
||||
final dt = _slowMotion ? 0.012 : 0.028;
|
||||
|
||||
setState(() {
|
||||
_simTime += dt;
|
||||
final currentPos = _calcPositionAtTime(_simTime, _v0, _angleDeg);
|
||||
_firedPoints.add(currentPos);
|
||||
|
||||
if (_simTime >= totalFlightTime) {
|
||||
_simTime = totalFlightTime;
|
||||
_isPlaying = false;
|
||||
_animCtl.stop();
|
||||
saqelTick();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animCtl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// --- Physics Helpers ---
|
||||
double _calcFlightTime(double v0, double ang) {
|
||||
final rad = ang * math.pi / 180.0;
|
||||
return (2.0 * v0 * math.sin(rad)) / _gravity;
|
||||
}
|
||||
|
||||
double _calcMaxHeight(double v0, double ang) {
|
||||
final rad = ang * math.pi / 180.0;
|
||||
final vy0 = v0 * math.sin(rad);
|
||||
return (vy0 * vy0) / (2.0 * _gravity);
|
||||
}
|
||||
|
||||
double _calcRange(double v0, double ang) {
|
||||
final rad = ang * math.pi / 180.0;
|
||||
return (v0 * v0 * math.sin(2.0 * rad)) / _gravity;
|
||||
}
|
||||
|
||||
Offset _calcPositionAtTime(double t, double v0, double ang) {
|
||||
final rad = ang * math.pi / 180.0;
|
||||
final vx = v0 * math.cos(rad);
|
||||
final vy0 = v0 * math.sin(rad);
|
||||
final x = vx * t;
|
||||
final y = vy0 * t - 0.5 * _gravity * t * t;
|
||||
return Offset(x, math.max(0.0, y));
|
||||
}
|
||||
|
||||
void _launchProjectile() {
|
||||
saqelTick();
|
||||
setState(() {
|
||||
_isPlaying = true;
|
||||
_simTime = 0.0;
|
||||
_firedPoints.clear();
|
||||
});
|
||||
_animCtl.repeat();
|
||||
}
|
||||
|
||||
void _resetSimulation() {
|
||||
saqelTick();
|
||||
setState(() {
|
||||
_isPlaying = false;
|
||||
_simTime = 0.0;
|
||||
_firedPoints.clear();
|
||||
_animCtl.reset();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final flightTime = _calcFlightTime(_v0, _angleDeg);
|
||||
final maxHeight = _calcMaxHeight(_v0, _angleDeg);
|
||||
final totalRange = _calcRange(_v0, _angleDeg);
|
||||
|
||||
// Current instant values:
|
||||
final rad = _angleDeg * math.pi / 180.0;
|
||||
final curVx = _v0 * math.cos(rad);
|
||||
final curVy = _v0 * math.sin(rad) - _gravity * _simTime;
|
||||
final isAtApex = curVy.abs() < 1.0;
|
||||
final isTargetHit = (_calcRange(_v0, _angleDeg) - _targetDistance).abs() <= 2.2;
|
||||
|
||||
return SaqelLabScaffold(
|
||||
titleAr: 'حركة المقذوفات في بُعدين',
|
||||
subtitleAr: 'مسار منحني • مركبتان متعامدتان vx و vy • المدى الأقصى والارتفاع',
|
||||
identity: kPhysicsProjectileMotionLabIdentity,
|
||||
onCheckpointTriggered: widget.onCheckpointTriggered,
|
||||
checkpointQuestion:
|
||||
'عند وصول المقذوف إلى أقصى ارتفاع رأسي (Apex)، كم تكون قيمة المركبة الرأسية للسرعة vy؟',
|
||||
checkpointOptions: const [
|
||||
'تساوي صفراً لحظياً، بينما تبقى السرعة الأفقية vx ثابتة',
|
||||
'تكون في قيمتها العظمى القصوى متجهة لأعلى',
|
||||
'تساوي تسارع الجاذبية الأرضية g مضروباً في 2',
|
||||
'تنعكس فوراً دون المرور بنقطة السكون اللحظي',
|
||||
],
|
||||
checkpointCorrectIdx: 0,
|
||||
telemetry: [
|
||||
LabPill('المدى R = ${totalRange.toStringAsFixed(1)} m'),
|
||||
LabPill('أقصى ارتفاع h = ${maxHeight.toStringAsFixed(1)} m',
|
||||
color: const Color(0xFFFF9F0A)),
|
||||
LabPill('زمن التحليق T = ${flightTime.toStringAsFixed(2)} s',
|
||||
color: const Color(0xFF30D158)),
|
||||
if (isTargetHit)
|
||||
const LabPill('إصابة مباشرة للهدف! 🎯', color: Color(0xFF00F5D4)),
|
||||
],
|
||||
canvas: CustomPaint(
|
||||
painter: _ProjectileCanvasPainter(
|
||||
v0: _v0,
|
||||
angleDeg: _angleDeg,
|
||||
gravity: _gravity,
|
||||
simTime: _simTime,
|
||||
isPlaying: _isPlaying,
|
||||
showVelocityVectors: _showVelocityVectors,
|
||||
showComplementary: _showComplementaryTrajectory,
|
||||
targetDistance: _targetDistance,
|
||||
firedPoints: List.of(_firedPoints),
|
||||
),
|
||||
child: Container(),
|
||||
),
|
||||
controls: [
|
||||
// Launch and Reset row:
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: ElevatedButton.icon(
|
||||
icon: Icon(
|
||||
_isPlaying ? CupertinoIcons.pause_fill : CupertinoIcons.play_arrow_solid,
|
||||
size: 18,
|
||||
),
|
||||
label: Text(
|
||||
_isPlaying ? 'إيقاف مؤقت' : 'إطلاق القذيفة 🚀',
|
||||
style: const TextStyle(fontWeight: FontWeight.w800, fontSize: 13),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.saqelCyan,
|
||||
foregroundColor: Colors.black,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
onPressed: _launchProjectile,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(CupertinoIcons.arrow_counterclockwise, size: 16),
|
||||
label: const Text('إعادة', style: TextStyle(fontSize: 12)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: Colors.white70,
|
||||
side: const BorderSide(color: Colors.white24),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
onPressed: _resetSimulation,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Angle Slider with presets:
|
||||
LabSlider(
|
||||
label: 'زاوية الإطلاق θ',
|
||||
value: _angleDeg,
|
||||
min: 15.0,
|
||||
max: 80.0,
|
||||
display: '${_angleDeg.toInt()}°',
|
||||
accent: _angleDeg == 45.0 ? const Color(0xFF00F5D4) : const Color(0xFFFF9F0A),
|
||||
onChanged: (v) => setState(() {
|
||||
_angleDeg = v;
|
||||
if (!_isPlaying) _firedPoints.clear();
|
||||
}),
|
||||
),
|
||||
|
||||
// Quick angle presets:
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
_buildPresetChip('30°', 30.0),
|
||||
const SizedBox(width: 6),
|
||||
_buildPresetChip('45° (أقصى مدى)', 45.0, isSpecial: true),
|
||||
const SizedBox(width: 6),
|
||||
_buildPresetChip('60° (متممة لـ 30°)', 60.0),
|
||||
const SizedBox(width: 6),
|
||||
_buildPresetChip('53° (مثال 12 ص61)', 53.0),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// Initial Velocity Slider:
|
||||
LabSlider(
|
||||
label: 'السرعة الابتدائية v₀',
|
||||
value: _v0,
|
||||
min: 12.0,
|
||||
max: 36.0,
|
||||
display: '${_v0.toStringAsFixed(1)} m/s',
|
||||
onChanged: (v) => setState(() {
|
||||
_v0 = v;
|
||||
if (!_isPlaying) _firedPoints.clear();
|
||||
}),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
LabToggle(
|
||||
label: 'إظهار مركبات السرعة المتجهة (vx و vy)',
|
||||
hint: 'vx أفقية ثابتة • vy رأسية تتغير بالجاذبية',
|
||||
value: _showVelocityVectors,
|
||||
onChanged: (v) => setState(() => _showVelocityVectors = v),
|
||||
),
|
||||
LabToggle(
|
||||
label: 'مقارنة الزاوية المتممة (${(90 - _angleDeg).toInt()}°)',
|
||||
hint: 'الزاويتان المتتامتان لهما المدى الأفقي نفسه R',
|
||||
value: _showComplementaryTrajectory,
|
||||
onChanged: (v) => setState(() => _showComplementaryTrajectory = v),
|
||||
),
|
||||
LabToggle(
|
||||
label: 'تصوير بالحركة البطيئة (Slow Motion)',
|
||||
hint: 'لدراسة انعدام vy عند القمة (Apex)',
|
||||
value: _slowMotion,
|
||||
onChanged: (v) => setState(() => _slowMotion = v),
|
||||
),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
LabFormulaCard(
|
||||
title: isAtApex && _isPlaying
|
||||
? 'القمة اللحظية! vy = 0 m/s و vx = ${curVx.toStringAsFixed(1)} m/s'
|
||||
: 'معادلات حركة المقذوفات (منهاج الوزارة)',
|
||||
body:
|
||||
'vx = v₀·cosθ = ${curVx.toStringAsFixed(1)} m/s (ثابتة دوماً)\n'
|
||||
'vy = v₀·sinθ − g·t = ${curVy.toStringAsFixed(1)} m/s\n'
|
||||
'المدى الأفقي: R = (v₀²·sin 2θ) / g = ${totalRange.toStringAsFixed(1)} m\n'
|
||||
'أقصى ارتفاع: h = (v₀·sinθ)² / 2g = ${maxHeight.toStringAsFixed(1)} m',
|
||||
),
|
||||
],
|
||||
footerNote:
|
||||
'بإهمال مقاومة الهواء • نموذج المقذوفات الأردني المعتمد (الوحدة 2: الصفحات 57 - 66).',
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPresetChip(String label, double val, {bool isSpecial = false}) {
|
||||
final isSelected = (_angleDeg - val).abs() < 0.5;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() {
|
||||
saqelTick();
|
||||
_angleDeg = val;
|
||||
if (!_isPlaying) _firedPoints.clear();
|
||||
}),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? (isSpecial ? AppColors.saqelCyan : const Color(0xFFFF9F0A))
|
||||
: Colors.white.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isSelected ? Colors.transparent : Colors.white24,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.black : Colors.white70,
|
||||
fontSize: 11,
|
||||
fontWeight: isSelected ? FontWeight.w800 : FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ============================================================================
|
||||
/// CANVAS PAINTER: Projectile Flight, Vectors, and Target
|
||||
/// ============================================================================
|
||||
class _ProjectileCanvasPainter extends CustomPainter {
|
||||
final double v0;
|
||||
final double angleDeg;
|
||||
final double gravity;
|
||||
final double simTime;
|
||||
final bool isPlaying;
|
||||
final bool showVelocityVectors;
|
||||
final bool showComplementary;
|
||||
final double targetDistance;
|
||||
final List<Offset> firedPoints;
|
||||
|
||||
_ProjectileCanvasPainter({
|
||||
required this.v0,
|
||||
required this.angleDeg,
|
||||
required this.gravity,
|
||||
required this.simTime,
|
||||
required this.isPlaying,
|
||||
required this.showVelocityVectors,
|
||||
required this.showComplementary,
|
||||
required this.targetDistance,
|
||||
required this.firedPoints,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas c, Size s) {
|
||||
// 1. Dark space backdrop
|
||||
c.drawRect(
|
||||
Offset.zero & s,
|
||||
Paint()
|
||||
..shader = const LinearGradient(
|
||||
colors: [Color(0xFF07111F), Color(0xFF0B1728)],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
).createShader(Offset.zero & s),
|
||||
);
|
||||
|
||||
final groundY = s.height * 0.82;
|
||||
const originX = 42.0;
|
||||
|
||||
// Scale factors from meters to screen pixels:
|
||||
// Max horizontal range is around 130m, we scale to fit width:
|
||||
final scaleX = (s.width - 80.0) / 100.0;
|
||||
final scaleY = (groundY - 30.0) / 45.0;
|
||||
|
||||
// 2. Draw Distance Grid and Ground
|
||||
_drawGroundAndGrid(c, s, groundY, originX, scaleX);
|
||||
|
||||
// 3. Draw Target Marker
|
||||
final targetScreenX = originX + targetDistance * scaleX;
|
||||
_drawTarget(c, targetScreenX, groundY);
|
||||
|
||||
// 4. Draw Theoretical Trajectory (Parabola)
|
||||
_drawParabola(c, originX, groundY, scaleX, scaleY, v0, angleDeg,
|
||||
const Color(0xFF00F5D4).withValues(alpha: 0.55), isDashed: true);
|
||||
|
||||
// 5. If complementary angle enabled (90 - theta):
|
||||
if (showComplementary) {
|
||||
final compAngle = 90.0 - angleDeg;
|
||||
_drawParabola(c, originX, groundY, scaleX, scaleY, v0, compAngle,
|
||||
const Color(0xFFFF9F0A).withValues(alpha: 0.45), isDashed: true);
|
||||
}
|
||||
|
||||
// 6. Draw Cannon / Launcher at origin
|
||||
_drawCannon(c, originX, groundY, angleDeg);
|
||||
|
||||
// 7. Draw Trajectory Trail of Fired Points
|
||||
if (firedPoints.isNotEmpty) {
|
||||
final trailPath = Path();
|
||||
for (int i = 0; i < firedPoints.length; i++) {
|
||||
final sx = originX + firedPoints[i].dx * scaleX;
|
||||
final sy = groundY - firedPoints[i].dy * scaleY;
|
||||
if (i == 0) {
|
||||
trailPath.moveTo(sx, sy);
|
||||
} else {
|
||||
trailPath.lineTo(sx, sy);
|
||||
}
|
||||
}
|
||||
c.drawPath(
|
||||
trailPath,
|
||||
Paint()
|
||||
..color = AppColors.saqelCyan.withValues(alpha: 0.85)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2.4,
|
||||
);
|
||||
}
|
||||
|
||||
// 8. Draw Moving Projectile Ball and Velocity Vectors
|
||||
final rad = angleDeg * math.pi / 180.0;
|
||||
final curX = v0 * math.cos(rad) * simTime;
|
||||
final curY = v0 * math.sin(rad) * simTime - 0.5 * gravity * simTime * simTime;
|
||||
|
||||
final ballX = originX + curX * scaleX;
|
||||
final ballY = groundY - math.max(0.0, curY) * scaleY;
|
||||
|
||||
// Glowing projectile ball
|
||||
c.drawCircle(
|
||||
Offset(ballX, ballY),
|
||||
8.0,
|
||||
Paint()
|
||||
..color = const Color(0xFF00F5D4)
|
||||
..maskFilter = const MaskFilter.blur(BlurStyle.solid, 4),
|
||||
);
|
||||
c.drawCircle(Offset(ballX, ballY), 5.5, Paint()..color = Colors.white);
|
||||
|
||||
// Velocity Vectors on the ball:
|
||||
if (showVelocityVectors && curY >= 0.0) {
|
||||
final vx = v0 * math.cos(rad);
|
||||
final vy = v0 * math.sin(rad) - gravity * simTime;
|
||||
|
||||
// Horizontal Vx vector (Cyan, constant)
|
||||
_drawVec(c, Offset(ballX, ballY), Offset(ballX + vx * 1.5, ballY),
|
||||
const Color(0xFF00F5D4), 'vx');
|
||||
|
||||
// Vertical Vy vector (Amber, dynamic)
|
||||
if (vy.abs() > 0.8) {
|
||||
_drawVec(c, Offset(ballX, ballY), Offset(ballX, ballY - vy * 1.5),
|
||||
const Color(0xFFFF9F0A), 'vy');
|
||||
}
|
||||
|
||||
// Gravitational acceleration vector g (pointing down)
|
||||
_drawVec(c, Offset(ballX, ballY), Offset(ballX, ballY + 28),
|
||||
const Color(0xFFFF375F), 'g');
|
||||
}
|
||||
}
|
||||
|
||||
void _drawGroundAndGrid(
|
||||
Canvas c, Size s, double groundY, double originX, double scaleX) {
|
||||
// Ground line
|
||||
final groundPaint = Paint()
|
||||
..color = const Color(0xFF1E293B)
|
||||
..strokeWidth = 4;
|
||||
c.drawLine(Offset(0, groundY), Offset(s.width, groundY), groundPaint);
|
||||
|
||||
// Grass / surface accents
|
||||
final grass = Paint()
|
||||
..color = const Color(0xFF10B981).withValues(alpha: 0.6)
|
||||
..strokeWidth = 2;
|
||||
c.drawLine(Offset(0, groundY), Offset(s.width, groundY), grass);
|
||||
|
||||
// Ticks every 10 meters
|
||||
for (int m = 10; m <= 90; m += 10) {
|
||||
final tx = originX + m * scaleX;
|
||||
if (tx > s.width - 10) break;
|
||||
c.drawLine(
|
||||
Offset(tx, groundY),
|
||||
Offset(tx, groundY + 6),
|
||||
Paint()..color = Colors.white30,
|
||||
);
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(
|
||||
text: '$m m',
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 9),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
tp.paint(c, Offset(tx - tp.width / 2, groundY + 8));
|
||||
}
|
||||
}
|
||||
|
||||
void _drawTarget(Canvas c, double tx, double groundY) {
|
||||
// Target base flag / bullseye
|
||||
c.drawCircle(
|
||||
Offset(tx, groundY),
|
||||
9,
|
||||
Paint()
|
||||
..color = const Color(0xFFFF375F).withValues(alpha: 0.3)
|
||||
..style = PaintingStyle.fill,
|
||||
);
|
||||
c.drawCircle(
|
||||
Offset(tx, groundY),
|
||||
9,
|
||||
Paint()
|
||||
..color = const Color(0xFFFF375F)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2,
|
||||
);
|
||||
c.drawCircle(Offset(tx, groundY), 3.5, Paint()..color = Colors.white);
|
||||
|
||||
// Flag pole
|
||||
c.drawLine(
|
||||
Offset(tx, groundY),
|
||||
Offset(tx, groundY - 24),
|
||||
Paint()
|
||||
..color = Colors.white70
|
||||
..strokeWidth = 1.5,
|
||||
);
|
||||
final flagPath = Path()
|
||||
..moveTo(tx, groundY - 24)
|
||||
..lineTo(tx + 14, groundY - 18)
|
||||
..lineTo(tx, groundY - 12)
|
||||
..close();
|
||||
c.drawPath(flagPath, Paint()..color = const Color(0xFFFF375F));
|
||||
}
|
||||
|
||||
void _drawParabola(
|
||||
Canvas c,
|
||||
double originX,
|
||||
double groundY,
|
||||
double scaleX,
|
||||
double scaleY,
|
||||
double v0,
|
||||
double ang,
|
||||
Color col, {
|
||||
bool isDashed = false,
|
||||
}) {
|
||||
final rad = ang * math.pi / 180.0;
|
||||
final totalFlightTime = (2.0 * v0 * math.sin(rad)) / gravity;
|
||||
final path = Path();
|
||||
|
||||
const steps = 60;
|
||||
for (int i = 0; i <= steps; i++) {
|
||||
final t = (totalFlightTime * i) / steps;
|
||||
final x = v0 * math.cos(rad) * t;
|
||||
final y = v0 * math.sin(rad) * t - 0.5 * gravity * t * t;
|
||||
|
||||
final sx = originX + x * scaleX;
|
||||
final sy = groundY - math.max(0.0, y) * scaleY;
|
||||
|
||||
if (i == 0) {
|
||||
path.moveTo(sx, sy);
|
||||
} else {
|
||||
path.lineTo(sx, sy);
|
||||
}
|
||||
}
|
||||
|
||||
c.drawPath(
|
||||
path,
|
||||
Paint()
|
||||
..color = col
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.6,
|
||||
);
|
||||
}
|
||||
|
||||
void _drawCannon(Canvas c, double ox, double gy, double angDeg) {
|
||||
c.save();
|
||||
c.translate(ox, gy);
|
||||
|
||||
// Cannon base wheel
|
||||
c.drawCircle(const Offset(0, -6), 11, Paint()..color = const Color(0xFF334155));
|
||||
c.drawCircle(
|
||||
const Offset(0, -6),
|
||||
11,
|
||||
Paint()
|
||||
..color = Colors.white30
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.5,
|
||||
);
|
||||
|
||||
// Barrel rotation
|
||||
c.rotate(-angDeg * math.pi / 180.0);
|
||||
final barrelRect =
|
||||
RRect.fromRectAndRadius(const Rect.fromLTWH(0, -5, 26, 10), const Radius.circular(3));
|
||||
c.drawRRect(barrelRect, Paint()..color = AppColors.saqelCyan);
|
||||
c.drawRRect(
|
||||
barrelRect,
|
||||
Paint()
|
||||
..color = Colors.white
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.2,
|
||||
);
|
||||
|
||||
c.restore();
|
||||
}
|
||||
|
||||
void _drawVec(Canvas c, Offset a, Offset b, Color col, String label) {
|
||||
final p = Paint()
|
||||
..color = col
|
||||
..strokeWidth = 2.4
|
||||
..strokeCap = StrokeCap.round;
|
||||
c.drawLine(a, b, p);
|
||||
|
||||
final ang = math.atan2(b.dy - a.dy, b.dx - a.dx);
|
||||
const hs = 7.0;
|
||||
final p1 = b - Offset(math.cos(ang - 0.45) * hs, math.sin(ang - 0.45) * hs);
|
||||
final p2 = b - Offset(math.cos(ang + 0.45) * hs, math.sin(ang + 0.45) * hs);
|
||||
c.drawPath(
|
||||
Path()
|
||||
..moveTo(b.dx, b.dy)
|
||||
..lineTo(p1.dx, p1.dy)
|
||||
..lineTo(p2.dx, p2.dy)
|
||||
..close(),
|
||||
Paint()..color = col,
|
||||
);
|
||||
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(
|
||||
text: label,
|
||||
style: TextStyle(color: col, fontSize: 10, fontWeight: FontWeight.w900),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
tp.paint(c, b + const Offset(3, -12));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _ProjectileCanvasPainter o) =>
|
||||
o.v0 != v0 ||
|
||||
o.angleDeg != angleDeg ||
|
||||
o.simTime != simTime ||
|
||||
o.isPlaying != isPlaying ||
|
||||
o.showVelocityVectors != showVelocityVectors ||
|
||||
o.showComplementary != showComplementary ||
|
||||
o.firedPoints.length != firedPoints.length;
|
||||
}
|
||||
+739
@@ -0,0 +1,739 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import 'lab_identity.dart';
|
||||
import 'lab_scaffold.dart';
|
||||
|
||||
/// ============================================================================
|
||||
/// PHYSICS — GRADE 10 VIRTUAL LAB (Jordanian MoE curriculum)
|
||||
/// Unit 1, Lesson 1: الكميات القياسية والكميات المتجهة وتمثيلها بيانياً
|
||||
/// Curriculum Lesson ID: physics_10_semester_1_unit_01_lesson_01
|
||||
///
|
||||
/// Direct textbook mapping (Pages 5-19):
|
||||
/// 1. ظاهرة هبوط الطائرات في الرياح المتقاطعة (Crosswind Landing - صفحة 5)
|
||||
/// - توجيه الطائرة ضد الرياح لتكون السرعة المحصلة منطبقة على محور المدرج.
|
||||
/// 2. تمثيل المتجهات وخصائصها ومضاعفاتها وسالب المتجه (صفحات 8-12)
|
||||
/// - سحب المتجه، تغيير مقياس الرسم، ضرب المتجه بكمية قياسية n، وسالب المتجه (-A).
|
||||
/// 3. ضرب المتجهات: الضرب القياسي (A·B = AB cos θ) والضرب المتجهي (|A×B| = AB sin θ)
|
||||
/// - عرض مساحة متوازي الأضلاع وقاعدة اليد اليمنى وحالة التساوي عند θ = 45°.
|
||||
/// ============================================================================
|
||||
|
||||
class PhysicsVectorsIntroLabView extends StatefulWidget {
|
||||
final LabCheckpointCallback? onCheckpointTriggered;
|
||||
const PhysicsVectorsIntroLabView({super.key, this.onCheckpointTriggered});
|
||||
|
||||
@override
|
||||
State<PhysicsVectorsIntroLabView> createState() =>
|
||||
_PhysicsVectorsIntroLabViewState();
|
||||
}
|
||||
|
||||
class _PhysicsVectorsIntroLabViewState extends State<PhysicsVectorsIntroLabView>
|
||||
with SingleTickerProviderStateMixin {
|
||||
// 0: هبوط الرياح المتقاطعة, 1: تمثيل وسالب المتجه, 2: الضرب النقطي والتقاطعي
|
||||
int _activeTab = 0;
|
||||
|
||||
// --- TAB 0: Crosswind Landing ---
|
||||
double _planeHeading = -18.0; // Heading deviation in degrees (-45..+45)
|
||||
double _windSpeed = 22.0; // Crosswind speed knots (-40..+40)
|
||||
final double _planeAirspeed = 70.0; // knots airspeed
|
||||
bool _isLanding = false;
|
||||
double _landingProgress = 0.0;
|
||||
late final AnimationController _landingCtl;
|
||||
|
||||
// --- TAB 1: Vector Representation & Properties ---
|
||||
double _vecMag = 80.0; // magnitude (20..140)
|
||||
double _vecAngle = 40.0; // angle in degrees (0..360)
|
||||
double _scalarMultiplier = 1.5; // n in [-2.0..2.0]
|
||||
int _selectedQuantityIdx = 0; // 0 = Force (vector), 1 = Mass (scalar)
|
||||
|
||||
// --- TAB 2: Dot & Cross Product ---
|
||||
double _dotMagA = 90.0;
|
||||
double _dotMagB = 75.0;
|
||||
double _dotAngle = 45.0; // Angle between A and B (0..180)
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_landingCtl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 2200),
|
||||
)..addListener(() {
|
||||
setState(() {
|
||||
_landingProgress = _landingCtl.value;
|
||||
if (_landingCtl.isCompleted) {
|
||||
_isLanding = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_landingCtl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _triggerLanding() {
|
||||
saqelTick();
|
||||
setState(() {
|
||||
_isLanding = true;
|
||||
_landingProgress = 0.0;
|
||||
});
|
||||
_landingCtl.forward(from: 0.0);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Crosswind physics calculations:
|
||||
// Runway is along the Y axis (0 deg is straight down the runway).
|
||||
// Plane velocity: Vx = V_air * sin(heading), Vy = V_air * cos(heading)
|
||||
// Wind velocity: Wx = windSpeed (pure crosswind), Wy = 0
|
||||
final headingRad = _planeHeading * math.pi / 180.0;
|
||||
final vxPlane = _planeAirspeed * math.sin(headingRad);
|
||||
final vyPlane = _planeAirspeed * math.cos(headingRad);
|
||||
final vxGround = vxPlane + _windSpeed;
|
||||
final vyGround = vyPlane;
|
||||
final vGroundMag = math.sqrt(vxGround * vxGround + vyGround * vyGround);
|
||||
final groundTrackDeg = math.atan2(vxGround, vyGround) * 180.0 / math.pi;
|
||||
final bool isAligned = groundTrackDeg.abs() <= 3.0;
|
||||
|
||||
// Dot and Cross physics calculations:
|
||||
final radTheta = _dotAngle * math.pi / 180.0;
|
||||
final dotProduct = _dotMagA * _dotMagB * math.cos(radTheta) / 100.0;
|
||||
final crossProduct = _dotMagA * _dotMagB * math.sin(radTheta) / 100.0;
|
||||
final bool isEqualed45 = (_dotAngle - 45.0).abs() <= 1.5;
|
||||
|
||||
return SaqelLabScaffold(
|
||||
titleAr: 'الكميات القياسية والمتجهة وتمثيلها',
|
||||
subtitleAr: 'الرياح المتقاطعة • تمثيل وسالب المتجهات • الضرب القياسي والمتجهي',
|
||||
identity: kPhysicsVectorsIntroLabIdentity,
|
||||
onCheckpointTriggered: widget.onCheckpointTriggered,
|
||||
checkpointQuestion:
|
||||
'في تجربة هبوط الطائرات مع رياح متقاطعة (Crosswind)، لتفادي خروج الطائرة عن المدرج يجب أن تكون …',
|
||||
checkpointOptions: const [
|
||||
'السرعة المحصلة لسرعتي الطائرة والرياح منطبقة على محور المدرج',
|
||||
'مقدمة الطائرة موازية تماماً للمدرج بغض النظر عن الرياح',
|
||||
'سرعة الرياح مساوية لسرعة الطائرة في المقدار ومعاكسة لها',
|
||||
'السرعة المحصلة متعامدة على المدرج لتثبيت العجلات',
|
||||
],
|
||||
checkpointCorrectIdx: 0,
|
||||
telemetry: _buildTelemetry(isAligned, vGroundMag, groundTrackDeg, dotProduct, crossProduct),
|
||||
canvas: _buildCanvas(isAligned, groundTrackDeg, dotProduct, crossProduct),
|
||||
controls: _buildControls(isAligned, isEqualed45, groundTrackDeg),
|
||||
footerNote:
|
||||
'مبني وموثق طبقاً للمنهاج الأردني المعتمد (الوحدة 1: الصفحات 5 - 19) • لا يعتمد بيانات وهمية.',
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildTelemetry(
|
||||
bool isAligned,
|
||||
double vGroundMag,
|
||||
double groundTrackDeg,
|
||||
double dotProduct,
|
||||
double crossProduct,
|
||||
) {
|
||||
if (_activeTab == 0) {
|
||||
return [
|
||||
LabPill('السرعة الأرضية |Vg| = ${vGroundMag.toStringAsFixed(1)} knot'),
|
||||
LabPill(
|
||||
'الانحراف = ${groundTrackDeg.toStringAsFixed(1)}°',
|
||||
color: isAligned ? const Color(0xFF30D158) : const Color(0xFFFF453A),
|
||||
),
|
||||
LabPill(
|
||||
isAligned ? 'مسار منطبق ومثالي ✅' : 'مسار هبوط منحرف ⚠️',
|
||||
color: isAligned ? const Color(0xFF30D158) : const Color(0xFFFF9F0A),
|
||||
),
|
||||
];
|
||||
} else if (_activeTab == 1) {
|
||||
final resMag = (_vecMag * _scalarMultiplier.abs()).toStringAsFixed(1);
|
||||
return [
|
||||
LabPill('المقدار الأساسي |A| = ${_vecMag.toInt()} N'),
|
||||
LabPill('الزاوية θ = ${_vecAngle.toInt()}°', color: const Color(0xFFFF9F0A)),
|
||||
LabPill('المتجه الناتج |n·A| = $resMag N', color: const Color(0xFF00F5D4)),
|
||||
if (_scalarMultiplier < 0)
|
||||
const LabPill('سالب المتجه (−180°)', color: Color(0xFFFF375F)),
|
||||
];
|
||||
} else {
|
||||
return [
|
||||
LabPill('الضرب القياسي A·B = ${dotProduct.toStringAsFixed(1)} J'),
|
||||
LabPill(
|
||||
'الضرب المتجهي |A×B| = ${crossProduct.toStringAsFixed(1)} N·m',
|
||||
color: const Color(0xFFFF9F0A),
|
||||
),
|
||||
LabPill(
|
||||
_dotAngle <= 90 ? 'اتجاه المتجه: خارج الصفحة ⊙' : 'اتجاه المتجه: داخل الصفحة ⊗',
|
||||
color: const Color(0xFF30D158),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildCanvas(
|
||||
bool isAligned,
|
||||
double groundTrackDeg,
|
||||
double dotProduct,
|
||||
double crossProduct,
|
||||
) {
|
||||
return GestureDetector(
|
||||
onPanUpdate: _activeTab == 1 ? _handleVectorDrag : null,
|
||||
child: CustomPaint(
|
||||
painter: _VectorsIntroCanvasPainter(
|
||||
activeTab: _activeTab,
|
||||
planeHeading: _planeHeading,
|
||||
windSpeed: _windSpeed,
|
||||
planeAirspeed: _planeAirspeed,
|
||||
isLanding: _isLanding,
|
||||
landingProgress: _landingProgress,
|
||||
vecMag: _vecMag,
|
||||
vecAngle: _vecAngle,
|
||||
scalarMultiplier: _scalarMultiplier,
|
||||
dotMagA: _dotMagA,
|
||||
dotMagB: _dotMagB,
|
||||
dotAngle: _dotAngle,
|
||||
),
|
||||
child: Container(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleVectorDrag(DragUpdateDetails d) {
|
||||
final box = context.findRenderObject() as RenderBox?;
|
||||
if (box == null) return;
|
||||
final local = box.globalToLocal(d.globalPosition);
|
||||
final cx = box.size.width * 0.45;
|
||||
final cy = box.size.height * 0.52;
|
||||
final dx = local.dx - cx;
|
||||
final dy = -(local.dy - cy);
|
||||
var ang = math.atan2(dy, dx) * 180.0 / math.pi;
|
||||
if (ang < 0) ang += 360.0;
|
||||
final dist = math.sqrt(dx * dx + dy * dy);
|
||||
setState(() {
|
||||
_vecAngle = ang.clamp(0.0, 360.0);
|
||||
_vecMag = dist.clamp(25.0, 130.0);
|
||||
});
|
||||
}
|
||||
|
||||
List<Widget> _buildControls(bool isAligned, bool isEqualed45, double groundTrackDeg) {
|
||||
return [
|
||||
LabSegments<int>(
|
||||
labels: const ['هبوط الرياح المتقاطعة', 'تمثيل وسالب المتجه', 'الضرب النقطي والتقاطعي'],
|
||||
values: const [0, 1, 2],
|
||||
current: _activeTab,
|
||||
onSelected: (tab) => setState(() {
|
||||
saqelTick();
|
||||
_activeTab = tab;
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
if (_activeTab == 0) ...[
|
||||
// TAB 0 CONTROLS
|
||||
LabSlider(
|
||||
label: 'توجيه مقدمة الطائرة (Heading)',
|
||||
value: _planeHeading,
|
||||
min: -40.0,
|
||||
max: 40.0,
|
||||
display: '${_planeHeading.toStringAsFixed(1)}°',
|
||||
accent: const Color(0xFF00F5D4),
|
||||
onChanged: (v) => setState(() => _planeHeading = v),
|
||||
),
|
||||
LabSlider(
|
||||
label: 'سرعة الرياح الجانبية (Crosswind)',
|
||||
value: _windSpeed,
|
||||
min: -35.0,
|
||||
max: 35.0,
|
||||
display: '${_windSpeed.toStringAsFixed(1)} knot',
|
||||
accent: const Color(0xFFFF9F0A),
|
||||
onChanged: (v) => setState(() => _windSpeed = v),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
icon: Icon(
|
||||
_isLanding ? CupertinoIcons.airplane : CupertinoIcons.arrow_down_circle_fill,
|
||||
size: 18,
|
||||
),
|
||||
label: Text(
|
||||
_isLanding ? 'جاري الهبوط التجريبي...' : 'تنفيذ هبوط تجريبي على المدرج 🛬',
|
||||
style: const TextStyle(fontWeight: FontWeight.w800, fontSize: 13),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isAligned ? AppColors.saqelCyan : const Color(0xFFFF9F0A),
|
||||
foregroundColor: Colors.black,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 11),
|
||||
),
|
||||
onPressed: _isLanding ? null : _triggerLanding,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
LabFormulaCard(
|
||||
title: 'الفيزياء المنهجية (صفحة 5 من الكتاب)',
|
||||
body:
|
||||
'السرعة المحصلة = سرعة الطائرة بالنسبة للهواء + سرعة الرياح\n'
|
||||
'V_ground = V_plane + V_wind\n'
|
||||
'الانحراف الحالي: ${groundTrackDeg.toStringAsFixed(1)}°\n'
|
||||
'${isAligned ? "✅ زاوية التوجيه عادلت سرعة الرياح بدقة واستقام المسار." : "⚠️ اضبط توجيه مقدمة الطائرة لعكس اتجاه دفع الرياح."}',
|
||||
),
|
||||
] else if (_activeTab == 1) ...[
|
||||
// TAB 1 CONTROLS
|
||||
LabSegments<int>(
|
||||
labels: const ['قوة F (كمية متجهة)', 'كتلة m (كمية قياسية)'],
|
||||
values: const [0, 1],
|
||||
current: _selectedQuantityIdx,
|
||||
onSelected: (i) => setState(() => _selectedQuantityIdx = i),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
LabSlider(
|
||||
label: 'المقدار |A|',
|
||||
value: _vecMag,
|
||||
min: 25.0,
|
||||
max: 130.0,
|
||||
display: '${_vecMag.toInt()} N',
|
||||
onChanged: (v) => setState(() => _vecMag = v),
|
||||
),
|
||||
LabSlider(
|
||||
label: 'الاتجاه θA من محور السينات الموجب',
|
||||
value: _vecAngle,
|
||||
min: 0.0,
|
||||
max: 360.0,
|
||||
display: '${_vecAngle.toInt()}°',
|
||||
accent: const Color(0xFFFF9F0A),
|
||||
onChanged: (v) => setState(() => _vecAngle = v),
|
||||
),
|
||||
LabSlider(
|
||||
label: 'معامل الضرب القياسي n (مضاعفة / سالب المتجه)',
|
||||
value: _scalarMultiplier,
|
||||
min: -2.0,
|
||||
max: 2.0,
|
||||
display: 'n = ${_scalarMultiplier.toStringAsFixed(2)}',
|
||||
accent: _scalarMultiplier < 0 ? const Color(0xFFFF375F) : const Color(0xFF30D158),
|
||||
onChanged: (v) => setState(() => _scalarMultiplier = v),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const LabFormulaCard(
|
||||
title: 'خصائص المتجهات المنهجية (صفحة 11)',
|
||||
body:
|
||||
'سالب المتجه (-A): نفس المقدار ويعاكسه تماماً في الاتجاه (180°).\n'
|
||||
'ضرب المتجه في عدد قياسي n: يصبح المقدار |n|·A، ويبقى بالاتجاه نفسه إذا n>0 وينعكس إذا n<0.',
|
||||
),
|
||||
] else ...[
|
||||
// TAB 2 CONTROLS
|
||||
LabSlider(
|
||||
label: 'الزاوية المحصورة بين المتجهين θ',
|
||||
value: _dotAngle,
|
||||
min: 0.0,
|
||||
max: 180.0,
|
||||
display: '${_dotAngle.toInt()}°',
|
||||
accent: isEqualed45 ? const Color(0xFF00F5D4) : const Color(0xFFFF9F0A),
|
||||
onChanged: (v) => setState(() => _dotAngle = v),
|
||||
),
|
||||
LabSlider(
|
||||
label: 'مقدار المتجه A',
|
||||
value: _dotMagA,
|
||||
min: 30.0,
|
||||
max: 120.0,
|
||||
display: '${_dotMagA.toInt()} N',
|
||||
onChanged: (v) => setState(() => _dotMagA = v),
|
||||
),
|
||||
LabSlider(
|
||||
label: 'مقدار المتجه B',
|
||||
value: _dotMagB,
|
||||
min: 30.0,
|
||||
max: 120.0,
|
||||
display: '${_dotMagB.toInt()} m',
|
||||
accent: const Color(0xFF60A5FA),
|
||||
onChanged: (v) => setState(() => _dotMagB = v),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
LabFormulaCard(
|
||||
title: isEqualed45
|
||||
? 'ملاحظة ذهبية: يتساوى الضرب القياسي والمتجهي عند θ = 45°!'
|
||||
: 'قوانين الضرب (صفحات 13 - 15 من الكتاب)',
|
||||
body:
|
||||
'الضرب القياسي (الشغل W): A·B = A·B·cosθ\n'
|
||||
'الضرب المتجهي (العزم τ): |A×B| = A·B·sinθ\n'
|
||||
'عند θ = 90°: الضرب النقطي ينعدم، والمتجهي يكون في قيمته العظمى.\n'
|
||||
'عند θ = 45°: tan(45°) = 1 ⟹ A·B = |A×B|.',
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// ============================================================================
|
||||
/// CANVAS PAINTER: 3 Physics Visualizations
|
||||
/// ============================================================================
|
||||
class _VectorsIntroCanvasPainter extends CustomPainter {
|
||||
final int activeTab;
|
||||
final double planeHeading;
|
||||
final double windSpeed;
|
||||
final double planeAirspeed;
|
||||
final bool isLanding;
|
||||
final double landingProgress;
|
||||
final double vecMag;
|
||||
final double vecAngle;
|
||||
final double scalarMultiplier;
|
||||
final double dotMagA;
|
||||
final double dotMagB;
|
||||
final double dotAngle;
|
||||
|
||||
_VectorsIntroCanvasPainter({
|
||||
required this.activeTab,
|
||||
required this.planeHeading,
|
||||
required this.windSpeed,
|
||||
required this.planeAirspeed,
|
||||
required this.isLanding,
|
||||
required this.landingProgress,
|
||||
required this.vecMag,
|
||||
required this.vecAngle,
|
||||
required this.scalarMultiplier,
|
||||
required this.dotMagA,
|
||||
required this.dotMagB,
|
||||
required this.dotAngle,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas c, Size s) {
|
||||
// Dark void background
|
||||
c.drawRect(
|
||||
Offset.zero & s,
|
||||
Paint()
|
||||
..shader = const LinearGradient(
|
||||
colors: [Color(0xFF07111F), Color(0xFF0B1728)],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
).createShader(Offset.zero & s),
|
||||
);
|
||||
|
||||
if (activeTab == 0) {
|
||||
_paintCrosswindRunway(c, s);
|
||||
} else if (activeTab == 1) {
|
||||
_paintVectorProperties(c, s);
|
||||
} else {
|
||||
_paintDotAndCrossProduct(c, s);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TAB 0: Crosswind Runway
|
||||
// ---------------------------------------------------------------------------
|
||||
void _paintCrosswindRunway(Canvas c, Size s) {
|
||||
final cx = s.width * 0.48;
|
||||
const rWidth = 84.0;
|
||||
|
||||
// Runway surface
|
||||
final runwayRect = Rect.fromLTWH(cx - rWidth / 2, 12, rWidth, s.height - 24);
|
||||
c.drawRRect(
|
||||
RRect.fromRectAndRadius(runwayRect, const Radius.circular(8)),
|
||||
Paint()..color = const Color(0xFF131D2D),
|
||||
);
|
||||
c.drawRRect(
|
||||
RRect.fromRectAndRadius(runwayRect, const Radius.circular(8)),
|
||||
Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.18)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.6,
|
||||
);
|
||||
|
||||
// Centerline dashed markings
|
||||
final dashPaint = Paint()
|
||||
..color = Colors.white70
|
||||
..strokeWidth = 2.4;
|
||||
for (double y = runwayRect.top + 20; y < runwayRect.bottom - 20; y += 28) {
|
||||
c.drawLine(Offset(cx, y), Offset(cx, y + 14), dashPaint);
|
||||
}
|
||||
|
||||
// Runway threshold "08"
|
||||
final tpRunway = TextPainter(
|
||||
text: const TextSpan(
|
||||
text: '08',
|
||||
style: TextStyle(
|
||||
color: Colors.white60,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 2,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
tpRunway.paint(c, Offset(cx - tpRunway.width / 2, runwayRect.bottom - 36));
|
||||
|
||||
// Calculate aircraft position:
|
||||
final headingRad = planeHeading * math.pi / 180.0;
|
||||
final vxPlane = planeAirspeed * math.sin(headingRad);
|
||||
final vyPlane = planeAirspeed * math.cos(headingRad);
|
||||
final vxGround = vxPlane + windSpeed;
|
||||
|
||||
// Normalised position on canvas:
|
||||
final startY = runwayRect.top + 38;
|
||||
final endY = runwayRect.bottom - 50;
|
||||
final curY = isLanding ? startY + (endY - startY) * landingProgress : startY + 50.0;
|
||||
// Ground drift
|
||||
final driftFactor = (vxGround / planeAirspeed) * 90.0;
|
||||
final curX = isLanding ? cx + driftFactor * landingProgress : cx;
|
||||
final planePos = Offset(curX, curY);
|
||||
|
||||
// Draw Vector diagram originating from the plane:
|
||||
final vAirEnd = planePos + Offset(vxPlane * 1.1, vyPlane * 0.9);
|
||||
final vWindEnd = vAirEnd + Offset(windSpeed * 1.6, 0);
|
||||
|
||||
// 1. Plane Airspeed Vector (Cyan)
|
||||
_drawArrow(c, planePos, vAirEnd, AppColors.saqelCyan, 'سرعة الطائرة V_air');
|
||||
|
||||
// 2. Crosswind Vector (Amber)
|
||||
_drawArrow(c, vAirEnd, vWindEnd, const Color(0xFFFF9F0A), 'رياح جانبية V_wind');
|
||||
|
||||
// 3. Ground Resultant Vector (Green or Red)
|
||||
final isAligned = (vxGround / planeAirspeed).abs() < 0.08;
|
||||
final resColor = isAligned ? const Color(0xFF30D158) : const Color(0xFFFF453A);
|
||||
_drawArrow(c, planePos, vWindEnd, resColor, 'السرعة المحصلة V_ground', width: 3.4);
|
||||
|
||||
// Draw Airplane at planePos rotated by planeHeading
|
||||
c.save();
|
||||
c.translate(planePos.dx, planePos.dy);
|
||||
c.rotate(headingRad);
|
||||
_drawAirplaneIcon(c, isAligned);
|
||||
c.restore();
|
||||
}
|
||||
|
||||
void _drawAirplaneIcon(Canvas c, bool isAligned) {
|
||||
final bodyPaint = Paint()..color = Colors.white;
|
||||
final wingPaint = Paint()..color = isAligned ? AppColors.saqelCyan : const Color(0xFFFF9F0A);
|
||||
|
||||
// Fuselage
|
||||
c.drawRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
const Rect.fromLTWH(-4, -18, 8, 36),
|
||||
const Radius.circular(4),
|
||||
),
|
||||
bodyPaint,
|
||||
);
|
||||
// Wings
|
||||
final wingPath = Path()
|
||||
..moveTo(0, -3)
|
||||
..lineTo(-22, 10)
|
||||
..lineTo(-22, 6)
|
||||
..lineTo(0, -9)
|
||||
..lineTo(22, 6)
|
||||
..lineTo(22, 10)
|
||||
..close();
|
||||
c.drawPath(wingPath, wingPaint);
|
||||
// Tail
|
||||
final tailPath = Path()
|
||||
..moveTo(0, 10)
|
||||
..lineTo(-9, 17)
|
||||
..lineTo(9, 17)
|
||||
..close();
|
||||
c.drawPath(tailPath, wingPaint);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TAB 1: Vector Properties & Multiplication
|
||||
// ---------------------------------------------------------------------------
|
||||
void _paintVectorProperties(Canvas c, Size s) {
|
||||
final cx = s.width * 0.44;
|
||||
final cy = s.height * 0.52;
|
||||
final origin = Offset(cx, cy);
|
||||
|
||||
_drawGridAndAxes(c, s, cx, cy);
|
||||
|
||||
final aRad = vecAngle * math.pi / 180.0;
|
||||
final ax = vecMag * math.cos(aRad);
|
||||
final ay = -vecMag * math.sin(aRad);
|
||||
final aEnd = origin + Offset(ax, ay);
|
||||
|
||||
// Draw original Vector A
|
||||
_drawArrow(c, origin, aEnd, AppColors.saqelCyan, 'A (${vecMag.toInt()} N)', width: 3.0);
|
||||
|
||||
// Draw Resultant Vector B = n * A
|
||||
final bx = ax * scalarMultiplier;
|
||||
final by = ay * scalarMultiplier;
|
||||
final bEnd = origin + Offset(bx, by);
|
||||
|
||||
final bColor = scalarMultiplier < 0 ? const Color(0xFFFF375F) : const Color(0xFF30D158);
|
||||
final bLabel = scalarMultiplier < 0
|
||||
? 'سالب المتجه (−${scalarMultiplier.abs().toStringAsFixed(1)} A)'
|
||||
: 'n·A (${scalarMultiplier.toStringAsFixed(1)} A)';
|
||||
|
||||
if ((scalarMultiplier - 1.0).abs() > 0.05) {
|
||||
_drawArrow(c, origin, bEnd, bColor, bLabel, width: 3.2, offsetText: 22);
|
||||
}
|
||||
|
||||
// Origin dot
|
||||
c.drawCircle(origin, 5, Paint()..color = Colors.white);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TAB 2: Dot & Cross Product (Parallelogram & Angle Arc)
|
||||
// ---------------------------------------------------------------------------
|
||||
void _paintDotAndCrossProduct(Canvas c, Size s) {
|
||||
final cx = s.width * 0.38;
|
||||
final cy = s.height * 0.58;
|
||||
final origin = Offset(cx, cy);
|
||||
|
||||
_drawGridAndAxes(c, s, cx, cy);
|
||||
|
||||
// Vector A along positive X axis:
|
||||
final aEnd = origin + Offset(dotMagA, 0);
|
||||
|
||||
// Vector B at angle theta:
|
||||
final radTheta = dotAngle * math.pi / 180.0;
|
||||
final bx = dotMagB * math.cos(radTheta);
|
||||
final by = -dotMagB * math.sin(radTheta);
|
||||
final bEnd = origin + Offset(bx, by);
|
||||
|
||||
// 1. Shaded Parallelogram for Cross Product (Area = |A × B|)
|
||||
final pFar = bEnd + Offset(dotMagA, 0);
|
||||
final paraPath = Path()
|
||||
..moveTo(origin.dx, origin.dy)
|
||||
..lineTo(aEnd.dx, aEnd.dy)
|
||||
..lineTo(pFar.dx, pFar.dy)
|
||||
..lineTo(bEnd.dx, bEnd.dy)
|
||||
..close();
|
||||
|
||||
c.drawPath(
|
||||
paraPath,
|
||||
Paint()
|
||||
..color = const Color(0xFFFF9F0A).withValues(alpha: 0.18)
|
||||
..style = PaintingStyle.fill,
|
||||
);
|
||||
c.drawPath(
|
||||
paraPath,
|
||||
Paint()
|
||||
..color = const Color(0xFFFF9F0A).withValues(alpha: 0.4)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.2,
|
||||
);
|
||||
|
||||
// 2. Shaded Projection for Dot Product (Shadow of B on A)
|
||||
final projX = origin.dx + bx;
|
||||
c.drawLine(
|
||||
bEnd,
|
||||
Offset(projX, origin.dy),
|
||||
Paint()
|
||||
..color = Colors.white38
|
||||
..strokeWidth = 1.2
|
||||
..strokeCap = StrokeCap.round,
|
||||
);
|
||||
c.drawRect(
|
||||
Rect.fromLTRB(
|
||||
math.min(origin.dx, projX),
|
||||
origin.dy - 3,
|
||||
math.max(origin.dx, projX),
|
||||
origin.dy + 3,
|
||||
),
|
||||
Paint()..color = const Color(0xFF30D158),
|
||||
);
|
||||
|
||||
// 3. Draw Vector Arrows
|
||||
_drawArrow(c, origin, aEnd, AppColors.saqelCyan, 'A', width: 3.2);
|
||||
_drawArrow(c, origin, bEnd, const Color(0xFF60A5FA), 'B', width: 3.2);
|
||||
|
||||
// 4. Angle Arc
|
||||
c.drawArc(
|
||||
Rect.fromCircle(center: origin, radius: 36),
|
||||
0,
|
||||
-radTheta,
|
||||
false,
|
||||
Paint()
|
||||
..color = const Color(0xFFFFD60A)
|
||||
..strokeWidth = 2.0
|
||||
..style = PaintingStyle.stroke,
|
||||
);
|
||||
final tpAngle = TextPainter(
|
||||
text: TextSpan(
|
||||
text: 'θ = ${dotAngle.toInt()}°',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFFFD60A),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout();
|
||||
tpAngle.paint(c, origin + const Offset(42, -26));
|
||||
|
||||
// Origin dot
|
||||
c.drawCircle(origin, 5, Paint()..color = Colors.white);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HELPER PAINTERS
|
||||
// ---------------------------------------------------------------------------
|
||||
void _drawGridAndAxes(Canvas c, Size s, double cx, double cy) {
|
||||
final grid = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.05)
|
||||
..strokeWidth = 1;
|
||||
for (double x = 0; x < s.width; x += 26) {
|
||||
c.drawLine(Offset(x, 0), Offset(x, s.height), grid);
|
||||
}
|
||||
for (double y = 0; y < s.height; y += 26) {
|
||||
c.drawLine(Offset(0, y), Offset(s.width, y), grid);
|
||||
}
|
||||
|
||||
final axis = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.22)
|
||||
..strokeWidth = 1.5;
|
||||
c.drawLine(Offset(0, cy), Offset(s.width, cy), axis);
|
||||
c.drawLine(Offset(cx, 0), Offset(cx, s.height), axis);
|
||||
}
|
||||
|
||||
void _drawArrow(
|
||||
Canvas c,
|
||||
Offset a,
|
||||
Offset b,
|
||||
Color col,
|
||||
String label, {
|
||||
double width = 2.6,
|
||||
double offsetText = 14,
|
||||
}) {
|
||||
final p = Paint()
|
||||
..color = col
|
||||
..strokeWidth = width
|
||||
..strokeCap = StrokeCap.round;
|
||||
c.drawLine(a, b, p);
|
||||
|
||||
final ang = math.atan2(b.dy - a.dy, b.dx - a.dx);
|
||||
const hs = 10.0;
|
||||
final p1 = b - Offset(math.cos(ang - 0.45) * hs, math.sin(ang - 0.45) * hs);
|
||||
final p2 = b - Offset(math.cos(ang + 0.45) * hs, math.sin(ang + 0.45) * hs);
|
||||
c.drawPath(
|
||||
Path()
|
||||
..moveTo(b.dx, b.dy)
|
||||
..lineTo(p1.dx, p1.dy)
|
||||
..lineTo(p2.dx, p2.dy)
|
||||
..close(),
|
||||
Paint()..color = col,
|
||||
);
|
||||
|
||||
// Draggable handle glow
|
||||
c.drawCircle(b, 7, Paint()..color = col.withValues(alpha: 0.28));
|
||||
c.drawCircle(b, 3.5, Paint()..color = col);
|
||||
|
||||
final tp = TextPainter(
|
||||
text: TextSpan(
|
||||
text: label,
|
||||
style: TextStyle(color: col, fontSize: 11, fontWeight: FontWeight.w900),
|
||||
),
|
||||
textDirection: TextDirection.rtl,
|
||||
)..layout();
|
||||
tp.paint(c, b + Offset(4, -offsetText));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _VectorsIntroCanvasPainter o) =>
|
||||
o.activeTab != activeTab ||
|
||||
o.planeHeading != planeHeading ||
|
||||
o.windSpeed != windSpeed ||
|
||||
o.isLanding != isLanding ||
|
||||
o.landingProgress != landingProgress ||
|
||||
o.vecMag != vecMag ||
|
||||
o.vecAngle != vecAngle ||
|
||||
o.scalarMultiplier != scalarMultiplier ||
|
||||
o.dotMagA != dotMagA ||
|
||||
o.dotMagB != dotMagB ||
|
||||
o.dotAngle != dotAngle;
|
||||
}
|
||||
+1539
-146
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user