مبتدئ → تطبيقي · 10 دروس

Flutter — تطبيقات موبايل من الصفر

تبني تطبيقاً واحداً يعمل على Android و iOS: واجهات عربية، شاشات، حالة، وربط API — وتخرج بنسخة أولى (MVP) قابلة للعرض والتطوير.

كيف تدرس هذا المسار؟

  1. اقرأ الدرس وافهم المفهوم قبل الكود.
  2. انسخ أمثلة Dart وجرّبها في مشروعك.
  3. استخدم flutter doctor و Hot Reload.
  4. سجّل إكمال كل درس — التقدّم محلي.

مناسب من سن 13 فما فوق، وفي آخر كل درس روابط لمراجعه الرسمية.

1. لماذا Flutter؟ — تطبيق واحد، منصتان

الهدف: تميّز بين موقع الويب والتطبيق الأصلي والتطبيق متعدد المنصات، وتعرف متى يكون Flutter اختياراً مناسباً.

صاحبة مخبز صغير في المنصورة تستقبل طلبات الكعك عبر واتساب، وتضيع منها طلبات كل أسبوع لأن الرسائل تختلط ببعضها. فكرتها الأولى «تطبيق للمخبز»، لكن قبل اختيار الأداة يجب أن تعرف الخيارات الثلاثة المتاحة، لأن لكل منها تكلفة وقيوداً مختلفة. هذا الدرس يضع الخيارات جنباً إلى جنب، ثم يوضح أين يقف Flutter بينها، ومتى لا يكون هو الجواب.

الخيار الأول موقع ويب متجاوب، أو تطبيق ويب تقدمي (PWA). يفتحه العميل برابط دون تثبيت، ويصل إليه التحديث فور نشره، ولا يمر بمراجعة أي متجر. تظهر حدوده عندما تحتاج إشعارات موثوقة على كل الأجهزة، أو عملاً كاملاً دون اتصال، أو وصولاً عميقاً إلى الكاميرا والبلوتوث. الخيار الثاني تطبيق أصلي (Native) لكل نظام: لغة Kotlin لنظام Android ولغة Swift لنظام iOS. يعطي أفضل وصول لإمكانات الجهاز، لكنه يعني مهارتين، وكوداً يُكتب مرتين، وأخطاء تُصلح مرتين.

الخيار الثالث إطار متعدد المنصات (Cross-platform)، و Flutter من أشهر أمثلته، وهو إطار مفتوح المصدر من Google. تكتب الكود بلغة Dart مرة واحدة، فيُبنى منه تطبيق Android وتطبيق iOS، ويمكن توسيعه لاحقاً إلى الويب وسطح المكتب. ويختلف Flutter عن أطر أخرى في أنه لا يستعير أزرار النظام وقوائمه، بل يرسم كل بكسل بنفسه عبر محرك رسم. تذكر الوثائق الرسمية أن محرك Impeller أصبح منذ الإصدار 3.27 المحرك الافتراضي على iOS وعلى Android بدءاً من المستوى API 29، مع رجوع تلقائي إلى المحرك القديم على الأجهزة التي لا تدعمه. لذلك يبدو التطبيق متطابقاً تقريباً على الجهازين، ويُترجَم كود Dart في نسخة النشر مسبقاً إلى كود آلة أصلي، فيبقى الأداء سريعاً.

كل ما تراه على الشاشة في Flutter اسمه عنصر واجهة (Widget): النص، والزر، والمسافة الفارغة، وحتى توسيط عنصر داخل مساحة. تُبنى الواجهة بتركيب عناصر صغيرة داخل بعضها، والمثال التالي يضع نصاً في منتصف المساحة المتاحة:

Center(
  child: Text('اطلب كعك العيد'),
)

هنا Center هو الأب و Text هو الابن، والأب يقرر موضع ابنه. هذه الفكرة، أي تركيب الواجهة من قطع متداخلة، ستتكرر في كل درس من دروس المسار حتى تصبح طريقة تفكيرك الطبيعية في أي شاشة.

كيف تقرر صاحبة المخبز؟ ثلاثة أسئلة عملية تحسم الاختيار في أغلب الحالات:
• كم مرة سيفتح العميل التطبيق؟ إن كانت الزيارة مرة في الموسم فصفحة ويب تكفي، لأن قلة من الناس يثبّتون تطبيقاً لطلب واحد.
• هل تحتاج إشعاراً عند جهوز الطلب، أو عملاً دون إنترنت، أو وصولاً للكاميرا؟ هنا يتقدم تطبيق الموبايل.
• كم شخصاً سيصون الكود؟ إن كان شخصاً أو اثنين، فكود واحد لمنصتين يعفيهما من كتابة كل ميزة وإصلاحها مرتين، وهذا ما يجعل Flutter مناسباً للمشاريع الصغيرة والطلاب.

خطأ شائع: الظن أن «كوداً واحداً» يعني أنك لن تحتاج جهاز Mac أبداً. بناء نسخة iOS ورفعها إلى App Store يتطلب Xcode، وهو لا يعمل إلا على macOS، والبديل خدمة بناء سحابية. ابدأ بنسخة Android على Windows أو Linux، فهي كافية للتعلم ولعرض النموذج الأول على المستخدمين. وخطأ آخر: افتراض أن كل حزمة تعمل على المنصتين؛ بعض الحزم على موقع pub.dev تدعم منصة واحدة فقط، فراجع خانة المنصات المدعومة في صفحة الحزمة قبل الاعتماد عليها.

قاعدة التخطيط في هذا المسار هي المنتج الأدنى القابل للتطبيق (MVP): ثلاث شاشات وميزة أساسية واحدة تعمل بلا أعطال. لمخبز المنصورة يعني ذلك قائمة المنتجات، وشاشة تفاصيل المنتج، وشاشة تأكيد الطلب. الدفع الإلكتروني ونقاط الولاء والتقييمات تنتظر حتى يثبت أن العملاء يستخدمون الطلب نفسه. الدرس العاشر يجمع ما تتعلمه في نسخة من هذا النوع.

  • Flutter يرسم واجهته بنفسه بمحرك Impeller، فتبدو متطابقة تقريباً على Android و iOS.
  • كود Dart واحد لمنصتين يوفر على الفريق الصغير كتابة كل ميزة وإصلاحها مرتين.
  • صفحة الويب تكفي للاستخدام النادر، والتطبيق يتقدم مع الإشعارات والعمل دون اتصال.
  • نشر نسخة iOS يحتاج Xcode على Mac أو خدمة بناء سحابية.
  • الإصدار الأول ثلاث شاشات وميزة أساسية واحدة متقنة.

مخطط

فكرة التطبيق
كود Dart
Android
iOS

مختبر: خريطة التطبيق

ارسم 3 مربعات للشاشات وأسهم الانتقال بينها على ورقة أو في Figma، واكتب على كل سهم الزر الذي يسببه.

مهمة عملية

اختر مشكلة يومية صغيرة، وأجب عن الأسئلة الثلاثة في الدرس كتابةً، ثم حدد ثلاث شاشات فقط مع ما يراه المستخدم في كل منها والزر الذي ينقله للشاشة التالية.

اختبر فهمك (1/3)

ما الذي يميّز طريقة Flutter في رسم الواجهة؟

اختبر فهمك (2/3)

تعمل على Windows وتريد نشر تطبيقك على App Store. ماذا تحتاج؟

اختبر فهمك (3/3)

أي خطة إصدار أول لمخبز صغير تتبع قاعدة الـ MVP؟

للاستزادة: Flutter: نظرة معمارية عامة · Flutter: محرك الرسم Impeller

2. التثبيت — Flutter SDK وأدوات التطوير

الهدف: تثبّت Flutter وأدوات Android، وتقرأ نتيجة flutter doctor وتصلح أشهر أعطالها.

يحتاج التطوير بـ Flutter إلى ثلاثة أشياء: حزمة تطوير Flutter (Flutter SDK) وتأتي معها لغة Dart تلقائياً، ومحرر كود، وجهاز تشغيل يكون محاكياً (Emulator) أو هاتفاً حقيقياً. تعرض صفحة التثبيت الرسمية طريقين: بداية سريعة عبر VS Code وامتداد Flutter الذي يعرض تنزيل SDK نيابة عنك، وإعداد مخصص تنزّل فيه SDK بنفسك وتضبط المسارات يدوياً. الطريقان يصلان إلى النتيجة نفسها، لكن الإعداد المخصص يعلّمك أين وُضع كل شيء، وهذا ما ستحتاجه عند أول عطل.

خطوات الإعداد المخصص على Windows:
1) نزّل Flutter SDK من الموقع الرسمي وفك ضغطه في مسار قصير مثل C:\src\flutter. تحذّر الوثائق من المسارات التي فيها مسافات أو رموز خاصة، أو التي تحتاج صلاحيات مدير مثل مجلد Program Files.
2) أضف المجلد C:\src\flutter\bin إلى متغير البيئة PATH من إعدادات النظام، ثم افتح نافذة طرفية جديدة.
3) ثبّت Android Studio، ومن SDK Manager فعّل في تبويب SDK Tools كلاً من Command-line Tools و Platform-Tools و Android Emulator، ثم أنشئ جهازاً افتراضياً من Device Manager.
4) ثبّت VS Code مع امتداد Flutter، وهو يثبّت امتداد Dart معه.
5) وافق على تراخيص Android بالأمر flutter doctor --android-licenses، واقرأ كل ترخيص ثم اكتب y.

بعد ذلك يأتي الفحص الشامل. هذه الأوامر الثلاثة ستعود إليها كلما شككت في البيئة:

flutter doctor
flutter doctor -v
flutter devices

الأمر الأول يطبع قائمة بالمكوّنات: علامة صح خضراء لكل مكوّن سليم، وعلامة تعجب صفراء لتحذير، وعلامة خطأ حمراء لنقص يمنع العمل. الخيار -v يعرض التفاصيل الكاملة والمسارات، وهو ما تنسخه عند طلب المساعدة في منتدى. أما flutter devices فيعرض الأجهزة التي يراها Flutter الآن، ويجب أن يظهر فيه المحاكي أو الهاتف قبل أن تحاول التشغيل. ولمعرفة إصدار Flutter وإصدار Dart المصاحب له معاً نفّذ flutter --version.

سجل أعطال: هذه أربع رسائل تتكرر عند المبتدئين، مع سبب كل منها وحلها:
• رسالة «flutter is not recognized as an internal or external command»: عدّلت PATH لكن النافذة الطرفية فُتحت قبل التعديل، فهي لا تعرف القيمة الجديدة. أغلقها وافتح نافذة جديدة أو أعد تشغيل المحرر، ثم نفّذ where.exe flutter لتتأكد أن المسار صحيح؛ في PowerShell تحديداً اكتب where.exe كاملة، لأن where وحدها اختصار لأمر آخر هناك.
• رسالة «cmdline-tools component is missing»: افتح SDK Manager في Android Studio وفعّل Android SDK Command-line Tools. لا داعي لإعادة تثبيت Flutter.
• رسالة «Android license status unknown»: نفّذ أمر التراخيص السابق ووافق عليها كلها، ثم أعد تشغيل flutter doctor.
• رسالة «Building with plugins requires symlink support»: فعّل وضع المطوّر (Developer Mode) من إعدادات Windows، لأن الحزم الإضافية تحتاج روابط رمزية.

لا تنشغل بكل علامة صفراء. التحذيرات التي تخص Chrome أو Visual Studio تتعلق ببناء تطبيقات الويب وتطبيقات سطح مكتب Windows، ولا تمنعك من بناء تطبيق Android. ركّز في هذه المرحلة على قسمي Android toolchain و Android Studio، وعد إلى الباقي عندما تحتاج تلك المنصات. وعلى macOS تتشابه الخطوات، ويضاف Xcode لبناء نسخ iOS وفق صفحة إعداد iOS الرسمية.

الهاتف الحقيقي بديل جيد للمحاكي، خصوصاً على حاسوب ذاكرته محدودة. من إعدادات الهاتف فعّل خيارات المطوّر ثم تصحيح أخطاء USB، ووصّل الهاتف ووافق على رسالة الثقة التي تظهر عليه. على Windows قد تحتاج تعريف USB من الشركة المصنّعة. الهاتف الحقيقي يكشف أيضاً مشكلات لا تظهر في المحاكي، مثل حجم الخط الكبير الذي يختاره بعض المستخدمين، ولوحة المفاتيح التي تغطي حقول الإدخال.

  • ضع Flutter SDK في مسار قصير بلا مسافات ولا صلاحيات مدير.
  • نافذة الطرفية المفتوحة قبل تعديل PATH لا ترى القيمة الجديدة.
  • flutter doctor -v هو أول ما تنسخه عند طلب المساعدة.
  • التحذيرات الخاصة بالويب وسطح المكتب لا تمنع بناء تطبيق Android.

مخطط

 flutter doctor
    |
 +--+--+--+--+
 v  v  v  v  v
SDK IDE emu lic

مختبر: علامة صح خضراء

أصلح أول خطأ أحمر يظهر في flutter doctor، وغالباً يكون تراخيص Android أو cmdline-tools، ثم أعد الفحص.

مهمة عملية

شغّل flutter doctor -v واحفظ النتيجة في ملف نصي، ثم أصلح كل علامة حمراء تخص Android حتى تصبح خضراء، ودوّن بجانب كل مشكلة الخطوة التي حلّتها. النتيجة المقبولة: لا أخطاء حمراء في قسمي Android.

اختبر فهمك (1/3)

أي أمر يفحص بيئة التطوير ويعرض المكوّنات الناقصة؟

اختبر فهمك (2/3)

عدّلت PATH ثم كتبت flutter في النافذة الطرفية نفسها فظهر «is not recognized». ما الحل؟

اختبر فهمك (3/3)

ظهرت رسالة «Building with plugins requires symlink support» على Windows. ما الإصلاح؟

للاستزادة: Flutter: صفحة التثبيت الرسمية · Flutter: إعداد تطوير Android

3. أول مشروع — flutter create و Hot Reload

الهدف: تنشئ مشروعاً وتفهم ملفاته، وتعرف متى يكفي Hot Reload ومتى تحتاج إعادة تشغيل.

مع جاهزية البيئة، يكفي أمر واحد لإنشاء مشروع كامل. نفّذ في الطرفية:

flutter create mesue_app
cd mesue_app
flutter run

الأمر الأول يولّد مجلداً فيه تطبيق عدّاد تجريبي جاهز، والثالث يبني التطبيق ويثبّته على الجهاز المتصل. إن وجد Flutter أكثر من جهاز سألك أيها تريد، ويمكنك التحديد مسبقاً بالخيار -d متبوعاً بمعرّف الجهاز كما يظهر في flutter devices. أول تشغيل بطيء نسبياً لأن أدوات البناء في Android تعمل لأول مرة، والتشغيلات التالية أسرع كثيراً.

انتبه إلى اسم المشروع. اسم الحزمة في Dart يتكون من حروف إنجليزية صغيرة وأرقام وشرطة سفلية فقط، مثل mesue_app، ولا يبدأ برقم ولا يكون كلمة محجوزة في اللغة. لذلك يرفض الأمر أسماء مثل MesueApp أو mesue-app. أما الاسم الذي يراه المستخدم تحت أيقونة التطبيق فشيء آخر، ويمكن جعله عربياً لاحقاً من إعدادات كل منصة.

جولة سريعة في المجلد:
• lib/main.dart: نقطة البداية، وفيه الدالة main. معظم عملك سيكون داخل مجلد lib.
• pubspec.yaml: اسم التطبيق وإصداره والحزم الخارجية والخطوط والصور. بعد أي تعديل فيه نفّذ flutter pub get.
• android/ و ios/: مشروعا المنصتين الأصليان، ولن تعدّلهما إلا لأمور مثل الصلاحيات والأيقونة.
• test/: ملفات الاختبار، وفيه اختبار جاهز للعدّاد التجريبي يمكنك تشغيله بـ flutter test.

الملف المولَّد طويل ومليء بالتعليقات، وهو مفيد للقراءة لكنه مربك كبداية. احذف محتوى lib/main.dart واكتب هذه النسخة القصيرة التي تفهم كل سطر فيها:

import 'package:flutter/material.dart';
void main() => runApp(const MesueApp());
class MesueApp extends StatelessWidget {
  const MesueApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Mesue App',
      theme: ThemeData(colorSchemeSeed: Colors.teal),
      home: Scaffold(
        appBar: AppBar(title: const Text('الرئيسية')),
        body: const Center(child: Text('التطبيق يعمل')),
      ),
    );
  }
}

الدالة runApp تستقبل العنصر الجذر للتطبيق. و MaterialApp يضبط التصميم وفق Material 3، وهو الافتراضي منذ Flutter 3.16، والخاصية colorSchemeSeed تولّد لوحة ألوان متناسقة كاملة من لون واحد. الصنف يرث من StatelessWidget لأنه لا يحمل بيانات تتغير، ودالته build تصف شكل الواجهة. والصيغة super.key من ميزات Dart 3 وتمرر المفتاح إلى الصنف الأب مباشرة، أما const فتسمح لـ Flutter بإعادة استخدام النسخة نفسها بدل إنشاء نسخة مطابقة في كل مرة.

ملاحظة عن الإصدارات الحديثة: فُصلت مكتبة Material مؤخراً في حزمة مستقلة اسمها material_ui على pub.dev، لذلك قد ترى في بعض صفحات الوثائق السطر import 'package:material_ui/material_ui.dart'; بدل السطر المعتاد. القالب الذي يولّده flutter create على جهازك هو المرجع لنسختك، وتشرح صفحة الحزمة أمر الترحيل dart fix --apply --code=migrate_design_widgets لمن يريد الانتقال.

ثلاث طرق لتطبيق التعديل: إعادة التحميل السريع (Hot Reload) بالحرف r في الطرفية أو بالحفظ في المحرر، وهي تحقن الكود الجديد وتعيد بناء الواجهة مع الإبقاء على حالة التطبيق؛ فإن كنت في الشاشة الثالثة تبقى فيها. إعادة التشغيل السريع (Hot Restart) بالحرف R الكبيرة، وهي تعيد التطبيق إلى بدايته وتصفّر المتغيرات. وإعادة التشغيل الكاملة بإيقاف التطبيق وتشغيله من جديد، وتلزمك عند تعديل كود Kotlin أو Swift أو ملفات المنصات. والطريقتان الأوليان متاحتان في وضع التصحيح (debug) فقط.

القاعدة العملية: تعديل الألوان والنصوص والمسافات وترتيب العناصر يكفيه Hot Reload. أما تعديل الدالة main أو initState أو القيم الابتدائية للمتغيرات العامة فلا يظهر بعده، لأن هذه الأجزاء لا يعاد تنفيذها؛ استخدم Hot Restart. وإذا ظهرت رسالة «Hot reload was rejected» فالسبب غالباً خطأ في الصياغة كقوس ناقص، والرسالة تذكر رقم السطر.

  • lib/main.dart نقطة البداية، و pubspec.yaml مكان الحزم والخطوط والصور.
  • اسم المشروع حروف صغيرة وأرقام وشرطة سفلية فقط.
  • Hot Reload يحفظ الحالة، و Hot Restart يعيد التطبيق إلى بدايته.
  • تعديلات main و initState تحتاج Hot Restart، وتعديلات الكود الأصلي تحتاج تشغيلاً كاملاً.

الهيكل

 main.dart
     |
  runApp()
     |
 MaterialApp
     |
  Scaffold
   ├── AppBar
   └── body

مختبر: غيّر النص واللون

بدّل «أول تطبيق لي» بجملة ترحيب عربية واحفظ الملف، ثم غيّر colorSchemeSeed ولاحظ أن Hot Reload يكفي للحالتين.

مهمة عملية

أنشئ mesue_app وشغّله، ثم غيّر لون البذرة في Theme والعنوان في AppBar باستخدام Hot Reload وحده. النتيجة المقبولة: يظهر التعديلان دون إيقاف التطبيق.

اختبر فهمك (1/3)

غيّرت قيمة ابتدائية داخل initState وضغطت r فلم يتغير شيء. ما التفسير؟

اختبر فهمك (2/3)

أي اسم مشروع يقبله الأمر flutter create؟

اختبر فهمك (3/3)

عدّلت ملف Kotlin داخل مجلد android. كيف ترى أثر التعديل؟

للاستزادة: Flutter: Hot Reload وحالاته الخاصة · Dart: مرجع ملف pubspec · pub.dev: حزمة material_ui

4. Widgets والتخطيط — كل شيء Widget

الهدف: تستخدم Scaffold و Column و Row و Container و Expanded، وتفهم قاعدة القيود لتصلح أخطاء التخطيط.

الواجهة في Flutter شجرة من العناصر المتداخلة. بعض العناصر يستقبل ابناً واحداً عبر الخاصية child، مثل Center و Padding، وبعضها يستقبل قائمة أبناء عبر children، مثل Column و Row. قراءة أي شاشة تبدأ من الجذر نزولاً: Scaffold يحمل AppBar ومحتوى body، والمحتوى عمود فيه صفوف، والصفوف فيها نصوص وأيقونات وأزرار.

قاعدة القيود: تلخّص الوثائق الرسمية طريقة التخطيط في جملة واحدة: القيود تنزل، والأحجام تصعد، والأب يحدد الموضع (Constraints go down. Sizes go up. Parent sets position). كل أب يخبر ابنه بأقل وأكبر عرض وارتفاع مسموح بهما، فيختار الابن حجمه ضمن هذه الحدود ويبلغه لأبيه، ثم يضعه الأب في مكانه. معظم أخطاء التخطيط المحيّرة تُفهم بهذه القاعدة: عنصر لا يكبر لأن أباه لم يمنحه مساحة، أو قائمة تشتكي لأن أباها منحها ارتفاعاً بلا حد.

أدوات التخطيط الأساسية:
• Scaffold: هيكل الشاشة، وفيه شريط علوي appBar ومحتوى body وزر عائم floatingActionButton.
• Column و Row: يرصّان الأبناء عمودياً أو أفقياً. وفي تطبيق عربي يبدأ Row من اليمين تلقائياً.
• Container: صندوق له لون وحشو وحواف مستديرة.
• Padding و SizedBox: حشو داخلي ومسافات ثابتة بين العناصر.
• Expanded: يمنح ابنه كل المساحة المتبقية داخل Row أو Column.

لنبنِ مثالاً حقيقياً: بطاقة منتج لمكتبة قرطاسية في أسيوط تعرض أيقونة واسم المنتج وزر إضافة. البطاقة صف أفقي داخل صندوق ملوّن:

Container(
  padding: const EdgeInsets.all(16),
  decoration: BoxDecoration(
    color: Theme.of(context).colorScheme.surfaceContainerHighest,
    borderRadius: BorderRadius.circular(12),
  ),
  child: Row(
    children: [
      const Icon(Icons.menu_book, size: 40),
      const SizedBox(width: 12),
      const Expanded(
        child: Text('دفتر ملاحظات بغلاف مقوّى، 200 ورقة مسطّرة'),
      ),
      FilledButton(onPressed: () {}, child: const Text('أضف')),
    ],
  ),
)

تتبّع القاعدة هنا: Row يعطي الأيقونة والزر قيوداً مرنة فيأخذ كل منهما حجمه الطبيعي، ثم يعطي النص الملفوف بـ Expanded كل العرض المتبقي بالضبط. لذلك إن طال اسم المنتج التف إلى سطر ثانٍ بدل أن يخرج من حدود الشاشة. اللون مأخوذ من نظام ألوان الثيم بدل كتابة رمز لون ثابت، فيتغير تلقائياً إذا فعّلت الوضع الداكن لاحقاً.

رسالتا خطأ ستراهما كثيراً:
• شريط مخطط بالأصفر والأسود ورسالة «A RenderFlex overflowed by 42 pixels»: أبناء Row أو Column أكبر من المساحة المتاحة. الحل لف العنصر المرن، وغالباً النص، بـ Expanded أو Flexible.
• رسالة «Vertical viewport was given unbounded height»: وضعت ListView داخل Column، والعمود يعطي أبناءه ارتفاعاً غير محدود، والقائمة لا تعرف أين تتوقف. الحل لف القائمة بـ Expanded لتأخذ المساحة المتبقية فقط.

وحين لا تفهم لماذا أخذ عنصر حجماً معيناً، لا تخمّن بتغيير الأرقام عشوائياً. افتح أدوات Flutter DevTools من المحرر، واستخدم مفتش العناصر (Widget Inspector) لتختار العنصر على الشاشة وترى القيود التي وصلته من أبيه والحجم الذي اختاره. غالباً يظهر السبب فوراً: أب يفرض عرضاً ثابتاً، أو ابن بلا Expanded.

للمحاذاة خاصيتان: mainAxisAlignment تتحكم بالتوزيع على المحور الرئيسي، وهو عمودي في Column وأفقي في Row، و crossAxisAlignment تتحكم بالمحور الآخر. وإن احتجت وضع عنصر فوق آخر، كشارة عدد فوق أيقونة السلة، فاستخدم Stack.

أما الأزرار فتتبع في Material 3 تدرجاً في الوزن البصري: FilledButton للإجراء الرئيسي في الشاشة، و OutlinedButton للإجراء الثانوي المهم، و TextButton للإجراءات الأقل أهمية مثل «إلغاء». زر رئيسي واحد في الشاشة يوجّه عين المستخدم إلى الخطوة المتوقعة، وثلاثة أزرار ممتلئة متجاورة تجعل كل شيء يبدو بالأهمية نفسها. وأخيراً، اكتب const قبل العناصر التي لا تتغير قيمها، فلا يعيد Flutter إنشاءها مع كل إعادة بناء للشاشة.

  • القيود تنزل، والأحجام تصعد، والأب يحدد الموضع.
  • Expanded يمنح ابنه المساحة المتبقية ويحل أغلب أخطاء overflow.
  • ListView داخل Column يحتاج Expanded حتى يعرف حدود ارتفاعه.
  • زر FilledButton واحد للإجراء الرئيسي، و TextButton للإجراءات الثانوية.

الهيكل

 Scaffold
 ├── AppBar
 └── Column
      ├── Text
      ├── Row
      │    ├── Icon
      │    ├── Expanded(Text)
      │    └── FilledButton
      └── Expanded(ListView)

مختبر: بطاقة منتج

ابنِ البطاقة، ثم احذف Expanded عمداً وراقب رسالة overflow، ثم أعده واقرأ كيف اختفت.

مهمة عملية

ابنِ شاشة ترحيب: أيقونة كبيرة، ثم عنوان، ثم فقرة وصف، ثم زرّا «ابدأ» و«لاحقاً» في Row، مع مسافة 24 بين العناصر. النتيجة المقبولة: لا شريط overflow حتى مع تكبير خط الهاتف.

اختبر فهمك (1/3)

نص طويل داخل Row أظهر شريطاً أصفر وأسود ورسالة «A RenderFlex overflowed». ما الحل المعتاد؟

اختبر فهمك (2/3)

ظهرت رسالة «Vertical viewport was given unbounded height» بعد وضع ListView داخل Column. ما الإصلاح؟

اختبر فهمك (3/3)

شاشة فيها «حفظ» و«إلغاء». أي تركيب يتبع تدرّج أزرار Material 3؟

للاستزادة: Flutter: التخطيط · Flutter: فهم القيود · Material 3: الأزرار

5. StatefulWidget — عندما تتغير الشاشة

الهدف: تدير حالة محلية بـ setState، ثم تنقل الحالة المشتركة إلى ChangeNotifier مع حزمة provider.

تفرّق وثائق Flutter بين نوعين من الحالة. الحالة المؤقتة (Ephemeral state) تخص عنصراً واحداً، مثل رقم التبويب المختار أو قيمة عدّاد في شاشة واحدة. وحالة التطبيق (App state) تحتاجها عدة شاشات، مثل محتوى سلة المشتريات أو بيانات المستخدم المسجّل. لكل نوع أداة مناسبة، والخلط بينهما يصنع إما كوداً معقداً بلا داعٍ أو بيانات تتعارض بين الشاشات.

للحالة المؤقتة يكفي StatefulWidget. وهو مكوّن من صنفين: الأول يحمل الإعدادات الثابتة، والثاني صنف State يحفظ البيانات المتغيرة ويبني الواجهة. عندما تغيّر متغيراً داخل setState، يعلّم Flutter هذا الجزء بأنه يحتاج إعادة بناء، فيستدعي build من جديد بالقيم الجديدة. هذا متتبع لأكواب الماء اليومية:

class WaterTracker extends StatefulWidget {
  const WaterTracker({super.key});
  @override
  State<WaterTracker> createState() => _WaterTrackerState();
}
class _WaterTrackerState extends State<WaterTracker> {
  int cups = 0;
  void addCup() {
    if (cups >= 8) return;
    setState(() => cups++);
  }
  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        Text('الأكواب: $cups من 8 (${cups * 250} مل)'),
        LinearProgressIndicator(value: cups / 8),
        FilledButton(onPressed: addCup, child: const Text('كوب جديد')),
      ],
    );
  }
}

لاحظ $cups داخل النص: هذا دمج قيمة متغير في السلسلة النصية (String interpolation)، وللتعابير الأطول تُستخدم الأقواس المعقوفة كما في حساب المليلترات. وللصنف State دورة حياة: تُستدعى initState مرة واحدة عند إنشاء العنصر، وهي مكان تجهيز القيم الأولى وبدء طلب البيانات، ثم تُستدعى build مرات كثيرة، وأخيراً dispose عند إزالة العنصر لتحرير الموارد. لا تضع في build عمليات ثقيلة أو طلبات شبكة، لأنها قد تُستدعى مرات عديدة أثناء الحركة.

ثلاثة أخطاء تجعل الزر يبدو معطلاً:
• تغيير المتغير بـ cups++ خارج setState: القيمة تتغير في الذاكرة، لكن Flutter لم يُبلَّغ فلا يعيد البناء.
• تعريف int cups = 0; داخل build: كل إعادة بناء تعيد القيمة إلى صفر. المتغيرات الباقية تُعرَّف في صنف State خارج build.
• استدعاء setState داخل build نفسها: يدخل التطبيق في حلقة إعادة بناء لا تنتهي.

الآن حالة أصعب: تطبيق مكتبة فيه سلة مشتريات تظهر في شاشة المنتجات، وفي شاشة السلة، وفي شارة العدد أعلى الشاشة. لو أبقيت القائمة في State إحدى الشاشات لاضطررت إلى تمريرها ودوال تعديلها عبر constructors من شاشة إلى أخرى. الحل الذي توصي به وثائق Flutter للمبتدئين هو صنف ChangeNotifier يحمل البيانات، مع حزمة provider التي توصله إلى أي عنصر تحته. أضفها بالأمر flutter pub add provider:

class CartModel extends ChangeNotifier {
  final List<String> _items = [];
  List<String> get items => List.unmodifiable(_items);
  void add(String item) {
    _items.add(item);
    notifyListeners();
  }
}
void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => CartModel(),
      child: const MesueApp(),
    ),
  );
}

ثم في أي شاشة تحت المزوّد تقرأ السلة أو تعدّلها:

final count = context.watch<CartModel>().items.length;
Text('في السلة: $count');
FilledButton(
  onPressed: () => context.read<CartModel>().add('دفتر A5'),
  child: const Text('أضف للسلة'),
)

الاستدعاء notifyListeners() يبلغ كل من يراقب النموذج بأن البيانات تغيرت. و context.watch تُستخدم داخل build لأنها تعيد بناء العنصر عند كل تغيير، أما context.read فتُستخدم داخل دوال الضغط لأنها تحتاج النموذج لتنفيذ أمر فقط. والمزوّد يستدعي dispose على النموذج تلقائياً حين لا يعود مطلوباً. والخطأ الشائع هنا عكس الاستخدام: كتابة context.read داخل build لعرض العدد، فيظهر الرقم الصحيح أول مرة ثم يتجمد مهما أضفت، لأن read لا تشترك في التغييرات. وإن رأيت خطأ «Could not find the correct Provider» فالشاشة التي تقرأ النموذج ليست تحت ChangeNotifierProvider في الشجرة؛ ارفع المزوّد فوق MaterialApp كما في المثال.

قاعدة القرار: إن كانت البيانات تخص عنصراً واحداً فاستخدم setState ولا تبحث عن غيرها. وإن احتاجتها شاشتان أو أكثر فانقلها إلى ChangeNotifier فوق تلك الشاشات. وهناك حلول أكبر مثل Riverpod و Bloc تناسب التطبيقات الكبيرة، لكنها ليست شرطاً لأول تطبيق. وإن أردت تجنب أي حزمة، فالعنصر المدمج ListenableBuilder يعيد البناء عند تغيّر ChangeNotifier دون provider.

  • الحالة المؤقتة تخص عنصراً واحداً ويكفيها setState.
  • المتغيرات الباقية تُعرَّف في صنف State خارج build.
  • الحالة المشتركة بين الشاشات تنتقل إلى ChangeNotifier مع provider.
  • context.watch داخل build، و context.read داخل دوال الضغط.

مخطط

زر +
setStateحالة عنصر واحد
build()
أضف للسلة
notifyListenersحالة مشتركة
كل شاشة تراقب CartModel

مختبر: من setState إلى provider

ابنِ المتتبع بـ setState أولاً، ثم أنشئ CartModel واعرض عدد العناصر في شاشتين مختلفتين.

مهمة عملية

أضف إلى المتتبع زر «تراجع» لا يسمح بقيمة سالبة، ثم انقل السلة إلى CartModel واعرض عددها في AppBar وفي شاشة السلة. النتيجة المقبولة: الإضافة من شاشة تحدّث العدد في الشاشتين فوراً.

اختبر فهمك (1/3)

ضغطت زر الإضافة فزادت قيمة cups في الذاكرة لكن الشاشة لم تتغير. ما السبب؟

اختبر فهمك (2/3)

عرّفت int cups = 0; في أول سطر داخل build. ماذا يحدث عند كل ضغطة؟

اختبر فهمك (3/3)

سلة مشتريات يجب أن تظهر في ثلاث شاشات مختلفة. ما الخيار الأبسط الموصى به للمبتدئين؟

للاستزادة: Flutter: إضافة التفاعل و StatefulWidget · Flutter: إدارة الحالة البسيطة · pub.dev: حزمة provider

6. التنقل — Navigator وشاشات متعددة

الهدف: تنتقل بين الشاشات بـ push و pop، وتمرر البيانات ذهاباً وإياباً، وتعرف متى تستخدم التبويبات أو go_router.

يدير Navigator في Flutter الشاشات كمكدس (Stack) من الأوراق: كل شاشة جديدة توضع فوق السابقة، والرجوع يرفع الورقة العليا فتظهر التي تحتها. الدالة push تضع ورقة، والدالة pop ترفعها، وزر الرجوع في الهاتف أو في AppBar ينفذ pop تلقائياً.

لنأخذ حالة عملية: مركز دروس تقوية في الإسكندرية يريد تطبيقاً لطلابه. الشاشة الرئيسية قائمة دروس وعدّاد للدروس المنتهية، والضغط على درس يفتح شاشة تفاصيله، وفيها زر «أنهيت الدرس» يغلق الشاشة ويزيد العدّاد في الرئيسية. هنا تحتاج أمرين: إرسال عنوان الدرس إلى الشاشة الجديدة، واستقبال نتيجة منها عند الرجوع. هذه شاشة التفاصيل:

class LessonPage extends StatelessWidget {
  const LessonPage({super.key, required this.title});
  final String title;
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(title)),
      body: Center(
        child: FilledButton(
          onPressed: () => Navigator.pop(context, true),
          child: const Text('أنهيت الدرس'),
        ),
      ),
    );
  }
}

ومن الشاشة الرئيسية، داخل صنف State الذي يحمل العدّاد:

onTap: () async {
  final done = await Navigator.push<bool>(
    context,
    MaterialPageRoute(builder: (_) => const LessonPage(title: 'المتغيرات')),
  );
  if (!mounted) return;
  if (done == true) setState(() => completed++);
},

تتبّع الرحلة: البيانات تذهب إلى الشاشة الجديدة عبر الـ constructor في المعامل title، وتعود منها قيمة عبر Navigator.pop(context, true). الدالة push تُرجع Future يكتمل عند إغلاق الشاشة، لذلك تُنتظر بـ await. وإذا رجع الطالب بزر الرجوع دون الضغط على الزر تكون القيمة null، ولهذا جاءت المقارنة بـ == true بدل افتراض وجود قيمة. أما السطر if (!mounted) return; فتطلبه وثائق Flutter بعد أي انتظار: ربما أُغلقت الشاشة الرئيسية نفسها أثناء الانتظار، واستدعاء setState على عنصر أُزيل يسبب خطأ.

ليس كل تنقل مكدساً. الأقسام الرئيسية المتوازية، مثل «الرئيسية» و«الدروس» و«حسابي»، تُبنى في Material 3 بشريط سفلي NavigationBar مع متغير يحفظ رقم القسم، فيتبدل محتوى body دون push:

bottomNavigationBar: NavigationBar(
  selectedIndex: _tab,
  onDestinationSelected: (i) => setState(() => _tab = i),
  destinations: const [
    NavigationDestination(icon: Icon(Icons.home), label: 'الرئيسية'),
    NavigationDestination(icon: Icon(Icons.menu_book), label: 'الدروس'),
    NavigationDestination(icon: Icon(Icons.person), label: 'حسابي'),
  ],
),

تتبّع المكدس خطوة بخطوة: تخيّل الأوراق بعد كل فعل، فهذا يكشف معظم أخطاء التنقل قبل أن تكتب سطراً:
1) فتح التطبيق: المكدس [الرئيسية].
2) الضغط على درس المتغيرات: push فيصبح المكدس [الرئيسية، تفاصيل المتغيرات].
3) الضغط على «أنهيت الدرس»: pop مع القيمة true فيعود المكدس [الرئيسية] ويزيد العدّاد.
4) لو فتحت الرئيسية بـ push بدل pop في الخطوة الثالثة لصار المكدس [الرئيسية، التفاصيل، الرئيسية]، وهذا مصدر شكوى «زر الرجوع لا ينتهي».

وعندما تكبر البيانات المرسلة، مرّر الكائن كله بدل حقول متفرقة: شاشة التفاصيل تستقبل Lesson lesson بدل العنوان والمدة والمعلم كل على حدة، فيبقى الـ constructor قصيراً، ويُضاف حقل جديد إلى الصنف دون تعديل كل استدعاء. وتجنب المتغيرات العامة لنقل البيانات بين الشاشات، لأنها تبقى بقيمها القديمة بعد الرجوع وتصنع أخطاء يصعب تتبعها.

القاعدة العملية: الأقسام المتساوية التي يتنقل بينها المستخدم ذهاباً وإياباً تبويبات، والانتقال من قائمة إلى تفاصيل عنصر فيها يكون بـ push. وماذا عن المسارات المسماة مثل Navigator.pushNamed(context, '/settings')؟ تذكر الوثائق الرسمية أنها لا توصي بها لمعظم التطبيقات بسبب قيودها مع الروابط العميقة (Deep links) وزر التقدم في المتصفح، وتنصح بـ Navigator مع MaterialPageRoute للتطبيقات البسيطة، أو بحزمة توجيه مثل go_router عندما تحتاج روابط عميقة أو نسخة ويب.

أخطاء تنقل شائعة وعلاجها:
• استخدام push للعودة إلى الرئيسية: كل ضغطة تضيف نسخة جديدة فوق المكدس، فيضطر المستخدم للضغط على الرجوع مرات كثيرة. استخدم pop.
• بقاء شاشة الدخول خلف الرئيسية بعد تسجيل الدخول: استخدم pushReplacement حتى تُستبدل الشاشة بدل أن توضع فوقها.
• زر رجوع لا تريده في شاشة ترحيب: اضبط automaticallyImplyLeading: false داخل AppBar.

  • push يضع شاشة فوق المكدس، و pop يرفعها ويمكن أن يعيد قيمة.
  • القيمة العائدة قد تكون null إذا رجع المستخدم بزر النظام.
  • افحص mounted بعد await قبل استدعاء setState.
  • الأقسام المتوازية تبويبات NavigationBar، والتفاصيل بـ push.
  • للروابط العميقة ونسخ الويب استخدم go_router بدل المسارات المسماة.

مخطط

 Home --push(title)--> Detail
   ^                    |
   +---- pop(true) -----+

 [الرئيسية | الدروس | حسابي]  ← NavigationBar

مختبر: 3 شاشات

قائمة دروس، ثم تفاصيل درس تستقبل العنوان وتعيد true، ثم عدّاد يزيد في الرئيسية بعد الرجوع.

مهمة عملية

ابنِ تطبيقاً بثلاثة تبويبات في NavigationBar، ومن تبويب الدروس افتح شاشة تفاصيل تعيد نتيجة. النتيجة المقبولة: العدّاد يزيد عند الضغط على «أنهيت الدرس» ولا يتغير عند الرجوع بزر النظام.

اختبر فهمك (1/3)

كيف تعيد الشاشة المفتوحة بـ push قيمة إلى الشاشة التي فتحتها؟

اختبر فهمك (2/3)

بعد تسجيل الدخول لا تريد أن يعود المستخدم لشاشة الدخول بزر الرجوع. ماذا تستخدم؟

اختبر فهمك (3/3)

تطبيقك فيه ثلاثة أقسام متوازية: الرئيسية، والدروس، وحسابي. ما البناء المناسب؟

للاستزادة: Flutter: التنقل والتوجيه · Flutter Cookbook: إعادة بيانات من شاشة · pub.dev: حزمة go_router

7. قوائم ونماذج — ListView و TextField

الهدف: تعرض قائمة قابلة للتمرير بكفاءة، وتجمع إدخال المستخدم في نموذج يتحقق من صحته قبل الحفظ.

مركز تدريب في عمّان ينظم ورشة برمجة مجانية، ويريد شاشة بسيطة يسجل فيها الموظف المشاركين: الاسم ورقم الهاتف، ثم قائمة تعرض من سُجّل. الشاشة تجمع أكثر عنصرين استخداماً في التطبيقات: القائمة القابلة للتمرير، والنموذج الذي يرفض المدخلات الخاطئة قبل حفظها.

للقوائم الطويلة استخدم ListView.builder، فهو يبني العناصر الظاهرة على الشاشة فقط، ويبني الباقي عند التمرير، لذلك يبقى سريعاً حتى مع ألف عنصر. أما Column بألف عنصر فيبنيها كلها دفعة واحدة، ولا يتمرر أصلاً ما لم يُلف بعنصر تمرير. وعنصر ListTile صف جاهز فيه أماكن للأيقونة والعنوان والوصف، فيغنيك عن بناء كل سطر يدوياً. هذه حقول الحالة ودالة الإرسال داخل صنف State:

final _formKey = GlobalKey<FormState>();
final _nameCtrl = TextEditingController();
final List<String> _names = [];
@override
void dispose() {
  _nameCtrl.dispose();
  super.dispose();
}
void _submit() {
  if (!_formKey.currentState!.validate()) return;
  setState(() => _names.add(_nameCtrl.text.trim()));
  _nameCtrl.clear();
}

وفي build، داخل Column:

Form(
  key: _formKey,
  child: TextFormField(
    controller: _nameCtrl,
    textInputAction: TextInputAction.done,
    decoration: const InputDecoration(labelText: 'اسم المشارك'),
    validator: (v) {
      final name = (v ?? '').trim();
      if (name.isEmpty) return 'الاسم مطلوب';
      if (name.length < 3) return 'ثلاثة أحرف على الأقل';
      return null;
    },
  ),
),
FilledButton(onPressed: _submit, child: const Text('إضافة')),
Expanded(
  child: _names.isEmpty
      ? const Center(child: Text('لا يوجد مشاركون بعد، أضف أول اسم'))
      : ListView.builder(
          itemCount: _names.length,
          itemBuilder: (context, i) => ListTile(
            leading: CircleAvatar(child: Text('${i + 1}')),
            title: Text(_names[i]),
          ),
        ),
),

كيف يعمل التحقق؟ دالة validator تُرجع نص الخطأ فيظهر تحت الحقل باللون الأحمر، أو تُرجع null إذا كانت القيمة سليمة. والاستدعاء validate() يشغّل كل دوال التحقق في النموذج معاً، ويعيد بناء النموذج لإظهار الرسائل، ويُرجع true فقط إذا نجحت جميعها. لذلك تبدأ دالة الإرسال بفحصه، ولا يُضاف أي اسم قبل أن ينجح.

لماذا dispose؟ الـ TextEditingController يحجز موارد ويستمع للتغييرات، وإن لم تتخلص منه عند إغلاق الشاشة بقي في الذاكرة. القاعدة: كل controller تنشئه في State تتخلص منه في dispose. والخطأ المقابل أخطر: إنشاء الـ controller داخل build، فتنشئ كل إعادة بناء controller جديداً فارغاً، ويختفي ما كتبه المستخدم فجأة.

حين يضم النموذج أكثر من حقل، كالاسم ثم الهاتف ثم البريد، اجعل زر لوحة المفاتيح ينقل المستخدم إلى الحقل التالي بضبط textInputAction: TextInputAction.next في كل حقل عدا الأخير، واجعل الأخير TextInputAction.done ويستدعي دالة الإرسال عبر onFieldSubmitted. فائدة عنصر Form هنا أنه يجمع الحقول كلها تحت مفتاح واحد، فيفحصها استدعاء واحد لـ validate بدل فحص كل حقل يدوياً، ويستطيع تفريغها كلها معاً بالاستدعاء reset().

انتبه أيضاً إلى أن القائمة في هذا المثال تعيش في الذاكرة فقط: إذا أُغلق التطبيق ضاعت الأسماء. هذا مقبول في مرحلة التعلم، لكن الموظف في مركز التدريب سيلاحظه من أول يوم. الخطوة التالية الطبيعية حفظ القائمة على الجهاز، وأبسط خيار لبيانات صغيرة كهذه حزمة shared_preferences على pub.dev، أما البيانات الكثيرة أو المترابطة فتحتاج قاعدة بيانات محلية.

قبل وبعد: كثير من الأمثلة تكتب التحقق هكذا (v) => v!.isEmpty ? 'مطلوب' : null. هذه النسخة تفشل في أمرين: علامة التعجب تفترض أن القيمة ليست null فتنهار إن كانت كذلك، والحقل المملوء بمسافات فقط يمر كأنه اسم. النسخة في المثال تستبدل null بنص فارغ عبر ?? ثم تحذف المسافات بـ trim() قبل الفحص، فتعالج الحالتين.

قائمة مراجعة لنموذج مريح:
• لوحة المفاتيح المناسبة: keyboardType: TextInputType.phone لحقل الهاتف، و TextInputType.emailAddress للبريد.
• الأرقام العربية: بعض المستخدمين يكتبون الرقم بالأرقام الهندية مثل ٠٧٩، فتعبير مثل RegExp(r'^[0-9]+$') سيرفضه. حوّل هذه الأرقام إلى 0-9 قبل الفحص أو اقبلها صراحة.
• توقيت الرسائل: autovalidateMode: AutovalidateMode.onUserInteraction يؤجل ظهور الخطأ حتى يبدأ المستخدم الكتابة في الحقل.
• حالة القائمة الفارغة: نص يشرح الخطوة التالية كما في المثال، بدل مساحة بيضاء صامتة.
• الفواصل بين العناصر: ListView.separated بدل إضافة Divider يدوياً.

  • ListView.builder يبني العناصر الظاهرة فقط فيبقى سريعاً مع القوائم الطويلة.
  • validator يُرجع نص الخطأ أو null، و validate() تشغّل كل التحققات معاً.
  • افحص null واحذف المسافات بـ trim قبل قبول أي نص.
  • أنشئ الـ controller حقلاً في صنف State وتخلّص منه في dispose.

الهيكل

 Form
  ├── TextFormField → validator
  └── FilledButton → validate() → setState

 ListView.builder
  ├── ListTile 1
  ├── ListTile 2
  └── ...

مختبر: نموذج تسجيل

Form بحقلي الاسم والهاتف، وزر يتحقق قبل الإضافة، وقائمة تعرض المسجلين مع رسالة للقائمة الفارغة.

مهمة عملية

حوّل المثال إلى قائمة مشتريات: حقل للصنف، وحقل للكمية بلوحة أرقام مع رسالة خطأ إن لم تكن الكمية رقماً موجباً (استعن بـ int.tryParse)، وزر حذف لكل عنصر. النتيجة المقبولة: لا يُضاف صنف بكمية صفر أو نص.

اختبر فهمك (1/3)

لقائمة من ألف عنصر، أي خيار يبني فقط العناصر الظاهرة على الشاشة؟

اختبر فهمك (2/3)

كلما أعيد بناء الشاشة اختفى ما كتبه المستخدم في الحقل. ما السبب الأرجح؟

اختبر فهمك (3/3)

أي validator يرفض حقلاً فيه مسافات فقط دون أن ينهار عند null؟

للاستزادة: Flutter Cookbook: نموذج مع تحقق · Flutter API: ListView

8. ربط API — http و JSON

الهدف: تجلب بيانات JSON من خادم، وتحوّلها إلى كائنات Dart، وتعرضها بحالات التحميل والخطأ والنجاح.

أغلب التطبيقات تعرض بيانات من خادم: أسعار، أو أخبار، أو مواعيد. يرسلها الخادم غالباً بصيغة JSON، وهي نص منظم من مفاتيح وقيم. الأقواس المربعة في JSON تعني قائمة وتصبح List في Dart، والأقواس المعقوفة تعني كائناً ويصبح Map. هذا الدرس يمشي خطوة بخطوة من إضافة الحزمة إلى شاشة تتعامل مع كل ما قد يحدث في الطريق.

الخطوة الأولى: الحزمة. نفّذ flutter pub add http، فيُضاف سطر الحزمة بإصدارها الحالي تحت dependencies في pubspec.yaml. حزمة http من فريق Dart وتعمل على Android و iOS والويب وسطح المكتب. سنستخدم في الأمثلة خدمة JSONPlaceholder التجريبية المجانية، وهي التي تستخدمها وصفات Flutter الرسمية أيضاً.

قبل كتابة أي كود، افتح عنوان الخادم في المتصفح واقرأ الرد بعينك: هل هو قائمة أم كائن؟ ما أسماء المفاتيح بالضبط، وهل الرقم يأتي رقماً أم نصاً بين علامتي تنصيص؟ هذه الدقيقة توفر عليك أخطاء التحويل لاحقاً، لأن كود Dart يجب أن يطابق شكل البيانات كما يرسلها الخادم فعلاً. وإن كان الخادم يحتاج ترويسات أو تفويضاً فجرّبه بأداة مثل curl قبل نقله إلى التطبيق.

الخطوة الثانية: نموذج البيانات ودالة الجلب. بدل تمرير Map خام في كل مكان، حوّل كل عنصر إلى كائن بأنواع واضحة. وفي Dart 3 تستطيع فحص شكل JSON بمطابقة الأنماط (Pattern matching) في سطر واحد:

import 'dart:convert';
import 'package:http/http.dart' as http;
class Post {
  const Post({required this.id, required this.title});
  final int id;
  final String title;
  factory Post.fromJson(Map<String, dynamic> json) {
    return switch (json) {
      {'id': int id, 'title': String title} => Post(id: id, title: title),
      _ => throw const FormatException('شكل منشور غير متوقع'),
    };
  }
}
Future<List<Post>> fetchPosts() async {
  final uri = Uri.parse('https://jsonplaceholder.typicode.com/posts');
  final res = await http.get(uri).timeout(const Duration(seconds: 10));
  if (res.statusCode != 200) {
    throw Exception('رمز الاستجابة ${res.statusCode}');
  }
  final list = jsonDecode(res.body) as List;
  return list.map((e) => Post.fromJson(e as Map<String, dynamic>)).toList();
}

الكلمة async تجعل الدالة تُرجع Future، و await تنتظر الشبكة دون تجميد الواجهة. والسطر switch يتأكد أن المفتاح id رقم صحيح وأن title نص؛ وإن جاء الشكل مختلفاً رمى FormatException برسالة واضحة بدل خطأ غامض في مكان بعيد. لاحظ أيضاً أن الدالة ترمي استثناءً عند أي رمز غير 200، ولا تُرجع null؛ هذا ما يجعل الواجهة تميّز الخطأ عن النجاح.

الخطوة الثالثة: الواجهة بثلاث حالات. أنشئ الطلب مرة واحدة في initState واحفظه في متغير، ثم مرّره إلى FutureBuilder:

late Future<List<Post>> _postsFuture;
@override
void initState() {
  super.initState();
  _postsFuture = fetchPosts();
}
@override
Widget build(BuildContext context) {
  return FutureBuilder<List<Post>>(
    future: _postsFuture,
    builder: (context, snap) {
      if (snap.hasError) return Center(child: Text('تعذّر التحميل: ${snap.error}'));
      if (!snap.hasData) return const Center(child: CircularProgressIndicator());
      final posts = snap.data!;
      return ListView.builder(
        itemCount: posts.length,
        itemBuilder: (_, i) => ListTile(title: Text(posts[i].title)),
      );
    },
  );
}

لماذا initState؟ لأن build تُستدعى كثيراً، وكتابة future: fetchPosts() داخلها ترسل طلباً جديداً إلى الخادم مع كل إعادة بناء. ولزر «إعادة المحاولة» أعد إسناد المتغير داخل setState فيبدأ طلب واحد جديد. وإن كانت الاستجابة ضخمة، بآلاف العناصر، فوثائق Flutter تشرح فك JSON في خيط خلفي (Isolate) حتى لا تتقطع الحركة على الشاشة أثناء التحويل.

سجل أعطال الشبكة: ما الذي يعنيه كل عرض، وأين تبحث:
• رمز 404: العنوان غير موجود؛ راجع المسار حرفاً حرفاً.
• رمز 401 أو 403: الخادم يطلب تعريفاً أو يرفض الصلاحية؛ راجع ترويسة التفويض.
• رمز 500: الخادم نفسه تعطل؛ لا يُصلح من التطبيق، فاعرض رسالة لطيفة وزر إعادة.
• TimeoutException: لم يصل رد خلال عشر ثوانٍ؛ شبكة ضعيفة أو خادم بطيء.
• استثناء اتصال بلا أي رمز: الهاتف بلا إنترنت أو اسم الخادم خاطئ.
• FormatException: شكل JSON لا يطابق النموذج؛ اطبع res.body وقارنه بالمفاتيح المتوقعة.

وهناك عطل لا يظهر إلا بعد النشر: نسخة التطوير تعمل، ونسخة release على Android تفشل في كل الطلبات. السبب أن Android يشترط التصريح بإذن الإنترنت في ملف AndroidManifest.xml الرئيسي بالسطر <uses-permission android:name="android.permission.INTERNET" />، والقالب يضيفه لنسخة التصحيح فقط. وأخيراً، أي مفتاح API سري داخل التطبيق يمكن استخراجه من ملف التثبيت؛ المفاتيح السرية مكانها خادم وسيط، والتطبيق يتصل بذلك الخادم.

  • حوّل JSON إلى أصناف بأنواع واضحة، ومطابقة الأنماط في Dart 3 تفحص الشكل بسطر واحد.
  • ارمِ استثناءً عند أي رمز غير 200 حتى تميّز الواجهة الخطأ عن النجاح.
  • أنشئ الـ Future مرة واحدة في initState ومرّر المتغير إلى FutureBuilder.
  • إذن INTERNET في Manifest الرئيسي شرط لنسخة release على Android.
  • لا مفاتيح سرية داخل التطبيق المنشور.

مخطط

Apphttp.getAPI
statusCode?
200
غير ذلك
jsonDecode
Exception
FutureBuilder: بياناتخطأتحميل

مختبر: منشور عشوائي

اعرض عنوان منشور مع زر «منشور جديد» يعيد إسناد الـ Future داخل setState، ثم افصل الإنترنت وراقب رسالة الخطأ.

مهمة عملية

اعرض أول عشرين منشوراً فقط باستخدام take(20)، وأضف RefreshIndicator للسحب والتحديث، ورسالة واضحة مع زر «إعادة المحاولة» عند فشل الاتصال. النتيجة المقبولة: وضع الطيران يُظهر الرسالة، وإعادة الاتصال ثم الضغط على الزر تعيد القائمة.

اختبر فهمك (1/3)

أين تضع مفتاح API سرياً يحتاجه تطبيقك؟

اختبر فهمك (2/3)

كتبت future: fetchPosts() مباشرة داخل build. ما المشكلة؟

اختبر فهمك (3/3)

التطبيق يجلب البيانات أثناء التطوير، لكن نسخة release على Android تفشل في كل الطلبات. ما الأرجح؟

للاستزادة: Flutter Cookbook: جلب البيانات من الإنترنت · Flutter: JSON والتسلسل · Flutter: الشبكات وإذن الإنترنت · pub.dev: حزمة http

9. العربية و RTL — تجربة مستخدم محلية

الهدف: تضبط الاتجاه ونصوص الإطار، وتترجم نصوصك بملفات ARB، وتنسّق التواريخ والأرقام بـ intl.

تعريب التطبيق ثلاث طبقات، ولكل طبقة أداة. الطبقة الأولى الاتجاه ونصوص الإطار الجاهزة، مثل «إلغاء» و«موافق» في منتقي التاريخ. والطبقة الثانية نصوصك أنت: العناوين والأزرار والرسائل. والطبقة الثالثة التنسيق: التواريخ والأرقام والعملات وصيغ الجمع. كثير من التطبيقات تنجز الأولى وتنسى الباقي، فتظهر واجهة من اليمين لليسار برسالة «1 منتجات».

الطبقة الأولى: flutter_localizations. أضف الحزمتين بالأمر الوارد في الوثائق الرسمية: flutter pub add flutter_localizations:"{sdk: flutter}" intl:any، ثم اضبط MaterialApp:

import 'package:flutter_localizations/flutter_localizations.dart';
MaterialApp(
  locale: const Locale('ar'),
  supportedLocales: const [Locale('ar'), Locale('en')],
  localizationsDelegates: GlobalMaterialLocalizations.delegates,
  theme: ThemeData(colorSchemeSeed: Colors.teal, fontFamily: 'Cairo'),
  home: const HomePage(),
)

القائمة GlobalMaterialLocalizations.delegates تجمع مندوبي الترجمة (Delegates) الخاصين بـ Material و Cupertino والعناصر الأساسية. بهذا يقلب Flutter الاتجاه في التطبيق كله، وتظهر نصوص العناصر الجاهزة بالعربية. وإن حذفت السطر locale اختار Flutter لغة الجهاز إن كانت ضمن supportedLocales. لا تلف كل شاشة بـ Directionality؛ استخدمه للاستثناءات فقط، مثل حقل لإدخال رابط إنجليزي يجب أن يبقى من اليسار لليمين.

الطبقة الثانية: ملفات ARB. بدل كتابة النصوص داخل الكود، ضع generate: true تحت قسم flutter في pubspec.yaml، وأنشئ في جذر المشروع ملف l10n.yaml:

arb-dir: lib/l10n
template-arb-file: app_ar.arb
output-localization-file: app_localizations.dart

ثم ملف lib/l10n/app_ar.arb، وفيه رسالة تحتاج صيغ الجمع العربية:

{
  "cartCount": "{count, plural, =0{السلة فارغة} =1{منتج واحد} =2{منتجان} few{{count} منتجات} many{{count} منتجاً} other{{count} منتج}}",
  "@cartCount": {"placeholders": {"count": {"type": "int"}}}
}

العربية تغيّر صيغة المعدود حسب العدد: ثلاثة منتجات، وأحد عشر منتجاً، ومئة منتج، ولذلك تحتاج الرسالة حالات few و many و other إضافة إلى الصفر والواحد والاثنين. أنشئ ملف app_en.arb بالمفاتيح نفسها، ثم شغّل التطبيق أو نفّذ flutter gen-l10n فيتولّد صنف AppLocalizations. استورده كما في الوثائق بالسطر import 'l10n/app_localizations.dart';، وأضف AppLocalizations.delegate إلى قائمة المندوبين، ثم اكتب في أي شاشة AppLocalizations.of(context)!.cartCount(5).

الطبقة الثالثة: intl للتنسيق. مثالان سريعان:
• DateFormat.yMMMMd('ar').format(date) يكتب التاريخ باسم الشهر العربي بدل الصيغة الرقمية المبهمة.
• NumberFormat.decimalPattern('ar').format(1250.5) يضع فواصل الآلاف والكسور وفق قواعد اللغة.
• الأرقام قد تظهر هندية أو لاتينية حسب رمز المنطقة، فجرّب ar و ar_EG واختر واحداً تلتزم به في التطبيق كله.
• استخدام DateFormat في كود Dart خالص قبل تحميل الترجمة قد يرمي خطأ يطلب initializeDateFormatting؛ داخل الواجهة بعد تحميل المندوبين تكون البيانات جاهزة.

المسافات والمحاذاة يجب أن تتبع الاتجاه أيضاً. استخدم EdgeInsetsDirectional.only(start: 16) بدل EdgeInsets.only(left: 16)، فكلمة start تعني اليمين في العربية واليسار في الإنجليزية، فتنعكس المسافة وحدها عند تبديل اللغة. والأمر نفسه مع AlignmentDirectional.centerStart. والخط يحتاج تصريحاً في pubspec.yaml تحت قسم fonts، مع خط يدعم العربية جيداً مثل Cairo أو Tajawal أو Noto Naskh Arabic، وانتبه إلى المسافات البادئة في YAML لأن إزاحة سطر واحد خطأً تُفشل قراءة الملف.

أخطاء تكشفها المراجعة البصرية: ضبط locale دون مندوبي الترجمة يبقي الواجهة من اليسار لليمين مع تحذير في الطرفية. وأسهم Icons المدمجة مثل arrow_back تنعكس تلقائياً، أما صورة سهم بصيغة PNG أو أيقونة من حزمة خارجية فقد لا تنعكس، فراجع كل سهم بعينك. والجملة العربية التي تبدأ بكلمة إنجليزية أو رقم قد تظهر بترتيب مربك؛ ابدأها بكلمة عربية قدر الإمكان، وجرّب الأزرار بنصوص أطول من المتوقع. وأفضل اختبار نهائي أن تغيّر لغة الهاتف نفسه إلى العربية ثم إلى الإنجليزية وتمر على كل الشاشات، فبعض المشكلات لا تظهر إلا مع لغة النظام الفعلية.

  • flutter_localizations يقلب الاتجاه ويعرّب نصوص العناصر الجاهزة.
  • نصوصك مكانها ملفات ARB، وصيغ الجمع العربية تحتاج few و many و other.
  • intl ينسّق التواريخ والأرقام وفق اللغة، فاختر رمز منطقة واحداً للأرقام.
  • EdgeInsetsDirectional و AlignmentDirectional تنعكس وحدها مع اللغة.

مخطط

الطبقة 1: الاتجاه ونصوص الإطارflutter_localizations
الطبقة 2: نصوصكARB + AppLocalizations
الطبقة 3: التواريخ والأرقامintl
RTL:زرالنص
LTR: Textbtn

مختبر: واجهة عربية كاملة

انقل كل نصوص شاشة واحدة إلى app_ar.arb، وأضف رسالة جمع لعدد العناصر، وجرّبها بالأعداد 0 و 1 و 2 و 5 و 11 و 100.

مهمة عملية

طبّق الطبقات الثلاث على مشروعك، وأضف في الإعدادات زراً يبدّل بين العربية والإنجليزية عبر متغير locale. النتيجة المقبولة: تنعكس المسافات والأسهم، وتتغير صيغة الجمع صحيحاً في اللغتين.

اختبر فهمك (1/3)

كتبت locale: const Locale('ar') فبقيت الواجهة من اليسار لليمين مع تحذير في الطرفية. ما الناقص؟

اختبر فهمك (2/3)

أي مسافة تنعكس تلقائياً عند التبديل بين العربية والإنجليزية؟

اختبر فهمك (3/3)

في ملف ARB العربي، لماذا تحتاج رسالة عدد المنتجات صيغة plural بعدة حالات؟

للاستزادة: Flutter: تدويل التطبيقات · pub.dev: حزمة intl · Flutter Cookbook: استخدام خط مخصص

10. المشروع النهائي — MVP جاهز للعرض

الهدف: تسلّم تطبيقاً بثلاث شاشات وحالة ونموذج وواجهة عربية، مع اختبار ونسخة موقّعة وخطة نشر.

المشروع النهائي يجمع ما سبق في تطبيق صغير يمكن عرضه على صديق أو وضعه في محفظة أعمالك. خذ مثالاً تخيّلياً: نادي قراءة في مدرسة ثانوية يريد «متتبع قراءة» يسجل فيه كل طالب الكتب التي يقرؤها وعدد الصفحات. الفكرة محددة، والمستخدمون معروفون، ويمكن قياس النجاح بسهولة.

الحد الأدنى المطلوب:
• ثلاث شاشات متصلة: قائمة الكتب، وتفاصيل كتاب، وإضافة كتاب أو إعدادات.
• حالة تتحدث عند الإضافة والحذف، بـ setState أو بـ ChangeNotifier إن تشاركتها الشاشات.
• نموذج واحد على الأقل يتحقق من المدخلات، مثل رفض عدد صفحات سالب.
• واجهة عربية كاملة من اليمين لليسار بخط واضح ونصوص في ملف ARB.
• اختياري: بيانات من API مع حالتي التحميل والخطأ.

لا تترك كل شيء في main.dart. قسّم الملفات داخل lib حسب الدور، فملف لكل شاشة يسهّل القراءة والمراجعة والعثور على الأخطاء:

lib/
  main.dart
  models/book.dart
  state/reading_model.dart
  screens/home_screen.dart
  screens/book_detail_screen.dart
  screens/add_book_screen.dart
  widgets/book_tile.dart
  l10n/app_ar.arb

قبل التسليم اكتب اختباراً واحداً على الأقل للرحلة الأساسية. اختبار العناصر (Widget test) يشغّل الواجهة في بيئة اختبار سريعة دون هاتف، ويكتب ويضغط كما يفعل المستخدم:

import 'package:flutter_test/flutter_test.dart';
import 'package:mesue_app/main.dart';
void main() {
  testWidgets('إضافة كتاب تظهره في القائمة', (tester) async {
    await tester.pumpWidget(const MesueApp());
    await tester.enterText(find.byType(TextFormField), 'رجال في الشمس');
    await tester.tap(find.text('إضافة'));
    await tester.pump();
    expect(find.text('رجال في الشمس'), findsOneWidget);
  });
}

الدالة pump تطلب إعادة بناء الإطار بعد الضغط، ثم يتأكد expect أن الاسم ظهر مرة واحدة. يعمل هذا الفحص لأن دالة الإرسال تفرّغ الحقل بعد الإضافة؛ لو بقي النص في الحقل لوُجد مرتين وفشل الاختبار، وهذا بحد ذاته اكتشاف مفيد لسلوك الواجهة.

خطوات ما قبل النشر بالترتيب:
1) flutter analyze يكشف الأخطاء والتحذيرات قبل التشغيل، و flutter test يشغّل الاختبارات.
2) flutter build apk --release ينتج ملف APK في build/app/outputs/flutter-apk/ لتثبيته على هاتف حقيقي وتجربته.
3) أنشئ مفتاح الرفع بالأداة keytool كما في دليل Flutter لنشر Android، وضع بياناته في ملف android/key.properties.
4) flutter build appbundle ينتج حزمة AAB، وهي الصيغة التي يطلبها Google Play للتطبيقات الجديدة.
5) قبل كل رفع جديد زد رقم البناء في السطر version: 1.0.0+1 من pubspec.yaml، فالرقم بعد علامة + يصبح versionCode، والمتجر يرفض رقماً استُخدم من قبل.

الجزء الذي قبل علامة + هو اسم الإصدار الذي يراه المستخدم في صفحة المتجر. اتفاق شائع أن تزيد الرقم الأخير عند إصلاح خطأ، والأوسط عند إضافة ميزة، والأول عند تغيير كبير في التطبيق. أما رقم البناء بعد + فيزيد مع كل رفع مهما كان صغيراً.

أخطر خطأ في هذه المرحلة رفع ملف المفتاح upload-keystore.jks أو ملف key.properties إلى GitHub. الوثائق الرسمية تطلب صراحة إبقاء key.properties خاصاً وخارج المستودعات العامة، لأن من يحصل على المفتاح وكلمة مروره يستطيع توقيع نسخ تبدو صادرة منك. أضفهما إلى .gitignore قبل أول commit، واحفظ نسخة احتياطية من المفتاح في مكان آمن. أما iOS فيمر عبر Xcode ثم TestFlight للتجربة قبل App Store، ويحتاج حساب مطوّر لدى Apple.

ملف README هو أول ما يقرؤه المراجع أو صاحب العمل: وصف التطبيق في سطرين، وخطوات التشغيل flutter pub get ثم flutter run، وقائمة الميزات، وثلاث لقطات شاشة. ومعيار النجاح النهائي عملي: يفتح التطبيق دون أعطال على هاتف حقيقي، ويستطيع طالب لم يره قبلاً أن يضيف كتاباً ويفتح تفاصيله خلال دقيقة دون شرح. راقب أثناء التجربة لوحة المفاتيح التي تغطي الحقول والخط الكبير الذي يكسر التخطيط، فهذه أشياء لا يكشفها المحاكي عادة.

  • MVP صغير يعمل بلا أعطال أفضل من تطبيق ضخم نصف جاهز.
  • اختبار عناصر واحد للرحلة الأساسية يكشف الانكسارات قبل المستخدم.
  • Google Play يطلب حزمة AAB، و APK للتجربة المباشرة على الهاتف.
  • زد رقم البناء بعد + في version قبل كل رفع.
  • مفتاح التوقيع و key.properties لا يدخلان المستودع أبداً.

مخطط

قائمةتفاصيل
إضافة / إعدادات
analyzetestapk (تجربة)
appbundle موقّعGoogle Play
README + لقطات

مختبر: عرض 3 دقائق

سجّل فيديو قصيراً: فتح التطبيق، ثم التنقل، ثم إضافة عنصر وظهوره في القائمة.

مهمة عملية

سلّم MVP مع README واختبار عناصر ناجح، وارفع المشروع إلى مستودع GitHub بعد التأكد أن key.properties وملف المفتاح في .gitignore. النتيجة المقبولة: flutter analyze بلا أخطاء، و flutter test ناجح، وملف APK يعمل على هاتف حقيقي.

اختبر فهمك (1/3)

ما أفضل إصدار أول لتطبيق يبنيه مبتدئ؟

اختبر فهمك (2/3)

أي ملف يجب أن يُستبعد من Git قبل أول commit؟

اختبر فهمك (3/3)

رفعت النسخة 1.0.0+1 إلى Google Play وتريد رفع تحديث. ما التعديل الضروري في pubspec.yaml؟

للاستزادة: Flutter: بناء ونشر تطبيق Android · Android Developers: حزم Android App Bundle · Flutter: اختبار التطبيقات

مراجع رسمية لهذا المسار

  • Flutter — التثبيت الرسمي — طريقا التثبيت السريع عبر VS Code والإعداد المخصص، مع روابط إعداد Android و iOS.
  • Flutter — بناء الواجهات — شرح Widgets والتخطيط والتفاعل والتنقل من الفريق الرسمي.
  • Dart — جولة في اللغة — المتغيرات والأصناف والأمان من القيم الفارغة و async/await التي يقوم عليها كل كود Flutter.
  • Flutter Cookbook — وصفات قصيرة جاهزة: نماذج، قوائم، شبكة، وتنقل، مع أمثلة كاملة قابلة للتشغيل.
  • Flutter — التدويل والعربية — إعداد flutter_localizations وملفات ARB وتوليد صنف AppLocalizations.
  • Material Design 3 — إرشادات المكونات والألوان والتدرج البصري التي تطبقها عناصر Material في Flutter.

تطبيقك المصغّر جاهز للعرض

ارفع الكود على GitHub وشارك لقطات — أو راسلنا إن احتجت مساعدة في النشر على المتجر.