Update Saqel Platform: 2026-09-12 13:38:49

This commit is contained in:
Hamza-Ayed
2026-09-12 13:38:49 +03:00
parent 1f31369893
commit f4d4f02853
34 changed files with 9136 additions and 386 deletions
@@ -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;
}
}
@@ -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;
}
@@ -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;
}