Update Saqel Platform: 2026-09-12 13:38:49
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user