الجوال والشاشات
الوضع native — تطبيق مصغّر بلغة Dart
تكتب Dart حقيقياً (لا JSON ولا DSL). تطبيق أوكتا يُنزّل المصدر،
يترجمه على الجهاز (مرة لكل إصدار منشور ثم يُخزَّن مؤقتاً)، ويعرض
الودجت الذي تُعيده main() — بهوية المنصة كاملة.
لا مُنتَج مترجَم يغادر مستودعك. okta-web يقرأ كل ملفات .dart تحت
okta_app/native/<entry>/lib/ في حزمة مصدر موقَّعة واحدة عند النشر،
فما يُراجَع هو بالضبط ما يعمل.
الهيكل (يصلك جاهزاً في الـ boilerplate)
okta_app/native/main/
├── pubspec.yaml ← يثبّت okta_miniapp — لا ترفع الـ ref بنفسك
├── analysis_options.yaml
├── lib/
│ └── main.dart ← نقطة الدخول: Widget main()
└── tool/
└── validate.dart ← نفس بوابة الـ CI
نقطة الدخول: okta_app/native/main/lib/main.dart — ملف .dart تحت
lib/ حصراً، بلا ... يُفرَض عند الحفظ وعند النشر معاً.
أصغر مثال يعمل
import 'package:flutter/material.dart';
import 'package:okta_host/okta_host.dart';
/// أوكتا يستدعيها ليأخذ ودجت الجذر.
Widget main() => const HomeScreen();
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
// القيم القياسية، لا `Okta.context()`: هذه تعيد **خريطة** مفكوكة
// بمفاتيح snake_case، فـ`ctx.tenantId` لا وجود له و`ctx['tenantId']`
// مفتاح غائب — وقراءة مفتاح غائب هي أخبث فخّ في هذا المحرّك (أدناه).
return Scaffold(
appBar: AppBar(title: const Text('تطبيقي')),
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('الجهة: ${Okta.tenantId()} الدور: ${Okta.roleId()}'),
ElevatedButton(
onPressed: () => Okta.toast('مرحباً'),
child: const Text('قل مرحباً'),
),
],
),
);
}
}
عقد المضيف — package:okta_host
المخرج الوحيد من الصندوق هو Okta.*. العقد الحالي 27.
| النداء | ما يفعله | أدنى عقد |
|---|---|---|
Okta.contract() |
رقم عقد المضيف على هذا الجهاز | 1 |
Okta.locale() · Okta.tenantId() · Okta.roleId() · Okta.isDark() |
الهوية والمظهر، قيماً قياسية | 1 |
Okta.context() |
نفس ما سبق خريطةً مفكوكة بمفاتيح locale/tenant_id/role_id/is_dark — فضّل القياسية |
1 |
Okta.get(path) · Okta.getQuery(path, query) · Okta.post(path, body) · Okta.api(method, path, body, query) |
HTTP عبر المضيف — المسارات محصورة في /api/<slug>/… وواجهة الشركاء المحكومة بالنطاقات. المصادقة تُرفَق عنك. تعيد OktaApiResponse — راجع «شكل الناتج» أدناه |
1 |
Okta.scanBarcode() · Okta.scanNfc() |
ماسحان يأخذان الشاشة ويعيدان قراءة واحدة (String?) |
1 |
Okta.uploadFile(path) |
اختيار ورفع لمسار مسموح — خريطة مفكوكة، أو null إن ألغى المستخدم |
1 |
Okta.toast(message) |
إشعار بهوية المضيف | 1 |
Okta.storeGet/storePut/storeDelete/storeKeys |
تخزين مفتاح-قيمة دائم، مُسمّى لتطبيقك وحده. storePut تعيد false عند الرفض ولا ترمي |
5 |
Okta.playSound(name) |
success · error (أو failure) · warning · chime — مفردات مغلقة، ومعها اهتزاز. وchime نغمة «مرّ طالبٌ وحُسب» وليست تغيير عقد (راجع «playSound باقية ولم تُستبدَل») |
5 |
Okta.location() |
موقع تقريبي واحد — خريطة ['latitude']/['longitude']/['accuracy']/['error'] |
5 |
Okta.preciseLocation() |
أدقّ ما يستطيعه الجهاز، أبطأ — نفس الشكل | 6 |
Okta.appIcon(size) |
ودجت أيقونة تطبيقك يرسمها المضيف (الصندوق لا يحمّل صورة) | 7 |
Okta.close() |
الخروج من التطبيق المصغّر — لا يملك الصندوق Navigator |
9 |
Okta.openDocument(path, fileName) |
تنزيل مسار API وتسليمه لتطبيق يفتحه. false جواب عادي |
11 |
Okta.toolKeyStart/toolKeyStop/toolKeyReads/toolKeyChannels |
قرّاء مفاتيح الأدوات (NFC/بلوتوث/سلكي/بوابة LAN) — سحب لا بثّ | 12 |
Okta.hasCapability(name) · Okta.requestCapability(name) |
مُتقاعدة — البوّابة خلفهما أُزيلت، وتُجيبان true دائماً على 19 فأعلى. أُبقيتا كي تبقى التطبيقات المنشورة تُترجَم. |
13 |
Okta.remoteImage(path, size) |
صورة يجلبها المضيف من مسار API ويرسمها. المسار لا الرابط | 15 |
Okta.cameraScanStart/cameraScanStop/cameraScanReads · Okta.cameraPreview(size) |
ماسح مدمج داخل تخطيطك — سحب كقرّاء المفاتيح | 15 |
Okta.studentHashid() · Okta.portal() |
الطالب الذي رُكِّب عليه تبويبك، والبوّابة (student/guardian) — '' على غير ذلك السطح |
17 |
OktaTabs.floatingBar وأخواتها (في okta_kit) |
القائمة السفلية العائمة بهوية أوكتا — ودجت جاهزة، لا نداء مضيف | 18 |
Okta.toolKeyFingerprint(key) |
بصمة البطاقة كما ينشرها كشف الطلاب — لمطابقة مسح بلا شبكة | 20 |
Okta.onMessage(handler) |
تسجيل مُعالِج الرسائل الفورية — واحد، والنداء الثاني يستبدل. لا إلغاء اشتراك: المضيف يغلق المقبس عند الإزالة. راجع «الرسائل الفورية» | 22 |
Okta.playAudio(url) · Okta.stopAudio() |
تشغيل ملف صوتي من رابط http(s) مطلق يخدمه خادمك أنت، وإسكات ما يُسمَع. النداء الثاني يستبدل ما يُسمَع، وstopAudio تُسكت المشغّل والنطق معاً. راجع «النداء المسموع» |
23 |
Okta.speak(text) · Okta.canSpeak() |
نطق نصّ بصوت الجهاز، والاستطلاع قبله. اسأل canSpeak قبل أن تعرض «النداء بالاسم»: أجهزة كثيرة بلا صوت عربي، وfalse جواب سليم لا عطل |
23 |
Okta.canRecord() |
هل يستطيع هذا الجهاز التسجيل أصلاً؟ اسألها قبل أن تعرض زرّ التسجيل — شاشة الفصل بلا مايك، والهاتف قد يكون إذنه مرفوضاً نهائياً. لا تطلب الإذن، فآمنٌ نداؤها أثناء الرسم | 24 |
Okta.recordStart() · Okta.recordStop() · Okta.recordCancel() |
تسجيل واحد في كل وقت. recordStop تعيد مقبضاً يسمّي التسجيل (String?)، والبايتات لا تعبر الجسر. راجع «المايك ورفع الملفات» |
24 |
Okta.playRecording(handle) · Okta.uploadRecording(path, handle) |
أسمِع صاحبه ما سجّله ثم ارفعه. المقبض المتقاعد يُرفَض ولا يُستبدَل به غيره | 24 |
Okta.uploadFileOfKind(path, kind) |
اختيار ورفع، وkind من مفردات مغلقة: image · audio · video · document · any. uploadFile القديمة لم تتغيّر |
24 |
OktaFocus.item(child, onSelect) · OktaFocus.first(...) |
من package:okta_focus: تجعل أي عنصر رسمتَه هدفاً لمفاتيح الاتجاه، وOK يُشغّل onSelect. وfirst هي التي تحمل التركيز عند أول رسم. راجع «دعم مفاتيح الاتجاه» |
25 |
OktaFocus.watched(child, onSelect, onFocusChange) · watchedFirst(...) |
نفسها، وتُخبرك بدخول التركيز وخروجه لترسم تمييزك فوق الحلقة لا بدلاً منها | 25 |
OktaFocus.reachable(child) |
يصله السهم ولا شيء يُضغَط — لصفٍّ ينبغي أن تمرّ الحلقة خلاله | 25 |
OktaNfc.canListen/start/reads/stop (في package:okta_nfc) |
محتوى بطاقة NFC — الرقم والتقنيات والنصوص المكتوبة عليها. سحبٌ لا بثّ، وأندرويد وحده، ويحتاج منحة | 26 |
Okta.toolKeyStartOn(channels) |
يفتح القارئات التي تسمّيها وحدها — nfc · keyboard · uhf_bluetooth. toolKeyStart() لم تتغيّر وتبقى تفتح الكل. راجع «افتح ما تحتاجه وحده» |
26 |
OktaFace.availability/start/reads/stop · OktaFace.preview(size) (في package:okta_face) |
مَن رأته كاميرا التعرّف في المدرسة — نوع الشخص ومعرّفه، لا صورته ولا قالبه. availability تجيب كلمة تقول مَن يُصلح الرفض. سحبٌ لا بثّ، وحاسوب Windows يشغّل خدمة التعرّف، ويحتاج منحة. راجع «مَن رأته كاميرا التعرّف» |
27 |
التطبيق المصغّر لا يملك أي صلاحية dart_eval: لا شبكة ولا نظام ملفات
إلا عبر هذه النداءات.
صيغة مفتاح البطاقة — واحدة، أياً كان النداء الذي أعادها
Okta.scanNfc() وOkta.toolKeyReads() وOktaNfc.reads() تُعيد المفتاح في
الصيغة القانونية نفسها التي يخزّنها okta-web: حروف صغيرة، بلا فواصل (: أو - أو مسافات)،
وبلا محارف تحكّم — فقارئ يرسل AA:BB:CC وآخر يرسل aabbcc يصلانك aabbcc.
قارِن مباشرةً. لا تكتب .toUpperCase() ولا .toLowerCase() قبل المقارنة:
لن تضرّ لأن التطبيع مُنتِج لنفسه، لكنها تُخفي أي انحراف مستقبلي بدل أن
تكشفه، وتجعل شفرتك تبدو كأنها تعرف شيئاً عن الصيغة لا تعرفه.
إن كان تطبيقك يحمل التفافاً على هذا فاحذفه. كان
Okta.scanNfc()يعيد حروفاً كبيرة غير مطبَّعة بينما مسار مفاتيح الأدوات يعيد الصيغة القانونية، فبطاقة تُعرَّف في شاشة الأدوات لا تطابق نفسها حين تُقرأ عبرscanNfc. لم يكن يظهر كعطل: المسح ينجح والبحث يخيب، فيبلّغ التطبيق أن الشخص غير مسجَّل. صُحّح في المضيف، والسطحان متطابقان الآن.
وينطبق الأمر نفسه على جسر WebView: حدث nfc صار يحمل الصيغة القانونية بعد
أن كان يحمل حروفاً كبيرة.
افتح ما تحتاجه وحده — toolKeyStartOn (العقد 26)
Okta.toolKeyStart() تفتح كل قارئ يملكه الجهاز، وهو الصواب لبوّابة تقبل
أي بطاقة. وهو خطأ لشاشة تقرأ نوعاً واحداً: بقيّة الراديوهات تُشعَل بلا أن
تُقرأ، وجلسة NFC يمنحها النظام لمالك واحد — فتطبيقٌ يقرأ باركود ويترك NFC
مفتوحة يحجزها عن Okta.scanNfc() وعن OktaNfc ما دامت شاشته قائمة.
// مفردة مغلقة: nfc · keyboard · uhf_bluetooth
final ok = await Okta.toolKeyStartOn(['nfc']);
if (!ok) {
// جهازٌ بلا ذلك القارئ — حالة عادية، اعرض بديلاً
}
الجواب نفسه الذي تعطيه toolKeyStart: true إن فُتحت واحدة على الأقل،
وfalse إن لم يُفتح شيء، ولا ترمي أبداً. وبقيّة النداءات لم تتغيّر —
toolKeyReads وtoolKeyChannels وtoolKeyStop تعمل كما هي أياً كان النداء
الذي فتح.
واقرأ toolKeyChannels() بعدها، ولا تثق بالقائمة التي أرسلت. هذا ليس
احتياطاً زائداً: الاسم الذي لا يعرفه المضيف يُسقَط، وطلبٌ لم يبقَ فيه اسم
معروف يُقرأ «كل القنوات» — أي أن ['NFC'] بحرف كبير تفتح الثلاث لا واحدة.
الاتجاه مقصود: تطبيقٌ يحسب قائمته أثناء التشغيل ويصل إلى لا شيء يُفضَّل أن
يحصل على قارئ يعمل بدل قارئ ميّت — قارئٌ لا يفتح شيئاً بصمت هو العطل الذي
وُجدت هذه القدرة لتمنعه. لكنه يعني أن الخطأ المطبعيّ يوسّع ولا يضيّق، ولا
شيء على الشاشة يستطيع أن يقوله لك. toolKeyChannels() هو ما يقوله:
await Okta.toolKeyStartOn(['nfc']);
final live = await Okta.toolKeyChannels(); // ['nfc'] لو نجح التضييق
وtoolKeyStart() لم تتغيّر حرفاً — لم تكتسب وسيطاً ولم يُعَد توجيهها. إن
كان تطبيقك يفتح الكل فلا شيء عليك أن تفعله، ولا ترفع minContract إلى 26 من
أجل رمز لا تناديه.
والمحاكي قد يتأخّر عن الهاتف، وهذا ليس عطلاً بل بنية. «الجهاز الافتراضي» ناتج بناء من okta-app يُنشَر في البوّابة يدوياً، فله رقم عقد خاصّ به قد يكون أقدم مما تقرؤه في الجدول أعلاه. ورمزٌ فوق عقد المحاكي لا يُجيبك
falseهناك — لا يُترجَم أصلاً، برسالة تسمّي ملفاً لم تكتبه (Cannot find static methodأوCannot find import).اليوم غلافا المحاكي عند العقد 27، فكل ما في الجدول أعلاه يُترجَم فيهما. والقاعدة باقية للعقد التالي: حين يتقدّم الهاتف قبل أن يُعاد بناء الغلافين، جرّب الرمز الجديد على جهاز حقيقي.
قراءة محتوى البطاقة — package:okta_nfc (العقد 26)
كل ما سبق يجيب لمن هذه البطاقة. وهذا يجيب ماذا عليها: الرقم نفسه، والتقنيات التي قدّمتها الشريحة، والنصوص المكتوبة فيها (سجلات NDEF). ولا يكتب على بطاقة ولا يغيّرها — ولن يفعل: الكتابة لا تُتراجَع على عتاد تسلّمه المدرسة لطفل.
والرقم هو الرقم نفسه. row['key'] هنا هو حرفاً بحرف ما يعيده
Okta.scanNfc() لنفس البطاقة: قراءة واحدة على المضيف، ومُطبِّع واحد، وصيغة
قانونية واحدة. فتطبيق ينتقل إلى هذا السطح يبقى يحلّ كل أداة مسجَّلة عبر
GET /tool-keys/resolve نفسها، بلا إعادة ربط وبلا سجلّ ثانٍ. لا تُطبِّعه من
جديد، ولا ترفع حروفه قبل المقارنة.
اسأل قبل أن ترسم
import 'package:okta_nfc/okta_nfc.dart';
if (await OktaNfc.canListen()) {
// ارسم زرّ الاستماع
} else {
// اسقط إلى Okta.scanNfc() — نفس الرقم، بطاقة واحدة في المرّة
}
canListen() تجيب false على كل ما ليس أندرويد، وعلى جهاز بلا NFC،
وحين لا تمنح الجهة تطبيقك هذه القدرة. وثلاثتها أجوبة عادية عن أجهزة
عادية. زرٌّ يُرسم بلا سؤال هو زرٌّ يُضغط فلا يحدث شيء — وهو العطل الذي وُجد
هذا الاستطلاع ليمنعه.
النطاق المطلوب
nfc.android_listener.read في scopes[] من بيانك. وهو مورد مستقل لا
فعلٌ أوسع على نطاقات الأدوات، عمداً: مفتاح الأداة معرّفٌ أصدرته المنصّة
وتستطيع سحبه، أما البايتات بجانبه على الشريحة فكتبها من برمج البطاقة ولا
سجلّ للمدرسة بها — سؤالان مختلفان، وتُسأل الجهة عنهما مرّتين.
والمنحة تُقرأ عند كل فتح، فسحبها من شاشة التثبيت يغلق القدرة في الفتحة التالية بلا أن تنشر شيئاً.
minContract: 26 إلزامي
تطبيق ينادي هذه المكتبة يجب أن يُعلن mobile.minContract: 26. وإغفالها
لا يجعله يعمل على مضيف أقدم: التطبيق المصغّر يُترجَم على الجهاز، فمكتبة
غائبة فشلُ ترجمة. الفرق أن المضيف يتعرّف على الاستيراد المفقود ويعرض
«حدِّث التطبيق» بدل أن يترك المستخدم أمام رسالة مترجِم.
ولا يوجد «جرّب 26 وإلا اسقط إلى 25» في إصدار واحد: المترجم يحلّ الرمز قبل
أن يُنفَّذ أي شرط، فمجرّد وجود النداء في الكود يكفي. الاختيار عند النشر:
إمّا تُعلن 26 وتستعمل الاثنين (هذا السطح وOkta.scanNfc()) بحسب الحالة،
أو لا تُعلن شيئاً وتبقى على القديم في كل مكان.
اسحب في حلقة، ولا تنتظر
لا شيء يستطيع أن ينادي إلى داخل تطبيقك المصغّر، فالقراءات تُجمَع
وتُسحَب — نفس شكل Okta.toolKeyReads():
Future<void> _drain() async {
final rows = await OktaNfc.reads();
for (final row in rows) {
final key = OktaJson.str(row, 'key'); // نفس رقم scanNfc
final text = OktaJson.str(row, 'text'); // المكتوب على الشريحة
_handle(key, text);
}
await Future.delayed(const Duration(milliseconds: 300));
if (_listening) {
_drain();
}
}
احرس الحلقة بعلمك أنت: State.mounted غير مجسور، فلا يستطيع فاصلٌ غير
متزامن أن يسأل هل ما زال على الشاشة. وحلقةٌ تنسى نفسها تعمل حتى يموت
التطبيق.
شكل الصفّ: key · at (ISO-8601 بتوقيت UTC، حين قرأها المضيف) ·
text (كل النصوص مجموعة بأسطر جديدة، أو '') · technologies ·
records (لكل سجلّ tnf وtype وtext).
وtext حقلٌ جاهز بجانب records عمداً: أكثر ما يُسأل هو «ما المكتوب على
البطاقة؟»، والبنى المتداخلة الخارجة من فكّ JSON تحتاج عناية داخل المفسّر.
الحدود
- أندرويد فقط. iOS يعطي نافذة نظام لكل قراءة لا حلقة في الخلفية، وذلك
شكلُ
Okta.scanNfc()لا شكل هذا السطح. والويب بلا جلسة أصلاً. - جلسة واحدة للجهاز.
OktaNfc.start()تأخذ الراديو من قارئ الأدوات متعدّد القنوات، فلا تتوقّع أن يبقىOkta.toolKeyReads()يُخرج قراءات NFC أثناء الاستماع — نادِOkta.toolKeyStart()مرّة أخرى بعدOktaNfc.stop()إن أردته. stop()يرمي ما في الذاكرة. القراءات المخزَّنة بطاقاتٌ لُوِّح بها قبل دقائق، وإعادتها إلى شاشة جديدة تصرّفٌ على ما لم يره أحد.- والمحاكي يرفض بصدق:
canListen()تجيبfalseهناك دائماً، فيُجرَّب مسار السقوط في المتصفّح، والقراءة الحقيقية على جهاز أندرويد.
مَن رأته كاميرا التعرّف — package:okta_face (العقد 27)
المدرسة تشغّل خدمة التعرّف على الوجه على حاسوب Windows في المبنى — عند البوّابة، في مكتب الاستقبال، عند باب القاعة. وهذا الفصل يجعل تطبيقك يطلب منها أن تعمل ما دامت شاشته قائمة، ويخبرك مَن تعرّفت عليه: نوع الشخص ومعرّفه على المنصّة، ووقت رؤيته.
ولا شيء غير ذلك يصلك — لا صورة ولا قالب ولا درجة تطابق ولا اسم. المضيف هو من ينزّل قوالب المدرسة، بصلاحية الشخص المسجَّل على ذلك الجهاز نفسه، ويسلّمها للمحرّك على الحاسوب نفسه. قالبٌ في يد تطبيق قالبٌ يستطيع أن يرسله إلى أيّ مكان، ولا يُصدَر للطفل وجهٌ بديل — فلا رمز في هذه المكتبة يستطيع حمل واحد، في أيّ اتجاه.
اسأل قبل أن ترسم — والجواب كلمة
import 'package:okta_face/okta_face.dart';
final status = await OktaFace.availability();
if (status == 'ready') {
// ارسم زرّ البدء
} else {
// قل لماذا — والكلمة تقول مَن يُصلح
}
| الكلمة | معناها | مَن يُصلحها |
|---|---|---|
ready |
البدء سيعمل | — |
unsupported |
الجهاز ليس Windows — المحرّك يعيش على واحد. والمحاكي وشاشات العرض يجيبونها دائماً | لا أحد — جهاز آخر |
not_granted |
الجهة لم تمنح تطبيقك face_recognition.matches.read |
الجهة، من شاشة التثبيت |
service_offline |
الخدمة لا تُجيب بعد: المضيف يشغّلها بنفسه حين تسأل، وإقلاعها قد يأخذ دقيقة — أو لا خدمة مثبّتة على هذا الحاسوب | لا أحد وهي تُقلع — اسأل ثانيةً. وإن دامت فالفنّي: الإعدادات التقنية في تطبيق أوكتا تقول لماذا |
no_permission |
الشخص المسجَّل لا يملك تحميل وجوه المدرسة على هذا الجهاز. وفي بوّابة المجمّع أيضاً: لم يعد يملك هناك المفتاحين (tenants.children.operate_apps وصلاحيةً من تطبيقك) |
من يمنح صلاحيات المدرسة — أو المجمّع |
no_faces |
لا أحد مسجَّل بوجهه للنموذج الذي يعمل به المحرّك | مكتب التسجيل |
busy |
الكاميرا مع شيء آخر على هذا الحاسوب — شاشة التسجيل عادةً | انتظر واسأل ثانيةً |
unreachable |
تعذّر جلب قائمة المدرسة الآن | الشبكة — أعد المحاولة لاحقاً |
too_large |
في بوّابة المجمّع وحدها: الشجرة أكبر ممّا تحمله بوّابة واحدة — فوق سقف المنصّة، أو أطول من الرسالة الواحدة التي تقبلها الخدمة على هذا الحاسوب. ولا تُحمَّل ناقصةً أبداً | لا أحد عند البوّابة: تشغّل كل مدرسة بوّابتها هي (فتحمّل مدرستها وحدها) |
service_outdated |
في بوّابة المجمّع وحدها: الخدمة على هذا الحاسوب لم تُثبت أن تطبيق أوكتا هو من شغّلها، أو لا تقرأ القائمة المضغوطة — بناءٌ أقدم، أو خدمةٌ شُغّلت باليد. وتبقى تخدم المدرسة الواحدة كما كانت | الفنّي: يحدّث الخدمة، أو يغلق المُشغَّلة باليد ويترك التطبيق يشغّل خدمته |
واقرأ أيّ كلمة لا تعرفها «غير جاهز» — القائمة قد تكبر. ولماذا كلمة لا
bool؟ لأن كل رفضٍ يُصلحه شخصٌ مختلف، وfalse واحدة كانت سترسل الأربعة إلى
المكان الخطأ نفسه.
وثلاث كلمات تزول وحدها، فأعد السؤال: service_offline (الخدمة تُقلع —
المضيف بدأ تشغيلها حين سألت)، وbusy (الكاميرا تتحرّر)، وunreachable (الشبكة
تعود). فلا تسأل مرّة عند فتح الشاشة وتكتفِ: أعد السؤال كل نصف دقيقة ما دامت
الشاشة قائمة ولم يبدأ التعرّف، كما يفعل المثال أدناه. والمضيف لا ينتظر الإقلاع
عنك عمداً: availability() تُجيب خلال ثوانٍ لأنها تُنتظَر في كودك أنت، وربما
على السلسلة نفسها التي تخدم قارئات البطاقات — ودقيقتان من الانتظار هناك دقيقتان
لا تُقرأ فيهما بطاقة.
النطاق المطلوب
face_recognition.matches.read في scopes[] — قراءة فقط، ولن يكون غير ذلك.
ويُقرأ عند كل فتح كنطاق NFC، فسحبه من شاشة التثبيت يغلق القدرة في الفتحة
التالية بلا أن تنشر شيئاً.
وليس بديلاً عن education.students.read: هذه المنحة تخبرك مَن رُئي
بمعرّفه، والاسم تقرؤه عبر واجهة الطلاب إن كانت الجهة منحتك إيّاها. منحةٌ واحدة
لا تصير اثنتين بصمت. وperson_id هو المعرّف نفسه الذي تجيب به
/api/apps/education/students، فالمطابقة مباشرة.
minContract: 27 إلزامي
حجّة العقد 26 حرفاً بحرف: المكتبة تُترجَم على الجهاز، ومضيفٌ أقدم يتعرّف على الاستيراد المفقود ويعرض «حدِّث التطبيق». ولا «جرّب 27 وإلا اسقط» في إصدار واحد — المترجم يحلّ الرمز قبل أن يُنفَّذ أيّ شرط.
ابدأ، واسحب في حلقة، وأوقف عند الخروج
bool _listening = false;
bool _closed = false;
Future<void> _open() async {
if (_closed) return;
final status = await OktaFace.availability();
if (_closed) return;
if (status == 'ready') {
if (await OktaFace.start()) {
_listening = true;
_drain();
return;
}
}
// الخدمة قد تكون تُقلع، والكاميرا قد تتحرّر، والشبكة قد تعود — فاسأل ثانيةً.
if (_passes(status)) {
await Future.delayed(const Duration(seconds: 30));
_open();
}
}
bool _passes(String status) =>
status == 'ready' ||
status == 'service_offline' ||
status == 'busy' ||
status == 'unreachable';
Future<void> _drain() async {
final rows = await OktaFace.reads();
for (final row in rows) {
_arrived(OktaJson.str(row, 'person_type'), OktaJson.str(row, 'person_id'));
}
await Future.delayed(const Duration(milliseconds: 500));
if (_listening) {
_drain();
}
}
void _close() {
_closed = true;
_listening = false;
OktaFace.stop();
}
و_closed مرّتين: قبل السؤال، كي لا يسأل سؤالٌ مؤجَّل — فيشغّل الخدمة — لشاشة
أُغلقت؛ وبعده، كي لا تبدأ الكاميرا لشاشة أُغلقت والجواب في طريقه. نادِ _open()
من initState و_close() من dispose.
شكل الصفّ: person_type (student أو employee) · person_id · at
(ISO-8601 بتوقيت UTC، حين رآه المضيف). والمفاتيح الثلاثة حاضرة دائماً.
واقرأ الصفّ في حلقة for كما أعلاه — لا تمرّره عبر ?: (المصيدة ١٥).
صفٌّ لكل وصول لا لكل إطار: المحرّك يتعرّف على كل إطار مرّتين في الثانية، والمضيف يطوي ذلك — مَن يقف أمام الكاميرا صفٌّ واحد، ولا يعود صفّاً ثانياً إلا بعد أن يغيب بضع ثوانٍ. فلا تكتب منع تكرار خاصّاً بك لهذا السبب. والمخزن يحتفظ بأحدث 256 إن لم تسحب.
والمعاينة: OktaFace.preview(size) ودجت يرسمه المضيف — الكاميرا الحيّة ما
دام تشغيلك أنت يعمل، وصندوقٌ فارغ بالمقاس نفسه قبل start وبعد stop وحين
تكون الكاميرا مع غيرك، فلا يتحرّك ما حولها.
كاميرا واحدة، ولها مالك
للحاسوب كاميرا واحدة والخدمة تشغّل شيئاً واحداً. فـstart() لا تأخذها من
شيء آخر — تجيب false، وavailability() تقول busy. وstop() تُنهي
تشغيلك وحده: إن أخذ مكتب التسجيل الكاميرا في الأثناء تُركت له.
والمضيف يُبقي تشغيلك حيّاً عنك: إن أُعيد تشغيل الخدمة، أو استعار مكتب التسجيل الكاميرا ثم أعادها، يعود التعرّف وحده بلا نداء منك — لأن البديل بوّابةٌ صمتت الساعة 7:40 ولا تقول شيئاً. فلا تكتب حلقة «أعد البدء إن توقّف» — بعد أن يبدأ. أمّا قبل ذلك فإعادة السؤال عليك (أعلاه).
الحدود
- Windows وحده، وحيث تعمل الخدمة. الهواتف وشاشات العرض والمتصفّح تجيب
unsupported، وهو الفرع الذي يسلكه أكثر الأجهزة — اكتبه. - في المدرسة قائمتها، وفي المجمّع شجرته المُشغَّلة. حين تسمح المنصّة لهذا
الفتح بتشغيل مدارس المجمّع (والإطلاق يقول ذلك لتطبيق أوكتا — «التطبيق يعمل
داخل مدارس المجمّع»)، تكون القائمة طلاب المجمّع نفسه وطلاب كل مدرسة تُديرها
نسختُه ومُنحت نسختُها
face_recognition.matches.read. الطلاب وحدهم: لا يُتعرَّف في هذا الوضع على موظّف، لا من المدارس ولا من المجمّع. ويبقى الصفّ بمفاتيحه الثلاثة ولا يسمّي مدرسة — ضع الطالب في مدرسته أنت، منentity_idفي كشف بطاقاتك أو على خادمك بـActForStudentsInTree. وأولavailability()هناك ينتظر تنزيل الشجرة كلّها (والوجوه تُنسى حين تُغلق الشاشة وتُنزَّل ثانيةً في الفتح التالي) — فلا تؤخّر قارئات البطاقات في المجمّع حتى يجيب الوجه. - والمحاكي يجيب
unsupportedبصدق — تبويب متصفّح بلا محرّك. فجرّب فيه فرع «غير جاهز» كلّه، والتعرّف الحقيقي على حاسوب المدرسة.
الماسح المدمج: cameraScanStart تُسلّح، وcameraPreview تفتح
Okta.cameraScanStart() تُجهّز الكاميرا ولا تفتحها. العدسة تُفتح حين
تُركِّب Okta.cameraPreview(size) في شجرتك، والمعاينة ليست عرضاً اختيارياً بل
هي مصدر القراءات: تطبيق يُسلّح الماسح ولا يركّب المعاينة لا يحصل على شيء
من cameraScanReads() أبداً.
فالترتيب:
cameraScanStart() → ركِّب cameraPreview(size) → cameraScanReads() في حلقة سحب
و**true تعني «مُسلَّح» لا «مفتوح»**: ما يُحسَم قبل عودتها هو إذن الكاميرا من
نظام التشغيل — وهو ما تعنيه false. ما بعد ذلك يقرّره تركيب المعاينة.
هذه النقطة تغيّرت في المضيف. كانت
cameraScanStartتحاول فتح العدسة بنفسها، وكانت تفشل دائماً على كل جهاز: الحزمة ترفض التشغيل قبل بناء ودجت المعاينة، والمعاينة لم تكن تُبنى إلا بعد نجاح التشغيل — حلقة مغلقة جعلت الماسح المدمج يُجيبfalseمنذ العقد 15. إن كنت جرّبته وتركته، جرّبه ثانيةً بالترتيب أعلاه.
بوّابة تعمل بلا شبكة — كشف الطلاب والبصمة (عقد 20)
Okta.toolKeyResolve عبر GET /tool-keys/resolve يسأل المنصّة «لمن هذه
البطاقة؟» — ويحتاج شبكة. وعند بوّابة مدرسة في السابعة صباحاً، اللحظة التي
تحتاج فيها جواباً هي اللحظة التي تنقطع فيها الشبكة.
فالكشف يُنزَّل مسبقاً:
GET /api/apps/tool-keys/students النطاق: tool_keys.links.read
{
"students": [
{
"student_id": "01J2X…",
"full_name": "…",
"grade_id": "01J2A…",
"section_id": "01J2B…",
"fingerprints": ["9f2c1d…", "4b70aa…"]
}
]
}
الصفوف تحمل بصمات لا مفاتيح، وهذا ليس تقييداً بل هو ما جعل الكشف ممكناً. مفتاح NFC هو UID البطاقة، فكشفٌ بمفاتيح خام هو وسيلة كتابة بطاقة تفتح البوّابة باسم أي طالب فيه. البصمة تُطابِق ولا تُصنِّع.
ولذلك لا تقارن مسحك بالكشف مباشرةً — مرّره أولاً:
final reads = await Okta.toolKeyReads();
for (final read in reads) {
final fp = await Okta.toolKeyFingerprint('${read['key']}');
if (fp == '') {
// لم يستطع البصم — لا مفتاح بصم مخزَّن ولا شبكة لجلبه.
// هذه ليست «بطاقة غير مسجَّلة»، فلا تعاملها كذلك.
continue;
}
final student = rosterByFingerprint[fp]; // كشفك المنزَّل
…
}
ثلاث خصائص تعتمد عليها:
- البصمة ثابتة لبطاقة واحدة في مدرسة واحدة — نفس القيمة في كل مرّة، وسواء أنتج المفتاحَ قارئ NFC أو UHF.
- ومختلفة بين مدرستين للبطاقة الفيزيائية نفسها. فلا تُخزّن بصمة من جهة وتقارنها في أخرى.
- مفتاح البصم لا يصل تطبيقك أبداً. المضيف يحمله ويبصم نيابةً عنك. ولو وصلك لأمكن توليد كل UID من أربعة بايتات وإعادة الكشف إلى بطاقات خام — وهو ما تمنعه البصمة أصلاً.
السلسلة الفارغة ليست «غير مسجَّل».
toolKeyFingerprintتُجيب''حين تعذّر البصم — لا مفتاح بصم بعد، أو جلسة بوّابة بلا جهة. وبصمة فارغة تُقارَن بالكشف فلا تطابق أحداً، فتظهر عند البوّابة كطالب غير مسجَّل. افحصها صراحةً وأظهر للمشغّل أن الجهاز لم يستطع التحقّق، لا أن الطالب مرفوض.
والطالب بلا بطاقة يظهر في الكشف بقائمة فارغة ولا يُحذف منه: «لم يُسجَّل بعد» و«ليس في هذه المدرسة» جوابان مختلفان، وكشف حاملي البطاقات وحدهم يجيب عنهما بصمت واحد.
وأعلِن minContract: 20 إن استعملت toolKeyFingerprint. الدالة رمز
جديد في مكتبة محقونة، والتطبيق يُترجَم على الجهاز — فنداؤها على مضيف أقدم
يموت بـCannot find static method مسمّياً ملفاً لم تكتبه. الكشف نفسه نداء
HTTP عادي ولا يحتاج حدّ عقد.
ملاحظة للتطبيقات القائمة: هذا الكشف تحت النطاق
tool_keys.links.readنفسه الذي يحمله تطبيقك إن كان يستعملresolve. لا نطاق جديد ولا إعادة موافقة من الجهات — لكن معناه اتّسع: كان «تأكَّد من بطاقة سُلّمت لك»، وصار يشمل «نزّل قائمة من يحمل بطاقة».
نداءٌ فوق عقد الجهاز لا يفشل — بل يُفسد الجلسة
كل صفّ عليه عقد غامق أعلاه مبنيّ على دالة خارجية يسجّلها المضيف. على مضيف أقدم لا تكون مسجَّلة، واستدعاؤها لا يُرجع خطأً بل يُتلف المفسّر لبقية الجلسة: ما يلي من نداءات يفشل بلا رابط بالسبب، فتطارد عطباً في شفرة سليمة.
جوابان، اختر واحداً لكل نداء:
- أعلِن
minContractفي بيانك مساوياً لأعلى رقم تستعمله، فيعرض تطبيق أوكتا «حدّث التطبيق» بدل تشغيلك على مضيف لا يفهمك؛ أو- احرس عند الاستدعاء:
if (Okta.contract() >= 11) { … }وقدّم بديلاً.والقياسية و
get/postوtoastوscan*بلا حراسة — كانت موجودة من العقد 1.
القائمة السفلية العائمة — OktaTabs.floatingBar (عقد 18)
شريط مستدير منفصل عن حواف الشاشة يطفو فوق المحتوى. مجرد استعمال الودجت يكفي:
ولم يعد هذا شكل المنصّة. بار أوكتا اليوم صلب ومثبَّت على الحافة —
OktaTabs.bottomBar، راجع «الشكل الثابت لكل تطبيق». العائم باقٍ يعمل كما هو لمن يعتمده، فهو خيارُ هيئةٍ لا الهيئةَ الافتراضية.
final labels = <String>['الرئيسية', 'التحضير', 'التقارير'];
final icons = <IconData>[OktaIcons.home(), OktaIcons.list(), OktaIcons.chart()];
Widget main() {
final palette = OktaPalette.of(Okta.isDark());
return OktaTabs.floatingOver(
content, // صفحتك
OktaTabs.floatingBar(labels, icons, _index, _onSelect, palette),
);
}
floatingOver يضع الشريط طافياً أسفل المحتوى (Scaffold هنا بلا خانة
bottomNavigationBar، فالتركيب Stack — وهذه الدالة هي ذلك الـ Stack كي لا
يعيد كل تطبيق بناءه). أعطِ الجزء المتمرّر حشوة سفلية OktaTabs.floatingReserve()
كي لا يختبئ آخر صفّ تحت الشريط.
التخصيص طبقات — بنفس نمط الصنف كله (دوال منفصلة، لا معاملات اختيارية — dart_eval 0.8.5 ينهار على معامل اختياري محذوف لدالة ساكنة في مكتبة محقونة):
| الدالة | ما تتحكم فيه |
|---|---|
floatingBar(labels, icons, selectedIndex, onSelect, palette) |
الهوية الافتراضية من الباليت |
floatingBarWithDots(… , dots, …) |
+ نقطة غير-مقروء على أي تبويب علمه true (القائمة تقصر بأمان) |
floatingBarTinted(… , background, ring, activePill, activeInk, idleInk) |
كل لون بيدك |
مرّر الألوان كسائنات Color مبنية من قيم حرفية عند موضع النداء
(const Color(0xFF6D428F)): كائن Color يعبر المعاملات المفسَّرة سليماً،
أمّا رقم int خام يُمرَّر ثم يُطعَم لمُنشئ مجسور فيصل مغلَّفاً مرتين ويموت
بخطأ cast.
صلب لا زجاجي، عمداً: BackdropFilter غير مجسور وBoxShadow غير معلَن على هذا المشغّل، فعمق الشريط حلقة بكسل واحد بلون الحدود — هندسة لا Border (المجسر يعلن المعامل BoxBorder ولا يعلن Border نوعاً فرعياً له).
العقد 18. استدعاء هذه العائلة على مضيف 17 يموت داخل
okta_kitبرسالةCannot find static methodتسمّي ملفاً لم تكتبه. أعلِنminContract: 18أو احرس بـOkta.contract() >= 18وقدّمbottomBarبديلاً — وهي متاحة منذ العقد 1، وهي شكل المنصّة اليوم.
flutter analyzeسيشتكي أنOktaوpackage:okta_hostغير معرّفين — هذا متوقَّع. المكتبة تُحقَن من المحرّك وقت الترجمة وليست حزمة pub حقيقية. البوابة المعتمَدة هيflutter test tool/validate.dart.
شكل ناتج Okta.get / Okta.post — وكيف تستهلكه
كل نداءات HTTP تعيد OktaApiResponse بثلاثة حقول وخاصية محسوبة واحدة:
| الحقل | النوع | ماذا يحمل |
|---|---|---|
status |
int |
كود HTTP — و**0 إن فشل النداء قبل أن يصل خادماً** (شبكة، أو مسار مرفوض من قائمة السماح) |
body |
dynamic |
جسم JSON مفكوكاً — أو نصّاً خاماً إن لم يكن JSON |
error |
dynamic |
وصف عطل النقل/الصلاحية، وnull إن لم يقع عطل |
ok |
bool (محسوبة) |
error == null و status بين 200 و299 |
فحصُ ok وحده يكفي للمسار السعيد: يجمع «لا عطل نقل» و«حالة ناجحة» معاً،
فلا حاجة لفحص status >= 200 && status < 300 بنفسك.
final res = await Okta.get('/api/my-app/students');
if (!res.ok) {
// status == 0 يعني أن النداء لم يصل خادماً أصلاً — لا تعرض «خطأ من
// الخادم» عندها، فالخادم لم يقل شيئاً.
Okta.toast(res.status == 0 ? 'تعذّر الاتصال' : 'الخادم ردّ ${res.status}');
return;
}
// body ديناميكي — أبقِه كذلك. راجع البند أدناه.
final dynamic payload = res.body;
final dynamic rows = payload['data'];
if (rows is List) {
for (final dynamic row in rows) {
final dynamic name = row['full_name'];
// …
}
}
الفهرسة أعلاه تفترض أن نقطتك تُعيد كائن JSON — وهي نقطتك أنت، فالافتراض في يدك. أما إن ردّت نصّاً غير JSON فيصلك
bodyنصّاً خاماً، وفهرسته بمفتاح نصّي تفشل وقت التشغيل. ولا يمكن الاحتراز بـif (body is Map): ذلك الحارس يُرقّي المستقبِل فيوقعك في فخّ الفهرسة نفسه.
body من نوع dynamic عمداً — وهذا ليس تفصيلاً. إن «رتّبته» بإعطائه
نوع Map صريحاً:
final Map data = res.body; // ← لا تفعل
final rows = data['items']; // Cannot use variable of type String as index
// to map of type <int, Color>
فأنت تقع في فخّ فهرسة Map الموصوف في قائمة dart_eval أعلاه، وقت
الترجمة. الحقل يصلك dynamic من المضيف لهذا السبب بالذات: تركُه على حاله
هو المسار الصحيح، لا الكسل.
error مقابل status: error يخصّ ما دون HTTP — انقطاع الشبكة، أو مسار
رفضته قائمة السماح فلم يُرسَل أصلاً. أما ردّ الخادم بـ 4xx/5xx فيصل بـ
error == null وstatus يحمل الكود. كلاهما يجعل ok تساوي false، لكنهما
عطلان مختلفان للمستخدم: الأول «لا اتصال»، والثاني «الخادم رفض».
Okta.uploadFile ليست كذلك — تعيد خريطة مفكوكة، لا صنفاً. اقرأها
بالمفاتيح ['status'] و['body'] و['file_name'] و['error'] (لاحظ
file_name بالشرطة السفلية)، و**null إن ألغى المستخدم الاختيار** فافحص
العدم قبل المفاتيح. لا .ok هنا ولا .fileName.
ولماذا تختلف عن
Okta.get؟ لأنOktaApiResponseصنفٌ يذكرهOkta.apiنفسه، وكل تطبيق يناديapiفيَحلّ الصنف. أمّا المكتبة المحقونة فلا يجوز أن تذكر صنفاً لا يستدعيه تطبيقك: dart_eval لا يَحلّ مثل هذا الصنف إلا حين يسحبه المصدر، فلتطبيق لا يرفع ملفاً يكون الاسم غير موجود — والانهيار يقع على المكتبة المحقونة، مسمّياً ملفاً لم يكتبه الشريك:CompileError: Unknown type OktaUploadResult. ولهذا صارتuploadFileوcontextوlocationوpreciseLocationكلّها تعيد خرائط.
final dynamic up = await Okta.uploadFile('/api/my-app/attachments');
if (up == null) return; // ألغى الاختيار
final dynamic status = up['status'];
if (status is int && status >= 200 && status < 300) {
Okta.toast('رُفع ${up['file_name']}');
}
المجمّع يقرأ مدارسه من التطبيق المصغّر — ?entities=
تطبيقك مفتوحٌ في مجمّع، والطلاب في مدارسه. لا تحتاج نقطة خادم: مسارات
البيانات نفسها تجيب عن المدارس التابعة حين تسمّيها، وOkta.getQuery يحمل
المعامل على كل مضيف (بلا رفع minContract). المثال التالي يرسم لوحة المجمّع:
مدارسه بشعاراتها، وعدد طلاب كل منها في نداء واحد. شُغِّل كما هو مكتوب على
محرّك التطبيقات المصغّرة، والأسطر التي تبدو متكلّفة تفرضها المصائد في قسم
«مصائد أثبتتها الأجهزة» أدناه.
// ١) مدارس المجمّع، وهل يحقّ لهذا الشخص قراءتها
final tree = await Okta.getQuery('/api/apps/entity/children', {'descendants': '1'});
if (!tree.ok) return;
final dynamic payload = tree.body;
// `== true` لا قراءةٌ مباشرة: منصّةٌ أقدم لا تُرسل هذا المفتاح،
// و`== true` هي المقارنة التي تقرأ المفتاح الغائب بأمان.
final bool allowed = payload['can_read_children'] == true;
if (!allowed) {
setState(() { _allowed = false; }); // لا تعرض زرّ التنزيل أصلاً
return;
}
final List<String> ids = [];
String joined = '';
final dynamic nodes = payload['data'];
for (final dynamic node in nodes) {
if (node['type'] == 'school' && ids.length < 20) { // حتى ٢٠ في النداء
final String id = '${node['id']}'; // مستوفاة، فيملكها المفسّر (الفخّ ١ب)
ids.add(id);
joined = joined == '' ? id : '$joined,$id';
}
}
// ٢) عدد طلاب كل مدرسة — نداءٌ واحد، ومفتاحٌ لكل مدرسة في الردّ
final res = await Okta.getQuery('/api/apps/education/students', {
'entities': joined,
'per_page': '1', // نريد الإجمالي لا الصفوف
});
if (!res.ok) return;
final dynamic answers = res.body['entities'];
final List<dynamic> rows = [];
for (final String id in ids) {
final dynamic answer = answers[id];
// المدرسة التي أجابت لا مفتاح `error` فيها، والمرفوضة لا `total` فيها.
// والمفتاح الغائب null خام: اسأل `identical` لا `== null` (الفخّ ٧).
final dynamic error = answer['error'];
if (identical(error, null)) {
rows.add({'id': id, 'total': '${answer['total']}', 'error': ''});
} else {
rows.add({'id': id, 'total': '', 'error': '$error'}); // مدرسةٌ رُفضت لا تُسقط الباقي
}
}
setState(() { _schools = rows; });
وفي build، حلقةٌ واحدة على _schools، والشعار مسارٌ يجلبه المضيف
(404 حين لا شعار، فيُرسم فراغاً):
Okta.remoteImage('/api/apps/entity/children/${school['id']}/logo', 32)
ومدرسة واحدة بطلابها وصورهم — ?entity= يعيد ردّها كما هو، فتقرؤه
بالكود نفسه الذي تقرأ به طلاب جهتك:
final one = await Okta.getQuery('/api/apps/education/students', {
'entity': schoolId,
'per_page': '50',
});
// ثم لكل صفّ:
Okta.remoteImage('/api/apps/education/students/${row['id']}/photo?entity=$schoolId', 40)
ثلاثة أشياء تحكم هذا الشكل:
- لا حلقة داخل حلقة. dart_eval لا يترجمها (راجع «مجموعة dart_eval المدعومة»
أدناه)، فلا تمشِ المدارس ثم طلاب كل مدرسة في دالة واحدة. خذ من الغلاف ما لا
يحتاج حلقة ثانية (
total، أوerror)، واقرأ صفوف مدرسة بعينها بـ?entity=حين تُفتح. - المرفوضة مفتاحٌ لا الردّ كلّه — ومفتاحها بحقول أخرى. الجواب يحمل حقول
المسار ولا
errorفيه، والرفض يحملerror(missing_scopeمعscope، أوentity_not_found) ولاtotalفيه. اعرف أيّهما بين يديك بـidentical(answer['error'], null): أمّاanswer['error'] == nullفتقرأ المفتاح الغائب false، وتخزين ما تعيده يقتل التطبيق المصغّر. can_read_childrenقبل الزرّ، مقروءةً بـ== true. شخصٌ لا يحمل الصلاحية يُرفض الطلب كلّه (children_not_readable)، فلا ترسم له ما سيُرفض. ومنصّةٌ أقدم من هذه الميزة لا تُرسل الحقل أصلاً، و== trueتقرأ ذلك «لا» — الجواب نفسه، وللسبب الصحيح.
والقاعدة كاملةً — أيّ منحة تُقرأ بها المدرسة، ومتى يُرفض الطلب — في فصل «الشركات والمجمّعات: شجرة الجهة وبيانات مدارسها».
التحقق محلياً — نفس بوابة الـ CI
cd okta_app/native/main
flutter pub get
flutter test tool/validate.dart
هذا يترجم كودك أمام محرّك الجهاز نفسه، فأي شيء خارج المدعوم يسقط في الـ CI لا على هاتف المستخدم.
ولهذا pub get نفسه قد يرفض قبل أن يصل أي شيء إلى CI. pubspec.yaml
يحمل حدّاً على إصدار Dart (وليس Flutter — راجع تعليق environment: في
الملف نفسه؛ حدّ Flutter لا يفرضه pub على المشروع الجذر، مُختبَراً لا
مفترضاً) لأن okta_miniapp يكتب class $Container implements Container
بيده، وFlutter 3.41 أضاف عضواً (Container.isAntiAlias) الحزمة المنشورة
على pub.dev لا تعرفه. Flutter أحدث من ذلك يرفضه pub get بسطر واحد يسمّي
الإصدار المطلوب — لا وهماً بنجاح ثم انهياراً لاحقاً في الترجمة.
مجموعة dart_eval المدعومة — اقرأ هذا قبل أن تكتب
محرّك الجهاز هو dart_eval + flutter_eval، ويدعم جزءاً من
Dart/Flutter. القائمة أدناه ليست أسلوباً مفضّلاً — كل بند منها يكسر:
-
نداءات المضيف عبر
Okta.*الساكنة فقط — لا دوال مضيف عُلوية. -
لا
State.mounted— غير مجسورة. نادِsetStateمباشرة. -
الـ callbacks تُكتب كإغلاقات لا كإحالات دوال:
onPressed: () => _doThing()لاonPressed: _doThing(خطأ «Cannot box Function»). -
لا تمرّر
cond ? null : closureلفتحة callback. مرّر() => …غير مشروطة واحرس داخل الدالة (if (_busy) return;). -
فهرِس JSON على مستقبِل
dynamicلا على مستقبِل نوعهMap.body['key']يعمل ما دامbodyمن نوعdynamic. إن أعطيت المستقبِل نوعMapالخام — بحارسis Mapيُرقّيه، أو بتعليق نوع على متغيّر أو معامل أو حقل — يتحوّلoperator[]إلى تحليل ساكن، فيربط dart_eval الـMapالخام بتنصيب مجسور اعتباطي (Map<int, Color>— شكل لوحة MaterialColor) ويرفض مفتاحك النصّي وقت الترجمة:Cannot use variable of type String as index to map of type <int, Color>. الـ<int, Color>لا تأتي من بياناتك إطلاقاً — لا تبحث عنها. أبقِ الـ JSONdynamicمن طرف لطرف (final dynamic v = body['key'];)، واحرس على النتيجة (v is List) لا على المستقبِل الذي ستفهرسه. ترقيةis Listسليمة — المشكلة فيMapوحدها. -
لا حلقات متداخلة. يرمي dart_eval 0.8.5 خطأ
RangeErrorوقت الترجمة حين يحوي جسم الحلقة — مباشرةً أو عبر دالة يستدعيها — حلقةً أخرى. سطّح البيانات الهرمية في قائمة واحدة (على الخادم يفضَّل) ثم مُرّ عليها بحلقة واحدة ببانٍ صفوف بلا حلقات. -
الأزرار:
ElevatedButtonوTextButtonفقط —FilledButtonوOutlinedButtonغير مجسورين. -
التخطيط:
RowوColumn(+Expanded).WrapوAlignmentDirectionalغير مجسورين — رتّب الشبكات بـRow+Expanded، واستخدمDirectionalityلاAlign+AlignmentDirectionalللـ RTL. -
مرّر
flex:دائماً لـExpanded/Flexible. الجسر لا يطبّق قيمة المعامل الافتراضية، فالإغفال يصلnullويُحوَّل إلىint—type 'Null' is not a subtype of type 'int'أثناء بناء الودجت. اكتبExpanded(flex: 1, child: …). والقاعدة نفسها لأي معامل اختياري نوعهintغير قابل للعدم في ودجت مجسور. -
BoxDecorationيحملcolorوboxShadowفقط.borderRadiusوgradientوimageوshapeموجودة في الجسر معلَّقة، والمعامل الذي لا يعلنه الجسر يُسقَط بصمت لا يُرفَض: يُترجَم نظيفاً والقيمة لا تصل Flutter أبداً. فـBoxDecoration(borderRadius: …)يرسم زوايا قائمة بلا أي خطأ، وvalidate.dartلا يمسكها لأن شيئاً لم يفشل. دوّر الزوايا بـClipRRect(borderRadius: …, clipBehavior: Clip.antiAlias, child: ColoredBox(…)). -
BoxDecoration(border: …)لا يُترجَم أصلاً. المعامل مُعلَن بنوعBoxBorder، لكن الجسر لا يعلنBorderنوعاً فرعياً منه، والمترجم لا يعرف إلا ما يعلنه الجسر. فـBorder.all(...)يفشل بـCannot assign argument of type Border to parameter of type BoxBorder. ارسم الخط الشعري كصندوق بارتفاع 1:Container(height: 1.0, color: …).الأخيرتان فخٌّ واحد من طرفين: معامل لا يعلنه الجسر يُسقَط بلا كلمة، ومعامل يعلنه يُفحَص نوعه بلا رحمة.
-
لا ثوابت
Icons.*اعتباطية — استخدم نصوصاً بدلها. -
فضّل أشكال
Map/Listالبسيطة على الـ generics العميقة عند عبور الجسر.
متى يصل تعديلك إلى الجهاز — ومتى يبقى القديم
تطبيق أوكتا يترجم المصدر على الجهاز مرة واحدة ثم يخزّنه مؤقتاً. والمفتاح ليس محتوى الحزمة — وهذا هو بيت القصيد. مكوّناته أربعة:
| المكوّن | يتحرّك حين |
|---|---|
slug |
تطبيق آخر |
entry |
حزمة دخول أخرى تحت الـ slug نفسه (انظر «عدّة حزم دخول» أدناه) |
payloadVersion |
يتحرّك updated_at لصفّ المودول — وكل تثبيت يحمل commit جديداً يحرّكه |
runtimeSignature |
تتغيّر مكتبة محقونة في المضيف، أي مع إصدار جديد من تطبيق أوكتا |
على sandbox: ادفع ثم أعد التثبيت — لا ترفع إصداراً
لا تحتاج إصداراً جديداً لكل تعديل. دورة التطوير هي:
- ادفع كودك إلى فرع الإصدار على مستودعك؛
- أعد تثبيت الإصدار نفسه على sandbox.
التثبيت يعيد حلّ commit الفرع في كل مرّة (خطوة commit_resolve)، فيصل
okta-web commit مختلفاً، فيتّسخ صفّ المودول، فيتحرّك updated_at، فيتغيّر
payloadVersion، فيسقط كاش الجهاز ويُعيد الترجمة. وinstall_on_sandbox يقبل
أي إصدار قائم لا الإصدارات القابلة للتحرير وحدها.
ومتى يبقى القديم فعلاً
حين لا يتحرّك أيٌّ من الأربعة. والحالة العملية الوحيدة: أعدت التثبيت بلا أن تدفع — نفس الـ commit، فلا شيء يتّسخ، فلا يُعاد سحب المصدر ولا تُعاد الترجمة. وهذا صحيح لا عطل: لا جديد ليصل.
هذا يُضلِّل أكثر مما يُعطِّل. لا رسالة ولا تحذير — تفتح التطبيق فترى السلوك القديم تماماً فتستنتج أن إصلاحك لم ينفع. فأول ما تتحقّق منه: هل وصل دفعك إلى الفرع الذي يشير إليه الإصدار؟ لا الكود.
الحزمة تُبنى مضغوطةً عند التثبيت — وتُعاد إذا تقادمت
منذ تفعيل الضغط، لا تُبنى الحزمة عند كل إطلاق. okta-web يبنيها مرّة واحدة عند التثبيت لكل نقطة دخول native، يضغطها gzip، ويخزّنها؛ فالإطلاق يقدّم ملفاً بدل أن يمشي شجرتك من جديد. وتطبيق أوكتا ينزّله ويفكّه ويشغّله.
والملف لم يعد مجمَّداً. كان الأرشيف يحمل min_contract وcapabilities
كما كانت لحظة التثبيت ولا يتحرّك حتى تعيد التثبيت — وعاش الشركاء بسببه
اليوم الأسوأ في التصحيح: تنشر تعديلاً، يرتفع رقم النسخة على كل جهاز، ويبقى
الكود القديم يعمل خلفه بمظهر محدَّث تماماً. الآن يقارن okta-web عمر الملف،
عند كل إطلاق، بعمر الشجرة القادر فعلاً على تقديمها: متى هبط كودك الجديد
على المنصة أُعيد البناء مرّة واحدة ثم قُدِّم الجديد. وما دام تنزيل كود النشر
في الطريق (أو فشل)، تبقى الأجهزة تُعرَض عليها النسخة السابقة — رقمها وملفها
معاً — لا رقماً جديداً يلفّ كوداً قديماً.
فما يظهر في البوابة هو ما يصل الأجهزة عند فتحها التالي — بلا إعادة تثبيت يدوية لأجل بيانٍ تحرّك.
دورة عملك المعتادة كما هي: ادفع ثم أعد التثبيت حين تتغيّر الملفات (سحب المصدر ما زال فعل التثبيت)؛ أمّا تعديل البيان وحده فيصل من تلقاء نفسه.
وعلى الإنتاج
الجهات تستقبل ما يُنشَر. فهناك الإصدار الجديد هو الآلية — لا لأن الكاش يعمل بشكل مختلف، بل لأن لا أحد يعيد التثبيت نيابةً عنها.
والنشر خطوتان تحت السطح: المنصة تسجّل إصدارك ثم تنزّل كودك بتوكن عمره ساعة. الأجهزة لا تُخبَر إلا بالشجرة التي هبطت فعلاً — والتنزيل المتعثّر لم يعد يبقى متعثّراً: okta-web يعيد المحاولة تلقائياً بتوكن يُسَكّ من جديد، من أول إطلاق جهاز يصادف العطب ومن جولة مجدولة كل عشر دقائق. فإن قالت البوابة 1.4.0 وما زالت الهواتف تعرض 1.3.0 دقائقَ بعد النشر فهذه أمانة النظام لا ضياع تحديثك؛ وإن طالت، افتح حالة التطبيق — فيها خطأ السحب نصاً.
وبالمقابل: تحديث تطبيق أوكتا نفسه يحرّك
runtimeSignatureفيُعيد الترجمة على كل جهاز بلا أن تنشر أنت شيئاً.
min_contract — أرضية عقد المضيف، لكل نوع حساب
عقد المضيف مُرقَّم. أعلِن الأرضية التي يعتمد عليها كودك، فيعرض تطبيق أوكتا «حدّث التطبيق» بدل تشغيل تطبيق مصغّر لا يفهمه الجهاز — ومنذ صار ردّ الإطلاق يحمل الأرضية، يقولها قبل تنزيل الحزمة لا بعد ترجمتها.
- الأرضية لنوع الحساب لا للتطبيق. كل صفّ في قائمة أنواع الحسابات لسطح
تطبيق أوكتا في محرّر الإصدار له حقل «أدنى عقد مضيف» تحت مدخله (في البيان:
audiences[].minContract). حزمة الموظّفين المكتوبة لمضيف أحدث يجب ألّا تُغلق حزمة وليّ الأمر عن الهواتف التي ما زالت تُترجَم عليها. (وحين تريد حزمةً أحدث لهواتف أحدث بلا أن تُقفل الأقدم إطلاقاً، فذاكversions[]— القسم التالي.) - النوع الذي لا يعلن أرضيته يرث افتراض الكتلة (
mobile.minContract، وهو ما تكتبه أداة MCPset_mobile_surface.min_contract). وما يُصدّره المحرّر فيmobile.minContractمرآةُ أرضية نوعك الأساسي، فبيانٌ لم يضبط يوماً إلا قيمة الكتلة يبقى يعني ما كان يعنيه بالضبط — لا شيء نُشر قبل هذا الحقل تغيّر سلوكه. - أعلن الأرضية التي تحتاجها فعلاً قدرات المضيف التي تناديها. (قاعدة «≥ 13 لأنك أعلنت قدرة جهاز» ذهبت مع البوّابة.)
- ينطبق على
nativeوحده؛ المحرّر يفرّغه في أي وضع آخر. - مسار
.dartواحد = عقد واحد. المدخل نفسه مُعلَناً مرّتين بأرضيتين مختلفتين — جمهور وبطاقة اللوحة، أو مكان شاشة — يُبنى بالإعلان الأول ويُسجَّل تحذير عند النشر؛ لا يُرفَض. أعطِ كل أرضية حزمتها. (ويُرفَض فعلاً حين يكون أحد الطرفين سطراً إضافياً — القسم التالي.) - والأرضية اختيارية، والفراغ جوابٌ حقيقي: اتركها فارغةً فيعمل المدخل على
كل الأجهزة بما فيها الأقدم ما لم يستوفِ الجهاز سطراً مقيَّداً بعقد أعلى — الخادم يجرّب الأسطر أولاً ثم يسقط إلى الافتراضي. okta-web يحلّ الأرضية الغائبة
الخاصة ← الكتلة ← 1، والواحد دون كل مضيف شُحن يوماً. وهو ما يفعله كل تطبيق منشور قبل أرضيات الأنواع، فلا شيء يلزمك بالرقم حتى تحتاجه. لكنها — مكتوبةً أو محلولةً — أرضيةٌ لا يجوز أن يكرّرها سطر إضافي في النوع نفسه: فالافتراضي بلا أرضية يطالب بالعقد 1، وسطرٌ عند 1 جوابٌ ثانٍ لسؤال واحد. القسم التالي. - تحت الحقل يعرض المحرّر أيّ إصدارات okta-app تشغّل أيّ عقد
(
1.1.1 → 26 · …)، مرآةً للجدول الذي يديره فريق أوكتا على okta-web (partners:sync-okta-app-releases، كل ساعة). اختر الأرضية التي يحتاجها كودك، ثم اقرأ هناك أيّ هواتف تبلغها. - MCP:
set_account_typesتقبلmin_contractلكل صفّ (صفوف native ذات مدخل فقط؛ وما دون 1 يُرفَض بالاسم)؛get_account_typesوget_mobile_surfaceتطبعان أرضية كل نوع وافتراض الكتلة والمرآة المُصدَّرة؛validate_manifestترفضminContractالمشوَّه وتحذّر من مسار مُعلَن بعقدين.
مداخل إضافية لنوع الحساب — audiences[].versions[]
الأرضية تقول لا تُشغّل هذا المدخل تحت مضيف كذا، ولا تقول أبداً ماذا يُشغَّل بدلاً منه — فحزمةٌ كُتبت لمضيف أحدث كانت تترك أمامك خيارين: أن تُقفل نوع الحساب عن الهواتف الأقدم، أو تبقى على الحزمة القديمة إلى الأبد. السطر الإضافي هو الثالث: أكثر من مدخل لنوع الحساب الواحد، والخادم يختار.
وكل سطر مقيَّد بمحور واحد، تختاره أنت:
| المحور | يقول | متى |
|---|---|---|
minContract |
«البناء الذي يُشغّل المضيف 28 أو أحدث يأخذ هذا» | السؤال الدقيق — العقد هو ما يقرّر فعلاً هل تُترجَم الحزمة على الجهاز |
minAppVersion |
«البناء عند 1.2.0 أو أحدث يأخذ هذا» | للبناءات التي لا تستطيع الإجابة عن السؤال الدقيق: كل okta-app شُحن قبل ترويسة X-App-Contract يبلّغ إصداره ولا يبلّغ عقده |
وسطرٌ بلا محور يُرفَض: يسمّي مدخلاً ولا يقول من يأخذه، فلا يوجد هاتف يستطيع الخادم أن يعطيه إيّاه.
"audiences": [
{ "key": "teacher", "mode": "native",
"entry": "okta_app/native/main/lib/main.dart",
"minContract": 20,
"versions": [
{ "minContract": 28,
"entry": "okta_app/native/main_v3/lib/main.dart" },
{ "minAppVersion": "1.2.0",
"entry": "okta_app/native/main_v2/lib/main.dart" }
] }
]
كيف يختار okta-web، بالترتيب. عقد الهاتف من ترويسة X-App-Contract، أو
يُستنتَج من جدول الإصدارات حين لا يبلّغ إلا إصداره. ثم:
- أسطر العقد (الأعلى أرضيةً أولاً): أوّل أرضية يبلغها عقد الهاتف. وعقدٌ مجهول لا يبلغ شيئاً هنا — السطر يسمّي مضيفاً، و«على الأرجح» ليس مضيفاً.
- أسطر الإصدار (الأحدث حدّاً أولاً): أوّل حدٍّ يستوفيه إصدار الهاتف (وعقده إن كان معلوماً يكفي أرضية السطر).
- المدخل الافتراضي
entryمهما كان عقده — بوّابة الجهاز تعرض «حدِّث التطبيق» كما اليوم.
فهاتفٌ لا يبلّغ عقداً يُقرأ ولا إصداراً يستوفيه يأخذ الافتراضي دائماً. وهذا هو وعد التوافق كلّه: أضِف سطراً ولا يتغيّر شيء ممّا يعمل — كل بناء okta-app أقدم من الترويستين يبقى يستلم ما يستلمه اليوم بالضبط.
العقد الواحد يسمّي مدخلاً واحداً داخل نوع الحساب — وأرضية المدخل الافتراضي
minContract واحدةٌ منها. سطرٌ يجلس على أرضية الافتراضي كان سيغلب كل هاتف
يبلّغ عقده ويخسر كل هاتف لا يبلّغه، وهو جوابٌ لا يقصده أحد. المحرّر وأدوات MCP
والنشر ترفضه بالاسم.
وأرضية المدخل الافتراضي اختيارية: اتركها فارغةً ويعمل المدخل على كل
الأجهزة ما لم يستوفِ الجهاز سطراً مقيَّداً بعقد أعلى — الخادم يجرّب الأسطر أولاً ثم يسقط إلى الافتراضي. وهو الأمر الذي كان دائماً، لأن okta-web يحلّ الغياب الخاصة ← الكتلة ← 1. اكتبها حين تكون حزمتك فعلاً بحاجة إلى مضيف أحدث، لا لأن الحقل يطلبها.
وانتبه إلى أن الافتراضي بلا أرضية يطالب بالعقد 1 على الخادم، فسطرٌ
مقيَّد عند 1 يُرفض بوصفه جواباً ثانياً لنفس السؤال.
وأرضية سطر الإصدار اختيارية: أهملها فيرث أرضية النوع — وهو ما يعطيه إيّاه okta-web أصلاً. اكتبها حين تكون الحزمة بحاجة إلى مضيف أحدث ممّا يحتاجه الافتراضي، ويملأها المحرّر من جدول الإصدارات حين يعرف عقد ذلك الإصدار.
ويجوز أن تترك entry فارغاً حين تُعلن أسطراً، ومعناها «هذا النوع يُخدَم
على تلك البناءات وحدها». قُلها عن قصد: النوع حينها يختفي عن كل هاتف لا يستوفي
أحد الحدود، وعن كل هاتف لا يبلّغ عقده ولا إصداره. المحرّر يحذّر بجانب
القائمة، وردّ أداة MCP يقولها نصّاً، والمراجع يراها شارةً حمراء.
الحدّ الواحد يسمّي مدخلاً واحداً على محور الإصدار كذلك. 1.02.0 و1.2.0
إصدارٌ واحد، وokta-web يثبّت الإملاء المُقنَّن في رابط الإطلاق الموقَّع — فإعلانهما
معاً يُرفَض بدل أن يُترَك صفّاً لا يستطيع أيّ إطلاق أن يبلغه.
ومسار .dart واحد = عقد واحد — وهنا خطأ لا التحذير الذي يناله التداخل
القديم: سطرٌ إضافي يسمّي مساراً يبنيه تصريح آخر بأرضية مختلفة رقمٌ ثانٍ لا يصل
جهازاً أبداً. أعطِ كل أرضية حزمتها.
عشرة أسطر بحدٍّ أقصى. القائمة سجلّ مداخل لا جدول — احذف الحدود التي لم يعد يعمل عليها أحد.
والمحاكي لا يحاكي هذا. تبويب المتصفّح بلا إصدار تطبيق ولا عقد يقارن بهما، فهو يسرد مداخلك الإضافية ويركّب الافتراضي. جرّب الحزمة الإضافية على جهاز يحمل ذلك المضيف.
MCP: set_account_types تقبل versions لكل صفّ (استبدال كامل مع بقيّة النوع،
كالمدخل الافتراضي)؛ وget_account_types وget_mobile_surface تطبعان كل سطر
بالمحور الذي قُيِّد به وتسمّيان النوع بلا افتراضي؛ وvalidate_manifest ترفض
سطراً بلا محور، وحدّاً مشوَّهاً، وحدّاً أو عقداً مكرَّراً، ومساراً إضافياً يصطدم
بعقد تصريح آخر.
والمسار المعتاد أن تكتب الأسطر في manifest.json ثم تستدعي
sync_from_manifest — تعبر إلى الإصدار كما يعبر المدخل الافتراضي. وما دامت
المنصّة تُدرّج الشكل على بيئتَي okta-web، تُرفَض الأسطر بالاسم عند الكتابة
وتقول الأدوات ذلك: رفضٌ يخصّ المنصّة لا بيانك.
الـ pubspec.yaml — لا ترفع الـ ref
okta_miniapp:
git:
url: https://github.com/TahdirIT/okta-miniapp.git
ref: __MINIAPP_REF__ ← تملؤه المنصة عند إنشاء المستودع
الـ ref مثبَّت على المحرّك الذي بُني به تطبيق أوكتا المنشور بالضبط، فما يُترجَم عندك يُترجَم مطابقاً على الجهاز. رفعُه يدوياً يكسر هذا الضمان.
شكل الـ manifest
"mobile": {
"supported": true,
"mode": "native",
"runtime": "dart",
"entry": "okta_app/native/main/lib/main.dart",
"minContract": 12,
"audiences": [
{ "key": "teacher", "mode": "native",
"entry": "okta_app/native/main/lib/main.dart", "minContract": 12 }
]
}
runtime: "dart" تُضاف تلقائياً في وضع native — لا تكتبها بنفسك. وminContract
على الكتلة مرآةُ النوع الأساسي؛ القيمة التي تُحتسَب هي التي على كل جمهور، والصفّ
الذي لا يحملها يرث قيمة الكتلة.
عدّة حزم دخول تحت slug واحد
<entry> في okta_app/native/<entry>/ هو اسم حزمة Dart قائمة بذاتها، ولا
شيء يُلزمك بواحدة. جمهوران مختلفان يجوز أن يشيرا إلى حزمتين مختلفتين:
"audiences": [
{ "key": "staff", "mode": "native",
"entry": "okta_app/native/staff_app/lib/main.dart", "minContract": 26 },
{ "key": "family", "mode": "native", "portal": "guardian",
"entry": "okta_app/native/family_app/lib/main.dart", "minContract": 20 }
]
تُجمَع كل حزمة وتُخزَّن على حدة. حزمة المصدر تحمل شجرة lib/ للمدخل
المطلوب وحده — لا تُضمّ الحزم الشقيقة إليها إطلاقاً — ومفتاح الكاش يحمل
الـ entry بجانب الـ slug لهذا السبب بالذات. (لو لم يحمله لتصادمت الحزمتان:
الـ slug واحد وpayloadVersion واحد، فمن يُطلَق أولاً يكسب المدخل ويُشغَّل
الآخر ببايتكود الأول ويموت بـ Cannot find package:….)
فإن كانت واجهة الموظّف وواجهة وليّ الأمر مختلفتين جوهرياً، افصلهما حزمتين —
أنظف من if كبير، وكل واحدة تُترجَم وتُخزَّن وحدها.
ولكل حزمة أرضيتها كذلك: في المثال أعلاه تحتاج حزمة الموظّفين العقد 26 بينما ما زالت حزمة الأسرة تعمل على 20، فوليّ أمر هاتفه على 20 يبقى يفتح شاشة الأسرة بينما تعرض شاشة الموظّفين «حدّث التطبيق» — تطبيق واحد بأرضيتين، ولا هاتف يُغلَق عنه بسبب حزمة لا يشغّلها أصلاً.
حين لا يفتح التطبيق أصلاً — عائلة أخطاء الحمولة
البطاقة تظهر، تنقر، فيفشل التحميل قبل أن يعمل أي من كودك. هذا ليس خطأً في تطبيقك المصغّر: نقطة الإطلاق نجحت (فالتثبيت والنطاق والجمهور سليمة) ثم فشل تنزيل حزمة المصدر.
يُفرَض شكل الـ entry ثلاث مرّات لا مرّتين: عند الحفظ في محرّر الإصدار،
وعند النشر في okta-web، ومرّة ثالثة عند تقديم الحزمة — وهذه الثالثة هي
التي تراها كفشل تحميل. أسبابها الممكنة كلّها من جهة الخادم:
| السبب | ماذا يعني |
|---|---|
| المودول غير منشور ككود على هذه النسخة | الصفّ في قاعدة البيانات موجود والشيفرة ليست على القرص |
okta_app/native/<entry>/lib غير موجودة في المودول المنشور |
نُشر المودول بلا شجرة native |
ملف الدخول غير موجود تحت تلك الـ lib/ |
الـ entry يشير إلى ملف لا وجود له |
الـ entry لا يطابق okta_app/native/<entry>/lib/**.dart |
إصدار قديم موروث من قبل بوّابة الشكل |
| الحزمة > 10 ميغابايت، أو > 128 ملفاً | تجاوزت حدود التجميع |
لا حدّ لحجم الملف الواحد. كان 256 كيلوبايت وأُلغي: الترجمة والنقل لا يريان إلا الإجمالي، فتقسيم ملف لم يكن يخدم شيئاً. والحدّ الباقي هو 10 ميغابايت للمشروع كاملاً و128 ملفاً، ورسالة التجاوز تسمّي الملف الذي كان يُقرأ حين نفدت الميزانية.
حدّ الـ10 ميغابايت يُصاب عادةً ببيانات لا بمنطق. مشروع Dart يبلغ هذا الحجم شبه دائماً يحمل ملفات مولَّدة: صور بترميز base64، أو خرائط ثوابت ضخمة، أو ترجمات مولَّدة. أخرج البيانات من Dart إلى نقطة نهاية تقرأها بـ
Okta.get— فما يصل من الشبكة لا يُترجَم على الجهاز، بينما كل بايت في حزمتك يُترجَم. وهذا ليس عن النقل: الحزمة تُنقل مضغوطة، لكن الترجمة على الجهاز تقع على المصدر كاملاً، وتقع على الـ isolate الذي يرسم الواجهة.
ما تفعله: أبلغ فريق المنصّة بـ slug التطبيق ووقت المحاولة. الخادم يسجّل
سبب الرفض بالضبط، والتطبيق يعرضه نصّاً على الشاشة في البيئات غير الإنتاجية —
فالإجابة موجودة عند أحد الطرفين ولا تحتاج تخميناً. وأول ما تتحقّق منه أنت: أن
مسار entry في بيانك يطابق مساراً موجوداً فعلاً في مستودعك، حرفاً بحرف.
صلاحيات موارد الجهاز — مُزالة، تُبنى من جديد
كان هنا محور صلاحيات ثانٍ: mobile.capabilities[] في البيان، ومفردات مغلقة
بإحدى عشرة قدرة، وفحص عند النشر، وبوّابة داخل تطبيق أوكتا ترفض أي نداء لم
يُعلنه التطبيق ولم يوافق عليه صاحب الجهاز. أُزيل هذا كلّه — المفردات
والبوّابة وسجلّ الموافقة وشاشة الأذونات ومرشّح قنوات القارئات — ويُعاد تصميمه
من الصفر بدل ترقيعه.
ما يعنيه ذلك لك الآن:
- لا تُعلن
mobile.capabilitiesفي بيان جديد. المفتاح ما زال مقبولاً كي لا يفشل نشرٌ تالٍ لأي تطبيق منشور، لكن لا أحد يقرؤه: لا يُطبَّع، ولا يُحمل إلى الجهاز، ولا يظهر في شاشة التثبيت. - كل نداء عتاد يعمل مباشرة.
Okta.scanNfc()وOkta.scanBarcode()وOkta.toolKeyStart()وOkta.location()وOkta.preciseLocation()وOkta.uploadFile()وOkta.openDocument()والتخزين — بلا إعلان وبلا أي سؤال من المنصّة. - نظام التشغيل يبقى يسأل. أندرويد و iOS يعرضان نافذتهما عند أول استخدام للكاميرا أو الموقع أو NFC، ويديرها صاحب الجهاز من إعدادات جهازه. هذه لم تكن يوماً من شأن هذه البوّابة.
Okta.hasCapability()وOkta.requestCapability()ما زالتا تُترجمان وتُجيبانtrueدائماً. أُبقيتا لأنهما سطح ترجمة — تطبيق منشور يناديهما كان سيفشل في الترجمة على الجهاز لو اختفى الرمز — فحراسك القديمة تعمل وتأخذ فرع «مسموح» دائماً. لا تكتب حراساً جديدة عليهما؛ البديل سيعيد استعمال الاسمين نفسيهما.- لم تعد هناك قاعدة «
minContract≥ 13». أعلنminContractلما تعتمد عليه فعلاً من قدرات المضيف، لا أكثر. - الرفض ما زال ليس استثناءً. قد يُجيب نداء العتاد
nullأوfalseأو دقّة سالبة — عتاد غائب، أو نافذة نظام رفضها المستخدم، أو قارئ لم يُقترن. تابع فحص القيم المُعادة؛ هذا الانضباط لم يكن يوماً معتمداً على البوّابة.
ولهذا انتقل عقد المضيف إلى 19. جهاز ما زال على 18 أو أقل يفرض البوّابة القديمة، وتطبيق كُتب للعالم غير المحروس يُرفض هناك بصمت —
scanNfcتُجيب null بلا أثر في أي سجل — لا بخطأ ترجمة يمكن رؤيته.
تبويبك في ملف الطالب (mobile.student_profile_tabs) — وضع native فقط
وليّ الأمر يفتح تطبيق أوكتا، فيرى أبناءه، فيضغط على ابنٍ منهم — وهنا تصير الشاشة ملف الطالب. هذا التصريح يعطيك تبويباً خاصاً بك داخل تلك الشاشة: أنت تملك جسده كاملاً، معلوماتك وعملياتك وتصميمك، لا بطاقة بقالبٍ تملأه.
"mobile": {
"supported": true,
"mode": "native",
"minContract": 17,
"student_profile_tabs": [
{
"key": "attendance",
"title": { "ar": "الحضور", "en": "Attendance" },
"entry": "okta_app/native/student_tab/lib/main.dart",
"portals": ["guardian"],
"order": 10
}
]
}
ليس هذا بلوك studentProfile. ذاك يضيف بطاقاتٍ وأرقاماً وأزراراً إلى
صفحة ملف الطالب في okta-web، يقرأها الموظّف، ويُكتشَف من أصناف
Livewire في مستودعك. وهذا تطبيق مصغّر native يُركَّب في هاتف وليّ
الأمر. جمهوران، ومضيفان، وزمنا تشغيل — فلا يجمعهما مصفوفة واحدة. اقرأ
توسيع ملف الطالب لذاك.
وليس نوع حساب. mobile.audiences[] تجيب مَن يفتح تطبيقك، وهذه تجيب
أين يُركَّب سطحك. لو جُمعا لصار «guardian» يعني شاشتين مختلفتين بحسب حقل
مجاور.
الحقول
| الحقل | مطلوب | الملاحظة |
|---|---|---|
key |
^[a-z][a-z0-9_-]{0,63}$. هويّة لا عنوان: بها يتذكّر النظام آخر تبويب فتحه وليّ الأمر، فتغييرها ينسى اختياره. |
|
title |
{ar, en} — والعربي إلزامي. نصّ مفرد يُقرأ عربياً اختصاراً. حتى 60 محرفاً. |
|
entry |
okta_app/native/<package>/lib/<file>.dart. يجوز أن يكون نفس ملف بطاقة بوّابتك، أو حزمة مستقلّة. |
|
portals |
["guardian"] افتراضاً. "student" هي الشاشة نفسها يفتحها الطالب على نفسه. |
|
order |
ترتيبك بين تبويباتك أنت. موضعك بين تبويبات التطبيقات الأخرى تقرّره المنصّة، لا أنت. | |
icon |
حتى 64 محرفاً. |
أربعة شروط، كلٌّ منها يُسقِط التبويب كاملاً
nativeوحده. التبويب يصله معرّف الطالب. ولو كانwebviewأوexternalلوجب إرسال ذلك المعرّف إلى صفحة يستضيفها الشريك — أي نقل معرّف طفل إلى خادمك في كل مرة تُفتح فيها الشاشة، خارج نداءاتOkta.*التي تتحقّق okta-web من كلٍّ منها على حدة. الإعلان تحت أي وضع آخر يُسقَط.minContract≥ 17. نقطة التركيب لم توجد قبل هذا العقد. الجهاز الأقدم لا يخطئ ولا يشتكي — يعرض ملف الطالب وتبويبك غير موجود، ولا شيء في أي سجلّ يقول لماذا.- النطاق
education.students.read. التبويب لا يصله إلا معرّف الطالب، وبلا هذا النطاق لا يستطيع تحويله إلى طالب يعرفه — فيُركَّب تبويب لا يعرض شيئاً. - ثلاثة تبويبات كحدٍّ أقصى. الشريط مشترك: كل تطبيق مثبَّت لدى الجهة يضع تبويبه في الصفّ نفسه، أمام أبٍ جاء ليرى ابنه. من يأخذ ثمانية مقاعد لا يوسّع الملف بل يستولي عليه، ويدفع الثمن وليُّ الأمر تمريراً أفقياً.
ماذا يصلك عند التركيب — وكيف تقرأه
يُركَّب تبويبك على طالب واحد، ويصلك hashid الطالب لا رقمه. وهذه ليست تفصيلة تجميلية: الرقم المتسلسل يُخمَّن، فيمشي تطبيقٌ على كشف المدرسة بزيادة واحد، والـ hashid لا.
نداءان جديدان في العقد 17:
// الطالب الذي فُتح عليه التبويب. دالة لا خاصيّة.
final studentHashid = Okta.studentHashid();
// مَن يقرأ: 'guardian' أو 'student'. الشاشة واحدة والقارئان اثنان.
final portal = Okta.portal();
كلاهما يعيد '' لا null حين لا يكون السطح تبويباً في ملف الطالب — وهذه
الحالة العادية في كل سطح آخر لتطبيقك. و'' ليست خطأ، بل تعني «هذا ليس تركيباً
على طالب». لا تسقط إلى «المستخدم الحالي» عندها: في سياق وليّ الأمر لا يوجد
طالب في الجلسة تسقط إليه، والتطبيق الذي يخمّن واحداً يعرض لأبٍ ابنَ عائلة أخرى.
لماذا
''لاnull؟ لأن'$value'على قيمة معدومة تُنتج المحارف الأربعةnull— نصّاً سليم الشكل تماماً يمشي إلى داخل مسار API بلا أن يوقفه شيء. السلسلة الفارغة يمكن فحصها، و'null'لا يمكن.
التفويض ليس مسؤوليتك ولا تستطيع تجاوزه. okta-web يعيد الفحص على كل نداء (وليُّ أمر مرتبط، أو الطالب نفسه، أو مفوَّض بتفويضٍ ساري) — فتبويب يحاول قراءة طالب آخر يتلقّى 403 لا بيانات. لا تبنِ تحقّقك أنت، ولا تخزّن الـ hashid لتقرأه لاحقاً في سياق آخر.
مثال كامل يعمل
okta_app/native/student_tab/lib/main.dart — تبويب حضور يعرض غيابات الطالب:
import 'package:flutter/material.dart';
import 'package:okta_host/okta_host.dart';
Widget main() => const AbsencesTab();
class AbsencesTab extends StatefulWidget {
const AbsencesTab({Key? key}) : super(key: key);
@override
State<AbsencesTab> createState() => _AbsencesTabState();
}
class _AbsencesTabState extends State<AbsencesTab> {
bool loading = true;
String error = '';
List<dynamic> rows = <dynamic>[];
@override
void initState() {
super.initState();
load();
}
Future<void> load() async {
final sid = Okta.studentHashid();
// «ليس تركيباً على طالب» حالة مشروعة لا خطأ — اعرض ما يفهمه القارئ
// بدل استدعاء API بمعرّف فارغ.
if (sid.isEmpty) {
setState(() {
loading = false;
error = 'افتح هذا التبويب من ملف أحد أبنائك.';
});
return;
}
// مسار تطبيقك أنت. okta-web يتحقّق من حقّ هذا القارئ في هذا الطالب
// قبل أن يصل النداء إلى كودك.
final res = await Okta.get('/api/my-app/absences?student=' + sid);
setState(() {
loading = false;
if (res.ok) {
rows = res.body['data'];
} else {
error = 'تعذّر تحميل السجلّ.';
}
});
}
@override
Widget build(BuildContext context) {
if (loading) {
return const Center(child: CircularProgressIndicator());
}
if (error != '') {
return Center(child: Text(error));
}
if (rows.isEmpty) {
// «لا غياب» خبر سارّ لوليّ الأمر — قُلْه، ولا تعرض جدولاً فارغاً.
return const Center(child: Text('لا غياب هذا الفصل. 🎉'));
}
// «ابنك» لوليّ الأمر و«أنت» للطالب: الشاشة واحدة والقارئان اثنان.
final who = Okta.portal() == 'guardian' ? 'ابنك' : 'أنت';
return ListView(
children: [
Padding(
padding: const EdgeInsets.all(12.0),
child: Text('$who تغيّب ${rows.length} مرة هذا الفصل'),
),
for (final row in rows)
ListTile(
title: Text('${row['date']}'),
subtitle: Text('${row['reason']}'),
),
],
);
}
}
ثلاث ملاحظات في هذا المثال تحديداً:
main()يعيد Widget كأي تطبيق مصغّر — التبويب ليس نوعاً جديداً من البرامج، بل السطح نفسه بنقطة تركيب مختلفة. فكل ما تعرفه عنnativeيسري هنا: مجموعة dart_eval المدعومة، والحدود، والمصائد أدناه.- حالة الفراغ ليست خطأً — من فتح التبويب من مكان غير ملف طالب يستحق جملة تشرح، لا دوّامة تدور إلى الأبد ولا نداء API بمعرّف فارغ.
- لا تعرض جدولاً فارغاً لوليّ أمر. «لا غياب هذا الفصل» هو المعلومة، وهو ما جاء يبحث عنه.
نطاقك أنت، وبيانات المنصّة
education.students.read شرط لأن التبويب يصله معرّف وحسب. إن أردت أكثر من
تحويل المعرّف إلى طالب — الصفّ، الشعبة، الجدول — اطلب النطاقات المقابلة كما في
نظام النطاقات. وأمّا بياناتك أنت فتقرؤها من مسار
تطبيقك (/api/<slug>/…) بلا نطاق إضافي، وهي عادةً معظم ما يعرضه التبويب.
مصائد
- تبويب يُسقَط لا يترك أثراً. أي شرط من الأربعة أعلاه يُسقِط الصفّ عند
البناء: الـ manifest يُنشَر، والتطبيق يُثبَّت، والتبويب ببساطة غير موجود على
هاتف الأب. لذلك محرّر الإصدار وفاحص
validate_manifestيرفضان الصفّ بخطأ لا بتحذير — الرفض حيث تقرأه أرحم من غياب لا تراه. - الفراغ أسوأ من الغياب. تبويب بعنوان جذّاب لا يعرض شيئاً حتى تُثبَّت بيانات، أفضل ألا يُعلَن. أعلِنه حين يكون له ما يقوله.
- العنوان لجمهور واحد فقط: وليّ الأمر. لا تكتب فيه اسم تطبيقك ولا مصطلحاً داخلياً؛ اسم تطبيقك ظاهر بجانب التبويب أصلاً.
مصائد أثبتتها الأجهزة — اقرأها قبل أن تشحن
كل بند هنا أسقط تطبيقاً حقيقياً على جهاز حقيقي، ولا يمسكه flutter analyze ولا
tool/validate.dart. الترجمة تنجح، ثم يموت المفسّر عند المستخدم.
١. Okta.* نداء جسر، لا قراءة حقل — خزّنه مرة واحدة.
كل نداء يذهب للمضيف ويعود بسلسلة JSON تُفكّ من جديد. في كل مرة. تطبيق حضور
كتب:
bool get _ar => Okta.locale().startsWith('ar'); // getter، ١٨٨ نداءً للبناء الواحد
String _t(String ar, String en) => _ar ? ar : en;
اقرأ الهوية مرة واحدة في initState واحفظها في حقول:
late final bool _ar;
@override
void initState() {
super.initState();
_ar = OktaText.isRtl(Okta.locale());
}
لا يمكن أن تتغيّر دون أن يعيد المضيف بناء التطبيق المصغّر أصلاً. والقاعدة أوسع
من الهوية: عامِل كل Okta.* على أنه شبكة، لا خاصية.
١ب. القيمة الآتية من الخارج ليست قيمة مفسِّر — والرسالة تشير للوسيط لا
للمستقبِل. نداء دالة مجسورة (startsWith، toLowerCase، trim،
contains، substring، وحتى toString) على قيمة وصلت من خارج المفسّر يموت
بـ:
type 'String' is not a subtype of type '$Value?' in type cast
#1 $String._startsWith
args[0] سليم تماماً — العطب في المستقبِل، فلا تطارد الوسيط الذي تسمّيه
الرسالة. Okta.* عولجت من داخلها فلم تعد تُخرِج شيئاً بهذا الشكل، لكن
قارئ JSON الذي تكتبه أنت يفعل:
String _text(dynamic map, String key) {
return map[key] as String; // الكسر يمرّ، والشكل يبقى خاطئاً
}
String _text(dynamic map, String key) {
final dynamic value = map[key];
return '$value'; // الاستيفاء يجعل المفسّر يخصّص السلسلة
}
الإصلاح عند المنبع لا عند مواضع الاستدعاء، وينطبق على فروع int/double
كذلك (value.toString() فيه العيب نفسه ويظهر أندر). وللمنطقي استعمل
== true لا as bool.
٢. لا تُعشّش الثلاثيّات — في وسيط أو في إسناد، سيّان.
final way = a ? (x ? '…' : '…') : (b ? '…' : '…'); // يصل null
المترجم يحجز خانة نتيجة الشرط الخارجي، والداخلي يكتب في خانة أخرى:
*L11: null, L12: null
8484: BoxString (L12) <<< EXCEPTION
فرّع وأعِد (if … return). ثلاثيّة واحدة آمنة. ونوع الخطأ في الرسالة
(String أو int) هو ما أنتجه التعبير فحسب — لا تقرأه كتضييق للقاعدة.
٣. دالة خاصة بلا مستدعٍ تُرسِب بوّابتك.
unused_element تحذير، وflutter analyze جزء من partner-module-policy. إن
حذفت زرّاً فاحذف معالجه معه.
٤. نداء العتاد ما زال قد يرفض — افحص ما يعيده.
بوّابة القدرات في المنصّة ذهبت، فلا شيء يُحجَب لغياب إعلان. الباقي هو الواقع:
عتاد غير موجود، ونافذة إذن من نظام التشغيل رفضها المستخدم، وقارئ لم يُقترن.
كل نداء يُجيب بشكل رفضه الموثَّق — scanNfc ← null، وstorePut /
openDocument / toolKeyStart ← false، وlocation ← دقّة سالبة — ولا يرمي
أيٌّ منها. تجاهُل القيمة المُعادة يعني زرّاً لا يفعل شيئاً بصمت.
٥. ref القديم يجعل بوّابتك تفحص شيئاً لا يملكه الجهاز.
okta_miniapp المثبَّت في okta_app/native/<entry>/pubspec.yaml لا يقرّر
ما يُترجَم عليه الجهاز — المضيف يحقن okta_host/okta_kit/okta_motion من
ثنائيّته. ما يقرّره هو ما تفحصه flutter test tool/validate.dart. فمرجع متأخّر
عن المضيف = بوّابة تصادق على مصدر غير موجود، والعطب يظهر عند المستخدم:
CompileError: Cannot find static method OktaTabs.bottomBar
رمزٌ يعرفه المضيف ولا تعرفه نسختك — أو العكس. المنصّة تدير هذه القيمة؛ إن رأيت فشل ترجمة على الجهاز وبوّابتك خضراء، فأول ما تسأل عنه هو تطابق الـ ref مع المضيف المنشور.
٦. الفشل الصامت أسوأ من الفشل. الالتقاط المحلي يؤكَّد قبل استشارة الشبكة (وهذا صحيح — به ينجو المسح من انقطاع)، لكن معناه أن رفعاً لم يصل يبدو على الشاشة مطابقاً تماماً لرفع وصل. قل ذلك صراحةً: صوت، ورسالة، وسطر في السجل. من يقف على بوابة ينظر إلى الطالب لا إلى شارة في الترويسة.
٧. is ليست فحصاً آمناً على قيمة مجسورة — والمفتاح الغائب أسوأ حالاتها.
أخطر فخّ في هذا المحرّك. جسر Map في dart_eval يعيد map[key] مباشرةً، وnull الناتج عن مفتاح
غائب هو null دارت خام لا $null المفسّر. فـvalue is String لا تعيد
false — بل ترمي:
type 'Null' is not a subtype of type '$Value' in type cast
ولا حيلة دفاعية: is ترمي، و== null أسوأ — تعطي false فتمرّر القيمة
للنداء التالي فيموت هناك بدلاً منها.
والقاعدة أوسع من المفتاح الغائب: على قيمة مجسورة، is المطابقة تجيب وغير
المطابقة ترمي. المفتاح موجود، وقيمته سليمة، ومع ذلك ترمي — رصده فريق
okta-hr على جهاز حقيقي على accuracy القادمة من Okta.location():
L23: $"accuracy" L24: 16.950000762939453
5436: Unbox (L24)
5437: NumLtEq (L31 <= L24) ← `is double` طابقت ونفّذت المقارنة
5441: IsType (L24 is 12) <<< EXCEPTION
type 'double' is not a subtype of type '$Value' in type cast
is double نجحت، ثم رمت is int على القيمة نفسها بعدها بأربع تعليمات.
فأي سلسلة is متتابعة على قيمة واحدة قنبلة موقوتة: أوّلُ اختبار لا يطابق
يرمي بدل أن يجيب false. والنمط شائع جداً في قراءة JSON (is int ثم
is double ثم is String)، وهو يعمل تماماً حتى تصل قيمة نوعها ليس الأول في
سلسلتك.
والفحص الوحيد الذي يجيب عن الغياب هو identical(value, null) — true
للمفتاح الغائب وللـnull الصريح، وfalse لكل شيء حاضر، مقيساً على المحرّك.
اسأله أولاً، ثم اقرأ القيمة:
final dynamic value = map['accuracy'];
if (identical(value, null)) return; // غائب أو null — الفحص الآمن الوحيد
final accuracy = double.tryParse('$value') ?? 0.0; // حاضر: استوفِه ثم حلّله
وحين يكون المفتاح حاضراً، فالقراءة الآمنة هي الاستيفاء — '$value' — وهو
نفسه إصلاح الفخّ ١ب أعلاه، هناك للتعليب وهنا للفحص: السلسلة الناتجة يخصّصها
المفسّر، فكل نداء عليها آمن، وتجيب عن النوع بلا اختبار is واحد. لكنها لا
تنجو من الغياب: استيفاء مفتاح غائب ينادي toString على الـnull الخام فيرمي
الخطأ نفسه type 'Null' is not a subtype of type '$Instance'. نسخةٌ سابقة من
هذا الدليل أظهرت '${map['accuracy']}' تجيب "null" عند الغياب — وهي لا
تفعل؛ فإن نسختَ ذلك السطر لمفتاح قد يغيب، فضع فحص identical قبله.
ولا تستعمل is إلا حين تكون السلسلة المستوفاة قد أكّدت لك أنها ستطابق.
ولا حارس containsKey كذلك. جُرِّب على الجهاز ورُدَّ: تعليبه لا يطابق
تعليب operator[] (وهو معامل مُعالَج معالجةً خاصّة)، فصار كل قراءة محروسة
تسقط إلى بديلها — أي أنه يجيب «غير موجود» عن مفاتيح موجودة. لا تكتبه.
فالدفاع عند المنبع: اجعل نقطتك تُصدِر كل حقل يقرؤه تطبيقك دائماً،
بقيمة null حين لا قيمة له. مفتاح موجود قيمته null آمن تماماً —
jsonDecode يلفّ تعاودياً فيصل $null ويتصرّف طبيعياً — والغياب وحده قاتل.
والنقطة نقطتك، فالضمانة في يدك.
وOktaJson يحميك من الشكل لا من الغياب: at تستبعد جسماً ليس كائناً
(null، قائمة، نصّ، رقم، منطقي) فتنجو من ردٍّ غير متوقَّع، وstrOr/number/
list تتسامح مع النوع الخطأ. أمّا مفتاح غائب فيمرّ منها كما يمرّ من قراءتك
المباشرة. الاستثناء OktaJson.flag — تستعمل == true لا is، فهي آمنة أمام
الغياب.
٨. setState(() => x = y) يصندق مرّتين — اكتب جسماً بأقواس.
الإغلاق السهمي يعيد قيمة جسمه، والإسناد تعبير، فتُعلَّب القيمة مرّة للكتابة
ومرّة للإرجاع، والثانية تقع على معلَّب:
type '$bool' is not a subtype of type 'bool' in type cast
7287: SetObjectProperty (L3._exporting = L1)
7288: BoxBool (L1) <<< EXCEPTION
setState(() { _busy = true; }); // لا قيمة تُرجَع، فلا تعليب ثانٍ
حوّلها كلّها، لا التي تنهار فقط: مواضع متطابقة في الملف نفسه ظلّت تعمل أسابيع، والفرق يعود لتخصيص السجلّات لا لشيء يُرى في المصدر.
٩. قراءة bool من قائمة هي الفخّ نفسه من طريق آخر.
الفهرسة تعيد قيمة معلَّبة أصلاً، والمترجم — إذ يرى نوعاً ساكناً bool — يضيف
BoxBool فوقها. قارِن، لا تقرأ:
final on = i < flags.length && flags[i] == true; // لا `flags[i]`
المقارنة تنتج منطقياً جديداً فلا يُعاد تعليبه. النصوص والودجتس تخرج من القوائم بلا مشكلة؛ المنطقيّات وحدها (والأعداد الذاهبة لمعامل مجسور) هي التي تعضّ.
١٠. لا تُهيّئ عدّاد حلقة من معامل.
عددٌ صحيح عبر معامل مفسَّر يصل معلَّباً. استعماله مباشرةً سليم (المترجم
يفكّه)، لكن int i = from نسخة محليّة: CopyValue ينقل العلبة، ويصدّق المترجم
تصريح int فيظنّها مفكوكة، ثم تموت المقارنة التالية:
type '$int' is not a subtype of type 'num' in type cast
استعمل حدوداً حرفية وكرّر الحلقة، دالةً لكل مدى. ولا توسّع القاعدة إلى «لا
حساب على معامل صحيح» — تلك خاطئة، وindex * 40 يشحن بلا مشكلة.
١١. قيمة أوليّة تعبر معامل دالة مفسَّرة تُعلَّب مرّتين.
حرفٌ يذهب مباشرةً إلى بانٍ مجسور سليم. أمّا المرور بمعامل أوّلاً فيصل معلَّباً
مرّتين ويموت بـ type '$int' is not a subtype of type 'int'. هكذا كسر مساعدٌ
بسيط — IconData _icon(int c) — شريط تبويب من خمسة تبويبات، مرّة لكل تبويب.
ابنِ القيم المجسورة من حروف عند موضع الإنشاء، أو أعِدها من دالة بلا معاملات
كما تفعل OktaIcons. والودجتس والنصوص تمرّ عبر المعاملات بلا أذى.
١٢. مُعلَن، غير موصول: يترجم ثم يرمي.
صنفٌ قد يكون معروفاً للمترجم وغير مسجَّل لدى وقت التشغيل، فيمرّ من
validate.dart ثم يرمي أثناء البناء:
UnimplementedError: Tried to invoke a nonexistent external function
في flutter_eval 0.8.2 هذا حال InkWell وInkResponse وSafeArea. استعمل
GestureDetector بدل الأولَيْن، وحسابات حشو بدل الثالث.
والفخّ نفسه على الدوال: list.sort() بلا مقارِن يُترجَم ثم يرمي، لأن
تنفيذها يقرأ args[0] بلا ?. مرّر المقارِن دائماً أو لا تُرتّب.
١٣. النصّ الطويل يلتفّ ولا يُقصّ.
Text لا يجسر maxLines ولا overflow، وTextOverflow ليس نوعاً مجسوراً
أصلاً — فلا نقاط حذف، والعنوان الأعرض من صندوقه يلتفّ فيشقّ الكلمة. أبقِه
سطراً واحداً بـ FittedBox(fit: BoxFit.scaleDown, child: Text(…)) داخل عرض
محدود، وأغفِل alignment (الغلاف يجعلها Alignment.center).
١٤. لا Timer — لكن هناك ساعة، وFuture.delayed تُنتظَر ولا تُعطى دالّة.
DateTime (بـ .now() و.parse وadd وdifference و…) وDuration
وFuture.delayed كلّها مجسورة وموصولة، وjsonEncode/jsonDecode كذلك.
الغائب هو Timer وحده، أي الاستدعاء الدوري لا التأخير. فحلقة السحب
(toolKeyReads/cameraScanReads) تُكتب استدعاءً ذاتياً بعد
await Future.delayed(مدّة)، وتحتاج علمك أنت لإيقافها: State.mounted
غير مجسورة، فلا تستطيع فجوة غير متزامنة أن تسأل هل ما زالت على الشاشة.
ودالّةٌ تمرّرها إلى Future.delayed تُترجَم ولا تُنادى أبداً: الجسر يأخذ
المدّة وحدها ويُسقط الدالّة بصمت. فحلقةٌ تكتب
Future.delayed(مدّة, () { _drain(); }) تسحب مرّة واحدة ثم تسكت، بلا خطأ في
أيّ مكان. اكتبها هكذا، داخل دالّة async:
await Future.delayed(const Duration(seconds: 1));
_drain();
مقيساً على المحرّك الذي يعمل على الجهاز، وcheck_miniapp_source يرفض الشكل
الأول باسمه (future_delayed_callback). وأمثلة هذا الدليل كانت تستعمله في
حلقات السحب حتى صُحّحت، فإن نسخت حلقة سحب من نسخة أقدم فاستبدلها.
١٥. عنصرٌ من JSON لا يمرّ عبر ?:.
قيمة تعبير ?: أحدُ فرعيه عنصرٌ من قائمة أو خريطة مفكوكة تخرج غير
مُصندَقة، وأوّل OktaJson (أو أيّ is) بعدها يموت:
type '_Map<$Value, $Value>' is not a subtype of type '$Value' in type cast
final first = rows.isEmpty ? null : rows[0]; // يموت في OktaJson.str(first, …)
final first = rows.isEmpty ? {} : rows[0]; // والفرع الآخر لا يغيّر شيئاً
dynamic first; // إسنادٌ داخل if
if (rows.isNotEmpty) { first = rows[0]; }
final name = rows.isEmpty ? '' : OktaJson.str(rows[0], 'name'); // اقرأ داخل الفرع
مقيساً على المحرّك: rows[0] مباشرةً، وrows.first، وrows[0] ?? …، وحلقة
for — كلّها سليمة، و**?: وحده** يفكّ الصندقة. ووجده اختبارٌ على المحرّك
نفسه الذي يعمل على الجهاز لا شكوى من مدرسة: كان يُسقط اختبار العقد 26 في
مستودع المضيف منذ كُتب. فتجنّبه قبل أن يصير شكوى.