أمثلة ومراجع

مثال: تطبيق «حضور بالمسح» (Native) — من الصفر إلى النشر

فصلٌ عملي واحد يبني تطبيقاً مصغّراً كاملاً بلغة Dart: مشرف يقف على البوّابة، يمرّر بطاقات الطلاب، وكل قراءة تُسجَّل حضوراً — وتُحفَظ محلياً حين تنقطع الشبكة. مثالٌ حقيقي لأنه أكثر ما يُبنى فعلاً، ولأنه يمرّ على كل ما يحتاجه تطبيق native: الحالة، والصفحات، والأذونات، والمسح، والشبكة، والتخزين، والحالات الثلاث، والهوية.

قبل أن تكتب سطراً: أربعة حدود تشكّل التصميم

هذه ليست تفضيلات. كلٌّ منها يغيّر ما الذي يمكن أن يكون عليه تطبيقك.

١. لا إدخال نصّي. لا TextField في السطح المجسور. تطبيق native هو تطبيق قراءة وإجراء: يعرض، ويمسح، ويضغط، ويرسل. أي شاشة جوهرها أن يكتب فيها المستخدم — نموذج تسجيل، بحث حرّ، تعليق — ليست تطبيقاً أصلياً؛ ابنِها بوضع webview أو external. اكتشاف هذا بعد أسبوعين من العمل هو أغلى درس في المنصّة، ولهذا هو أوّل سطر في الفصل.

٢. لا تنقّل. Navigator.of معلَن في الجسر بلا تنفيذ ولا إرسال ساكن، فلا يمكن الحصول على NavigatorState من داخل الصندوق، ولا يوجد مكدّس مسارات أصلاً. شاشتان داخل تطبيقك تعنيان حالة واحدة تحمل رقم الصفحة — النمط كاملاً أدناه.

٣. لا حلقات متداخلة. dart_eval 0.8.5 يرمي RangeError وقت الترجمة حين تحوي حلقةٌ حلقةً أخرى — مباشرةً أو عبر دالة تستدعيها. سطّح بياناتك في قائمة واحدة، على الخادم إن أمكن.

٤. الظلّ والزاوية المدوّرة لا يجتمعان. التدوير من ClipRRect وحده، والظلّ من BoxDecoration وحده، والأخير بلا borderRadius. من أراد سطحاً مرتفعاً مدوّراً فـOktaGlass.card — يرسمه المضيف أصلياً فينال الثلاثة معاً.

٠ · الهيكل والبيان

okta_app/native/attendance/
├── pubspec.yaml            ← يثبّت okta_miniapp — لا ترفع الـ ref بنفسك
├── analysis_options.yaml
├── lib/
│   └── main.dart           ← نقطة الدخول: Widget main()
└── tool/
    └── validate.dart       ← نفس بوابة الـ CI

والبيان:

"mobile": {
  "supported": true,
  "mode": "native",
  "entry": "okta_app/native/attendance/lib/main.dart",
  "minContract": 16
}

minContract: 16 لأننا نستخدم package:okta_glass. ولا شيء آخر تعلنه: الوصول للعتاد لم يعد له مدخل في البيان بعد إزالة بوّابة الصلاحيات.

١ · الحالة، والفخّ الذي يكلّف يوماً

StatefulWidget وsetState يعملان. وفخّان:

setState(() { _busy = true; });        // جسم كتلة
setState(() => _busy = true);          // يصندق مرّتين ويرمي

إغلاقٌ سهميّ يُعيد قيمة جسمه، والإسناد تعبير — فتُصندَق القيمة مرّة للكتابة ومرّة للإرجاع، والثانية تسقط على مصندَق: type '$bool' is not a subtype of type 'bool'. حوّل كلّ المواضع لا التي تسقط فقط: ظهورُ العطب يتوقّف على تخصيص السجلّات لا على شيء يُرى في المصدر.

والثاني: لا State.mounted — غير مجسورة، وذكرُ اسمها لا يُترجَم. نادِ setState مباشرةً.

٢ · الصفحات الداخلية — النمط، بلا Navigator

صفحةٌ رقمٌ في الحالة، والشريط يقرّر ما يعرضه، وزرّ الرجوع يرجع داخلياً ثم يسلّم للمضيف حين تنفد الصفحات:

class _AttendanceState extends State<Attendance> {
  int _page = 0; // 0 = المسح · 1 = سجلّ اليوم

  void _openLog() {
    setState(() { _page = 1; });
  }

  // زرّ رجوع واحد لكل الصفحات. يرجع داخلياً ما دام هناك مكان يرجع إليه،
  // ويسلّم للمضيف عند الصفحة الأولى — وهو المخرج الوحيد من التطبيق المصغّر.
  void _back() {
    if (_page != 0) {
      setState(() { _page = 0; });
      return;
    }
    Okta.close();
    // «لا شيء» ردٌّ صحيح من المضيف على close (قد لا يكون هناك ما يُطوى)،
    // فلا تعتمد عليه لإيقاف التنفيذ — ارجع فوراً بعده.
    return;
  }

  AppBar _bar(OktaPalette palette) {
    if (_page == 0) {
      return OktaAppBar.build('الحضور', palette);
    }
    return OktaAppBar.withBack(
        'سجلّ اليوم', palette, Okta.appIcon(22.0), () => _back());
  }
}

لا تضع الأيقونة في leading على الصفحة الأولى. Flutter يُدرِج زرّ الرجوع فقط حين يكون leading == null، فـOktaAppBar.buildWithIcon تحذف المخرج الوحيد بصمت. withBack تستعيد الخانة للزرّ وتُزيح الأيقونة إلى actions.

٣ · الإذن ثم المسح

اطلب القدرة عند لحظة استخدامها، لا عند فتح الشاشة: طلبٌ يصل ومعه سببٌ يراه المستخدم أمامه طلبٌ يستطيع الإجابة عليه، وجدارُ طلبات عند الإقلاع جدارٌ يُغلَق.

Future<void> _scan() async {
  final key = await Okta.scanNfc();
  if (key == null) {
    // null يغطّي كل نهاية ليست بطاقة: ألغى المستخدم، أو الهاتف بلا NFC، أو
    // رُفضت نافذة إذن النظام. لا شيء منها استثناء، ولا شيء منها يُعلَن كخطأ.
    return;
  }
  await _submit(key);
}

٤ · الإرسال — وثلاث نهايات لا نهايتان

OktaApiResponse يحمل status وbody وerror وok المحسوبة. والفرق الذي يُنسى: status == 0 يعني أن النداء لم يبلغ خادماً أصلاً. عرضُ «خطأ من الخادم» عندها كذبٌ على المشرف، والخادم لم يقل شيئاً.

Future<void> _submit(String key) async {
  final res = await Okta.post(
    '/api/attendance/reads',
    <String, dynamic>{'key': key},
  );

  if (res.ok) {
    Okta.playSound('success');
    // body ديناميكيّ من طرف لطرف — لا تُعطِ المستقبِل نوع Map.
    final dynamic name = OktaJson.str(res.body, 'student_name');
    setState(() { _log.add('$name · حاضر'); });
    return;
  }

  if (res.status == 0) {
    await _queue(key);
    return;
  }

  Okta.playSound('error');
  setState(() { _log.add('رُفض · ${res.status}'); });
}

٥ · الطابور المحلّي — والفشل الصامت

Future<void> _queue(String key) async {
  await Okta.storePut('queued:$key', key);
  Okta.playSound('warning');

  // قُلها صراحةً. الالتقاط المحلّي يُؤكَّد قبل استشارة الشبكة — وهذا صحيح، به
  // ينجو المسح من انقطاع — لكن معناه أن رفعاً لم يصل يبدو على الشاشة
  // **مطابقاً تماماً** لرفع وصل. من يقف على بوّابة ينظر إلى الطالب لا إلى
  // شارة في الترويسة، فالفرق يُقال بصوت ورسالة وسطر في السجلّ.
  Okta.toast('حُفظ محلياً — لا شبكة. سيُرسَل عند عودتها.');
  setState(() { _log.add('$key · بانتظار الشبكة'); });
}

Okta.storeKeys() وOkta.storeDelete(key) يفرغان الطابور لاحقاً.

التخزين ليس تخزيناً آمناً. عامِله كأنّ من يملك الجهاز يقرؤه: هو لطابور التقاط، لا لرمز مصادقة.

٦ · القائمة والحالات الثلاث

Widget _logPage(OktaPalette palette) {
  if (_loading) {
    return OktaStates.loading(palette);
  }
  if (_log.isEmpty) {
    return OktaStates.empty(
      OktaIcons.list(), 'لا قراءات بعد', 'مرّر بطاقة لتبدأ.', palette);
  }

  // حلقة واحدة، وبانٍ صفوف بلا حلقة داخله. حلقة داخل حلقة — مباشرةً أو عبر
  // دالة تستدعيها — تُسقط الترجمة بـ RangeError.
  final rows = <Widget>[];
  for (final entry in _log) {
    rows.add(OktaGlass.card(
      OktaList.row(OktaIcons.check(), entry, 'اليوم', palette),
    ));
  }

  return ListView(
    padding: const EdgeInsets.all(OktaMetrics.space4),
    children: rows,
  );
}

٧ · الهوية — بلا قيمة لونية واحدة

final palette = OktaPalette.of(Okta.isDark());

Scaffold(
  backgroundColor: const Color(0x00000000),
  appBar: _bar(palette),
  body: OktaGlass.background(          // حقل واحد للشاشة كلّها
    _page == 0 ? _scanPage(palette) : _logPage(palette),
  ),
)

راجع «هوية أوكتا داخل التطبيق المصغّر» أعلاه للتفصيل. القاعدة هنا سطر واحد: كل لون من palette، وكل رقم من OktaMetrics، وكل مكوّن من okta_kit — فحين تعيد أوكتا تصميم المادة يتغيّر تطبيقك بلا أن تنشر شيئاً.

٨ · التحقّق والنشر

flutter test tool/validate.dart                       # بوابة الـ CI نفسها
dart run tool/audit_bridge_usage.dart lib/main.dart   # يمسك المُسقَط بصمت

flutter analyze سيشتكي أن Okta وOktaPalette غير معرّفين — هذا متوقَّع. المكتبات تُحقَن من المحرّك وقت الترجمة وليست حزم pub.

ثم ادفع إلى فرع الإصدار وأعد التثبيت على sandbox — بلا إصدار جديد. ذاكرة البايتكود على الجهاز مفتاحها (slug, entry, payloadVersion, runtimeSignature) لا محتوى الحزمة، والتثبيت يعيد حلّ commit الفرع فيحرّك payloadVersion من تلقائه. النسخة القديمة لا تبقى إلا إن أعدت التثبيت بلا دفع — وحينها لا شيء جديد ليصل أصلاً. انظر «متى يصل تعديلك إلى الجهاز» أعلاه.

الملف كاملاً

import 'package:flutter/material.dart';
import 'package:okta_glass/okta_glass.dart';
import 'package:okta_host/okta_host.dart';
import 'package:okta_kit/okta_kit.dart';

Widget main() => const Attendance();

class Attendance extends StatefulWidget {
  const Attendance({super.key});

  @override
  State<Attendance> createState() => _AttendanceState();
}

class _AttendanceState extends State<Attendance> {
  int _page = 0;
  bool _busy = false;
  final List<String> _log = <String>[];

  void _openLog() {
    setState(() { _page = 1; });
  }

  void _back() {
    if (_page != 0) {
      setState(() { _page = 0; });
      return;
    }
    Okta.close();
    return;
  }

  Future<void> _scan() async {
    if (_busy) {
      return;
    }
    setState(() { _busy = true; });

    final key = await Okta.scanNfc();
    if (key == null) {
      setState(() { _busy = false; });
      return;
    }

    final res = await Okta.post(
      '/api/attendance/reads',
      <String, dynamic>{'key': key},
    );

    if (res.ok) {
      Okta.playSound('success');
      final name = OktaJson.strOr(res.body, 'student_name', key);
      setState(() {
        _log.add('$name · حاضر');
        _busy = false;
      });
      return;
    }

    if (res.status == 0) {
      await Okta.storePut('queued:$key', key);
      Okta.playSound('warning');
      Okta.toast('حُفظ محلياً — لا شبكة. سيُرسَل عند عودتها.');
      setState(() {
        _log.add('$key · بانتظار الشبكة');
        _busy = false;
      });
      return;
    }

    Okta.playSound('error');
    setState(() {
      _log.add('رُفض · ${res.status}');
      _busy = false;
    });
  }

  AppBar _bar(OktaPalette palette) {
    if (_page == 0) {
      return OktaAppBar.build('الحضور', palette);
    }
    return OktaAppBar.withBack(
        'سجلّ اليوم', palette, Okta.appIcon(22.0), () => _back());
  }

  Widget _scanPage(OktaPalette palette) {
    return ListView(
      padding: const EdgeInsets.all(OktaMetrics.space4),
      children: <Widget>[
        OktaGlass.card(
          Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: <Widget>[
              Text(
                'قرّب بطاقة الطالب من الجهاز',
                style: TextStyle(
                  color: palette.textStrong,
                  fontSize: OktaMetrics.textBody,
                  fontWeight: FontWeight.w600,
                ),
              ),
              const SizedBox(height: OktaMetrics.space4),
              _busy
                  ? OktaButton.disabled('جارٍ…', palette)
                  : OktaButton.primary('ابدأ المسح', () => _scan(), palette),
            ],
          ),
        ),
        const SizedBox(height: OktaMetrics.space3),
        OktaSurface.cardTappable(
          palette,
          () => _openLog(),
          OktaList.rowWithTrailing(
            OktaIcons.list(),
            'سجلّ اليوم',
            'ما سُجّل منذ بداية الدوام',
            OktaSurface.pill('${_log.length}', palette.brandSoft, palette.brand),
            palette,
          ),
        ),
      ],
    );
  }

  Widget _logPage(OktaPalette palette) {
    if (_log.isEmpty) {
      return OktaStates.empty(
          OktaIcons.list(), 'لا قراءات بعد', 'مرّر بطاقة لتبدأ.', palette);
    }

    final rows = <Widget>[];
    for (final entry in _log) {
      rows.add(Padding(
        padding: const EdgeInsets.only(bottom: OktaMetrics.space2),
        child: OktaGlass.card(
          OktaList.row(OktaIcons.check(), entry, 'اليوم', palette),
        ),
      ));
    }

    return ListView(
      padding: const EdgeInsets.all(OktaMetrics.space4),
      children: rows,
    );
  }

  @override
  Widget build(BuildContext context) {
    final palette = OktaPalette.of(Okta.isDark());

    return Directionality(
      textDirection: OktaText.isRtl(Okta.locale())
          ? TextDirection.rtl
          : TextDirection.ltr,
      child: Scaffold(
        backgroundColor: const Color(0x00000000),
        appBar: _bar(palette),
        body: OktaGlass.background(
          _page == 0 ? _scanPage(palette) : _logPage(palette),
        ),
      ),
    );
  }
}

عرض الدليل كاملاً في صفحة واحدة · ↧ Markdown خام