الجوال والشاشات
المايك ورفع الملفات (Voice & Files)
كل ما كان تطبيقك يستطيع إرساله إلى خادمك مكتوبٌ أو مصوَّر.
Okta.uploadFile(path) موجودة منذ العقد 1، لكن منتقي المضيف يقبل خمس
امتدادات فقط (pdf, jpg, jpeg, png, heic)، ولا شيء على هذا السطح كان يسجّل
صوتاً. فمعلّمة تريد أن تسجّل جملةً لوليّ أمر، ووليُّ أمرٍ يريد أن يردّ على نداء
الانصراف بصوته، ومشرفٌ يريد أن يرفق تقرير الحادثة الذي تملكه المدرسة بصيغة
.docx — ثلاثتهم بلا طريق.
العقد 24 يفتح الاثنين: مايك، ومنتقٍ تقول له ماذا تريد.
| النداء | ما يفعله |
|---|---|
Okta.canRecord() |
هل يستطيع هذا الجهاز التسجيل أصلاً؟ اسألها قبل أن تعرض زرّ التسجيل |
Okta.recordStart() |
ابدأ التسجيل. false = المضيف رفض (لا مايك، إذن مرفوض، أو تسجيل جارٍ) |
Okta.recordStop() |
أنهِ التسجيل وخُذ مقبضاً (String?) يسمّيه. null = لا شيء لينتهي |
Okta.recordCancel() |
ارمِ التسجيل الجاري وأطلق المايك. void، وآمنة دائماً |
Okta.playRecording(handle) |
أسمِع صاحبه ما سجّله قبل أن يرسله |
Okta.uploadRecording(path, handle) |
ارفع التسجيل إلى مسار API — نفس خريطة uploadFile |
Okta.uploadFileOfKind(path, kind) |
اختيار ورفع، وkind يقول أيّ ملفات يعرضها المنتقي |
كلها من العقد 24، فأعلن "minContract": 24 إن كنت تنادي واحداً منها.
وإن كنت لا تنادي شيئاً منها فلا ترفعه: الرفع بلا حاجة يرفض أجهزة كان
تطبيقك يعمل عليها.
سجِّل ← استمع ← أرسل
ثلاثة أفعال، والأوسط هو سبب وجود ستّة رموز لا ثلاثة: ملاحظةٌ صوتية لا يسمعها صاحبها قبل أن تغادر ملاحظةٌ لا يرسلها أحدٌ مرّتين.
String? _handle;
bool _busy = false;
Future<void> start() async {
if (!await Okta.canRecord()) {
Okta.toast('لا يوجد مايك متاح على هذا الجهاز');
return;
}
if (!await Okta.recordStart()) {
Okta.toast('تعذّر بدء التسجيل');
return;
}
setState(() { _busy = true; });
}
Future<void> stop() async {
final String? handle = await Okta.recordStop();
setState(() { _busy = false; _handle = handle; });
}
Future<void> play() async {
final String? h = _handle;
if (h == null) return;
await Okta.playRecording(h); // نفس المخرج الذي يستعمله playAudio
}
Future<void> send() async {
final String? h = _handle;
if (h == null) return;
final dynamic result = await Okta.uploadRecording('/api/apps/notes', h);
if (result == null) return; // ألغى المستخدم — لا يقع هنا عملياً
final int status = result['status'];
if (status >= 200 && status < 300) {
setState(() { _handle = null; }); // أُرسل، فلا تُرسله مرّتين
Okta.toast('أُرسلت الملاحظة');
} else {
Okta.toast('تعذّر الإرسال: ${result['error']}');
}
}
canRecord() قبل الزرّ — وهذا هو بيت القصيد
شاشة الفصل لها مكبّر ولا مايك. والهاتف قد يكون إذن المايك فيه مرفوضاً نهائياً من إعدادات النظام، حيث لا شيء يفعله التطبيق يرفع نافذة إذن بعدها أبداً.
بلا هذا الاستطلاع ترسم زرّ تسجيل، فيضغطه المستخدم، ولا يحدث شيء: لا نافذة
إذن، ولا رسالة خطأ، ولا سطر في أي سجلّ. وهو بالضبط العطل الذي وُجد هذا
الاستطلاع ليمنعه — نفس درس canSpeak() حرفياً.
و**false جوابٌ عادي عن جهاز سليم**، لا عطلٌ يُبلَّغ ولا محاولةٌ تُعاد: اعرض
الكتابة، أو الملف الذي يملكه المستخدم أصلاً، بدلاً من ذلك.
وهي لا تطلب الإذن، فآمنٌ أن تناديها وأنت ترسم الشاشة. recordStart() هي
التي قد ترفع النافذة.
المقبض يسمّي تسجيلاً بعينه، ولا يُعاد استعماله
recordStop() تعيد نصّاً معتماً يسمّي تسجيلاً يحتفظ به المضيف، لا
التسجيل نفسه: الصندوق بلا نظام ملفات، والبايتات لا تعبر الجسر أبداً. لا شيء
فيه يُقرأ ولا يُحلَّل — سلّمه إلى playRecording وuploadRecording ولا شيء
غير ذلك.
ويُتقاعَد لحظة بدء تسجيل جديد أو إغلاق تطبيقك. والمقبض المتقاعد يُرفَض باسمه ولا يُستبدَل به ما يحمله المضيف الآن — وهذا هو الفرق الذي يستحقّ الوسيط: وليّ أمر يسمع صوته مُعاداً، وتسجيلُ عائلةٍ أخرى هو ما يصل المدرسة.
فاحتفظ بالمقبض ما دامت شاشته، وامسحه بعد إرسال ناجح.
تسجيل واحد في كل وقت
recordStart() على مضيف يسجّل الآن تُجيب false ولا ترمي ما يُقال في
الهاتف بصمت. وplayRecording يمرّ بالمخرج الصوتي نفسه الذي يستعمله
playAudio (العقد 23): بدء أحدهما يُسكت الآخر، وOkta.stopAudio() تُسكت
أيّهما يصدر — فلا تحتاج أن تعرف أيّهما كان.
uploadFileOfKind — والمفردات مغلقة
Okta.uploadFile(path) لم تتغيّر ولن تتغيّر: خمس امتداداتها كما هي. توسيعها
كان سيغيّر بصمت ما يستلمه كل تطبيق منشور من منتقيه، ولا سطر في كوده يقول ذلك.
فمن يريد أكثر يقول أيَّ أكثر:
kind |
ما يعرضه المنتقي |
|---|---|
image |
صور الجهاز |
audio |
ملفات صوتية |
video |
مقاطع |
document |
pdf, doc/docx, xls/xlsx, ppt/pptx, txt, csv, rtf, odt, ods |
any |
كل شيء |
القائمة مغلقة، لأن المضيف هو من يرسم المنتقي: كلمةٌ لا يعرفها كانت
ستتدهور بهدوء إلى شيءٍ آخر على كل جهاز في كل مدرسة. المجهول يعود
{'status': 0, 'error': 'unknown file kind "…"'} — والمحاكي يرفضه كذلك، فخطؤك
المطبعيّ يُكتشَف في المتصفّح لا في مدرسة.
final dynamic picked = await Okta.uploadFileOfKind('/api/apps/files', 'document');
if (picked == null) return; // ألغى المستخدم
if (picked['status'] >= 400 || picked['status'] == 0) {
Okta.toast('تعذّر الرفع: ${picked['error']}');
return;
}
وdocument قائمة امتدادات لا «كل شيء» عمداً: منتقٍ يعرض كل شيء هو كيف يُرفق
مستخدمٌ فيديو 400 ميغابايت بطلب إجازة ثم ينتظره على شبكة مدرسة. اطلب any إن
كنت تقصدها.
null تعني «ألغى المستخدم» — والفشل شيء آخر
هذا هو الفرق الذي يجب أن تكتبه في الشفرة:
null← أغلق المستخدم المنتقي. لا شيء تفعله، ولا رسالة تعرضها.- خريطة بـ
status: 0أو4xx/5xxمعerror← فشل. اعرضه.
وuploadRecording لا تعيد null أبداً لمقبض متقاعد أو مضيف بلا مايك: تلك
نتيجةُ خطأ، لأن أحداً لم يُلغِ شيئاً — وتطبيقٌ يقرؤها إلغاءً يُسقط ملاحظةً
صوتية ويظنّ أنه أرسلها.
لا شيء يرمي
مايك غائب، إذن مرفوض، منتقٍ أُغلق، مقبض متقاعد، شبكة ماتت وسط الرفع — كلّها
false أو null أو نتيجةُ خطأ. لا تكتب try حول أيٍّ من السبعة؛ من يقرأ
«قد يفشل» يكتبها، والاستثناء هنا لا يقع.
أين لا يعمل، ويُقال لك بصدق
- الويب (بناء المتصفّح والمحاكي):
canRecord()تجيبfalse. حزمة التسجيل على الويب تعيد blob لا ملفاً، فالتسجيل لا يستطيع أن يسلك ساق الرفع نفسها. - المحاكي: يرفض السبعة، ومنتقي الملفات يُجاب فيه إلغاءً كما هو الحال
في
uploadFileمنذ البداية. محاكٍ يقول «نعم» دائماً لا يُشغّل مسار السقوط الذي نطلب منك كتابته، وأول مكان تكتشف فيه غيابه شاشةٌ أمام مستخدم حقيقي. - شاشة العرض (okta-screen): ترفض السبعة — تلفازٌ على حائط بلا مايك ولا أحد أمامه ليختار ملفاً.
فجرّب المسار المنطوق والمُسجَّل على جهاز حقيقي، واكتب مسار السقوط الذي
يعمل حين تجيب canRecord() بـfalse.
لا مفتاح في البيان، ولا نطاق
كالعقد 23 تماماً: هذه رموز مضيف. لا شيء تُضيفه إلى manifest.json ولا
نطاق تطلبه من الجهة — المايك مايك الجهاز الذي فُتح عليه تطبيقك، والملف يذهب
إلى مسار API تحكمه نطاقاتك المعتادة. الشيء الوحيد الذي يتحرّك هو
minContract.