Logo

المعمل 05: بناء تطبيق Flutter متكامل - من المعمارية إلى منطق التشغيل

19 دقيقة قراءة
شرائح الدرس
1 / 14

المعمل 05: بناء تطبيق Flutter كامل - من المعمارية إلى المنطق

التخطيط، المعمارية ثلاثية الطبقات، السمات، نماذج البيانات، وطبقة المنطق

نظرة عامة

يرشدك هذا المعمل خلال العملية الكاملة لبناء تطبيق Flutter متكامل من الصفر. ستتعلم كيفية تخطيط المتطلبات قبل كتابة الكود، وبناء هيكل تطبيقك باستخدام معمارية ثلاثية الطبقات نظيفة، وإدارة الألوان عبر نظام ثيمات مركزي (Centralized Theming)، وحل مشكلات التمرير وأحجام الشاشات، وتصميم أصناف نماذج البيانات، وبناء طبقة منطق (Logic Layer) تحسب قيمًا حقيقية وتستخدم حزمًا خارجية.


الأهداف

بنهاية هذا المعمل ستكون قادرًا على:

  • تحديد متطلبات التطبيق قبل كتابة أي كود
  • فصل الواجهة (UI)، ومنطق الأعمال (Business Logic)، والبيانات (Data) في طبقات منفصلة
  • تطبيق مبدأ DRY لكتابة كود Flutter قابل للصيانة
  • التعامل مع قيود الواجهة وأحداث تفاعل المستخدم
  • تنفيذ نظام ثيمات مركزي باستخدام Theme.of(context) و ThemeData
  • إصلاح أخطاء تجاوز التخطيط (Overflow) الناتجة عن اختلاف أحجام شاشات الأجهزة
  • إنشاء أصناف نماذج Dart لتمثيل البيانات المُهيكلة
  • بناء عنصر واجهة لطبقة منطق (Logic Layer) مع فرض صارم لأنواع البيانات
  • البحث عن الحزم ودمجها من مستودع Flutter Pub

المتطلبات الأساسية

  • الإلمام بعناصر واجهة Flutter (Column، Row، Text، Container)
  • صياغة أساسية لأصناف Dart
  • بيئة تطوير Flutter تعمل بشكل جيد

خلفية نظرية

المعمارية ثلاثية الطبقات

قبل كتابة سطر كود واحد، يبدأ كل تطبيق Flutter احترافي بفصل واضح للمسؤوليات. هذا هو أهم مبدأ أساسي في بناء تطبيقات قابلة للصيانة.

graph TD
    subgraph UI["طبقة الواجهة (UI Layer)"]
        A[عناصر الواجهة والشاشات]
        B[تفاعلات المستخدم]
        C[الحسابات المرئية]
    end
 
    subgraph Logic["طبقة منطق الأعمال (Business Logic Layer)"]
        D[معالجة التواريخ]
        E[منطق البحث]
        F[حسابات الأيام]
    end
 
    subgraph DataLayer["طبقة البيانات (Data Layer)"]
        G[النماذج والأصناف]
        H[البيانات الخارجية]
    end
 
    A --> D
    B --> D
    D --> G
    E --> G
    F --> G

لماذا يهم هذا: عندما تضع المنطق مباشرة داخل الواجهة بشكل ثابت (Hardcoded)، يجبرك كل تحديث على تغيير عدة أماكن في آنٍ واحد. أما التطبيق جيد البنية فيتيح لك تغيير الأشياء في مكان واحد بالضبط. فإذا وجدت نفسك تجري نفس التغيير في عدة أماكن، فهذه علامة واضحة على أن كودك سيء التنظيم.


التخطيط: تحديد المتطلبات

الخطوة الأولى في بناء أي تطبيق هي ليست فتح محرر الأكواد - بل تحديد ما يجب أن يقوم به التطبيق بالضبط. تُسمى هذه العملية بتحديد متطلبات البرنامج.

ما هي المتطلبات؟

تحدد المتطلبات:

  • الإجراءات التي يجب أن يكون المستخدم قادرًا على القيام بها
  • العناصر المرئية التي يجب أن يعرضها التطبيق
  • البيانات التي يحتاج التطبيق للوصول إليها ومعالجتها

الطبقات الثلاث بالتفصيل

بمجرد تحديد المتطلبات، تنظّم تطبيقك في ثلاث طبقات منعزلة:

الطبقةالمسؤوليةأمثلة
الواجهة (UI)العناصر المرئية، مدخلات المستخدم، تخطيط الشاشةعناصر الواجهة، الأزرار، النصوص
منطق الأعمال (Business Logic)الحسابات، المعالجة، القواعدحسابات التاريخ، تصفية البحث
البيانات (Data)تخزين المعلومات الخام وتوفيرهاأصناف النماذج، مصادر البيانات

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

flowchart LR
    User([إجراء المستخدم]) --> UI[طبقة الواجهة]
    UI -->|طلب| Logic[منطق الأعمال]
    Logic -->|استعلام| Data[طبقة البيانات]
    Data -->|إرجاع| Logic
    Logic -->|نتيجة| UI
    UI -->|عرض| User

التمرين 1 - اكتب وثيقة متطلبات

قبل بدء أي كود في هذه الجلسة، اكتب وثيقة متطلبات لتطبيق ملف شخصي بسيط:

  1. اسرد كل شاشة سيحتوي عليها التطبيق
  2. لكل شاشة، صف: ما الذي يراه المستخدم، وما الذي يمكنه فعله، وما البيانات التي تحتاجها الشاشة
  3. حدد أي العناصر تنتمي لطبقة الواجهة، وأيها لطبقة منطق الأعمال، وأيها لطبقة البيانات

التمرين 2 - ارسم مخطط فصل الاهتمامات (Separation of Concerns)

اختر ميزة واحدة (مثلاً وظيفة بحث):

  1. ارسم مخططًا كتليًا (Block Diagram) بثلاثة أقسام: الواجهة، منطق الأعمال، والبيانات
  2. تتبّع كيف يتدفق إدخال بحث المستخدم من الواجهة إلى منطق الأعمال إلى طبقة البيانات ثم يعود
  3. تأكد من عدم وجود أي سهم يربط الواجهة مباشرة بالبيانات - يجب أن تمر جميع الأسهم عبر منطق الأعمال دائمًا

أفضل ممارسات الواجهة

طريقة build وتخطيط عناصر الواجهة

طريقة build هي المكان الذي يبني فيه Flutter واجهتك. توضع عناصر الواجهة بداخلها الواحد تحت الآخر مباشرة وتُنفَّذ باستخدام منطق متسق.

@override
Widget build(BuildContext context) {
  return Column(
    children: [
      Text('First Item'),
      SizedBox(height: 60),   // spacing greater than 50 pixels
      Text('Second Item'),
      SizedBox(height: 60),
      Text('Third Item'),
    ],
  );
}

شرح سطرًا بسطر:

  • Column - يرتّب العناصر الفرعية رأسيًا، الواحد أسفل الآخر
  • SizedBox(height: 60) - يضيف فجوة مقدارها 60 بكسل بين العناصر
  • يوضع كل عنصر Text بشكل تسلسلي أسفل العنصر السابق، وتُنفَّذ بنفس منطق التخطيط

مبدأ DRY - قابلية صيانة الكود

DRY تعني: لا تكرر نفسك (Don't Repeat Yourself).

القاعدة الأساسية:

إذا أردت تغيير شيء ما ووجدت نفسك مضطرًا لتغييره في أكثر من مكان، فإن كودك مكتوب بشكل سيء. الكود جيد الكتابة يتيح لك إجراء هذا التغيير في مكان واحد بالضبط.

نمط سيء - اللون مكرر في كل عنصر واجهة:

Container(color: Color(0xFF1565C0), child: Text('Header')),
Container(color: Color(0xFF1565C0), child: Text('Section')),
Container(color: Color(0xFF1565C0), child: Text('Footer')),

تغيير اللون يتطلب تحديث ثلاثة أسطر منفصلة.

نمط DRY - اللون مُعرَّف مرة واحدة:

final primaryColor = Color(0xFF1565C0);
 
Container(color: primaryColor, child: Text('Header')),
Container(color: primaryColor, child: Text('Section')),
Container(color: primaryColor, child: Text('Footer')),

الآن تغيّر اللون في مكان واحد وتتحدث كل الحاويات الثلاث فورًا.

تفاعل المستخدم: عرض تفاصيل العنصر

عندما ينقر المستخدم على عنصر، يجب أن يعرض التطبيق فورًا التفاصيل الخاصة بذلك العنصر تحديدًا:

  • الخطوة 1: ينقر المستخدم على عنصر
  • الخطوة 2: يُطلق التطبيق استجابة تكشف عن التفاصيل الخاصة بذلك العنصر
GestureDetector(
  onTap: () {
    showDetails(item);  // displays details for this specific item
  },
  child: ListTile(title: Text(item.name)),
)

ضبط القيود - القيم الدنيا

يمكنك تحديد أصغر حجم يُسمَح لعنصر باتخاذه:

Container(
  constraints: BoxConstraints(
    minWidth: 100,   // smallest possible width
    minHeight: 50,   // smallest possible height
  ),
  child: Text('Content'),
)

هذا يضمن ألا يصغر العنصر عن الحد الأدنى المحدد، بغض النظر عن محتواه.

إعادة استخدام منطق الكود لاستقبال البيانات

عندما ينتهي المستخدم من إجراء اختيار، يجب أن يستخدم التطبيق منطقًا متسقًا وقابلًا لإعادة الاستخدام لاستقبال البيانات المختارة ومعالجتها:

// Reusable handler called after any selection completes
void onSelectionComplete(SelectedItem item) {
  processSelection(item);  // same logic handles all selection results
}

التمرين 3 - إعادة هيكلة كود متكرر

  1. افتح مشروع Flutter الخاص بك وابحث عن أي عنصر واجهة يتكرر فيه نفس النمط (اللون، الحشو، أو نمط النص) في ثلاثة أماكن أو أكثر
  2. استخلص هذا النمط إلى متغيّر أو ثابت واحد
  3. استبدل كل التكرارات بهذا المتغيّر
  4. غيّر المتغيّر مرة واحدة وتأكد أن كل الحالات تتحدث في آنٍ واحد

التمرين 4 - إضافة تفاعل وقيود

  1. أنشئ قائمة من ثلاثة عناصر معروضة داخل Column
  2. لُف كل عنصر داخل GestureDetector يعرض SnackBar باسم ذلك العنصر عند النقر عليه
  3. أضف Container مع BoxConstraints تحدد minWidth بقيمة 150 وminHeight بقيمة 80
  4. تحقق من أن الحاوية تحترم الحد الأدنى للحجم حتى عندما يكون محتواها صغيرًا جدًا

نظام الثيمات المركزي عبر Theme.of

المشكلة: الألوان الثابتة (Hardcoded)

عندما تحدد لونًا مباشرة على كل عنصر واجهة، يصبح تحديث التطبيق بشكل شامل مستحيلًا:

// BAD — same color hardcoded on every element
ElevatedButton(
  style: ButtonStyle(
    backgroundColor: MaterialStateProperty.all(Color(0xFF1565C0)),
  ),
  child: Text('Submit'),
  onPressed: () {},
),
Text('Title', style: TextStyle(color: Color(0xFF1565C0))),
Container(color: Color(0xFF1565C0), child: Text('Box')),

لتطبيق الوضع الليلي (Dark Mode) أو لون علامة تجارية جديد، يجب عليك تحديد وتحديث كل سطر على حدة.

الحل: نظام ثيمات مركزي

عرّف خصائص التصميم مرة واحدة داخل كائن ThemeData. تشير كل عناصر الواجهة إلى هذا المصدر الوحيد:

MaterialApp(
  theme: ThemeData(
    primaryColor: Color(0xFF1565C0),
    colorScheme: ColorScheme.fromSeed(
      seedColor: Color(0xFF1565C0),
    ),
  ),
  home: MyHomePage(),
)

الوصول للثيم - Theme.of(context)

بدلًا من قيمة لون ثابتة، أشِر إلى الثيم المركزي:

Container(
  color: Theme.of(context).primaryColor,
  child: Text(
    'Hello',
    style: TextStyle(
      color: Theme.of(context).colorScheme.onPrimary,
    ),
  ),
)

شرح سطرًا بسطر:

  • Theme.of(context) - يبحث عن كائن ThemeData المُعرَّف على مستوى MaterialApp
  • .primaryColor - يسترجع اللون الأساسي من ذلك المصدر المركزي
  • .colorScheme.onPrimary - يسترجع لون النص المصمم ليتباين مع اللون الأساسي
  • تغيير primaryColor داخل ThemeData مرة واحدة يحدّث تلقائيًا كل عنصر واجهة يشير إليه

تنفيذ تغييرات شاملة - الوضع الليلي (Dark Mode)

مع نظام الثيمات المركزي، يصبح تطبيق الوضع الليلي مجرد تغيير في سطر واحد:

MaterialApp(
  theme: ThemeData.light(),
  darkTheme: ThemeData.dark(),
  themeMode: ThemeMode.dark,  // change this one line to switch the entire app
  home: MyHomePage(),
)

لا حاجة لأي تغييرات في كود عناصر الواجهة. يتحدث التطبيق بأكمله عبر ThemeData المركزي.

flowchart TD
    A["ThemeData\nمصدر مركزي واحد"] --> B[عنصر Button]
    A --> C[عنصر Text]
    A --> D[عنصر Container]
    A --> E[عنصر AppBar]

التمرين 5 - الانتقال إلى نظام الثيمات المركزي

  1. اختر شاشة تحتوي على ثلاثة عناصر واجهة أو أكثر تستخدم نفس اللون الثابت
  2. احذف كل قيم الألوان الثابتة من تلك العناصر
  3. عرّف كائن ThemeData داخل MaterialApp الخاص بك بذلك اللون كـ primaryColor
  4. استبدل كل لون محذوف بـ Theme.of(context).primaryColor
  5. غيّر primaryColor داخل ThemeData مرة واحدة وتحقق من تحديث كل العناصر في آنٍ واحد

التمرين 6 - تبديل الوضع الليلي

  1. أضف كلًا من theme: ThemeData.light() و darkTheme: ThemeData.dark() إلى MaterialApp
  2. اضبط themeMode: ThemeMode.dark
  3. شغّل التطبيق ولاحظ التغيير المرئي الكامل دون أي تعديل على أي عنصر واجهة

حل مشكلات حجم الشاشة والتمرير

مشكلة تعدد أجهزة العرض (Device Fragmentation)

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

flowchart TD
    A[التطبيق يبدو صحيحًا على جهازك] --> B{جهاز المستخدم بشاشة أصغر}
    B -->|العناصر تلائم الشاشة| C[التطبيق يُعرَض بشكل صحيح]
    B -->|العناصر تتجاوز الحدود| D[خطأ ظاهر على الشاشة\nالمحتوى مقصوص]
    D --> E{اختر حلًا}
    E --> F[SingleChildScrollView\nيلف الـ Column]
    E --> G[ListView\nيستبدل الـ Column]
    E --> H[Expanded\nالعناصر تُعاد تحجيمها لتلائم الشاشة]

عندما يتجاوز المحتوى حدود Column، يعرض Flutter خطأ تجاوز مرئيًا (خطوط صفراء وسوداء) عند حافة الشاشة.

الحل الأساسي: SingleChildScrollView

خذ كل العناصر داخل Column الخاص بك ولُفّها داخل SingleChildScrollView:

// BEFORE — causes overflow on small screens
Column(
  children: [
    WidgetOne(),
    WidgetTwo(),
    WidgetThree(),
    WidgetFour(),
  ],
)
 
// AFTER — user can scroll to see all content
SingleChildScrollView(
  child: Column(
    children: [
      WidgetOne(),
      WidgetTwo(),
      WidgetThree(),
      WidgetFour(),
    ],
  ),
)

شرح سطرًا بسطر:

  • SingleChildScrollView - عنصر غلاف يتيح التمرير الرأسي لعنصره الفرعي الوحيد
  • تبقى بنية Column كما هي بداخله دون تغيير
  • يمرر المستخدمون للأعلى لكشف العناصر التي تمتد خارج منطقة الشاشة المرئية
  • يختفي خطأ التجاوز لأن المحتوى لم يعد مجبرًا على الملاءمة داخل حدود ثابتة

البديل 1: ListView

استبدل Column بالكامل بـ ListView، الذي يحتوي على تمرير مدمج بشكل أصلي:

ListView(
  children: [
    WidgetOne(),
    WidgetTwo(),
    WidgetThree(),
    WidgetFour(),
  ],
)

لا حاجة لأي غلاف - يتعامل ListView مع التمرير الخاص به تلقائيًا.

البديل 2: Expanded

استخدم Expanded مع العناصر داخل Column لجعلها تملأ وتتكيف مع المساحة المتاحة:

Column(
  children: [
    Expanded(child: WidgetOne()),  // stretches to fill remaining height
    WidgetTwo(),
  ],
)

يُجبر Expanded العنصر الفرعي على إعادة التحجيم ليلائم حدود الشاشة. الشاشة لا تُمرَّر؛ بل يتكيف العنصر مع حجمه ليلائم المساحة.

الاختيار بين الأساليب الثلاثة

الأسلوبمتى يُستخدم
SingleChildScrollViewيجب أن يبقى المحتوى بحجمه الكامل؛ يُمرِّر المستخدم لرؤية كل شيء
ListViewقائمة من عناصر متكررة تحتاج إلى تمرير
Expandedيجب أن ينكمش المحتوى ليلائم الشاشة دون تمرير

التمرين 7 - إحداث وإصلاح خطأ تجاوز (Overflow)

  1. أنشئ Column تحتوي على 8 عناصر Container كبيرة، كل منها بارتفاع height: 120
  2. شغّل التطبيق على محاكي صغير (بروفايل جهاز 4 بوصات)
  3. لاحظ خطأ التجاوز الظاهر في أسفل الشاشة
  4. لُف Column داخل SingleChildScrollView
  5. تأكد أن الخطأ اختفى وأنك تستطيع التمرير عبر كل الحاويات

التمرين 8 - قارن بين الأساليب الثلاثة

  1. نفّذ القائمة المتجاوزة باستخدام SingleChildScrollView + Column
  2. أعد تنفيذها باستخدام ListView فقط
  3. أعد تنفيذها باستخدام Expanded (لاحظ: يُعاد تحجيم المحتوى بدلًا من تمريره)
  4. اكتب ملاحظة قصيرة تقارن ما يختبره المستخدم في كل حالة

نماذج بيانات الواجهة (UI Data Models)

ما هو نموذج البيانات؟

نموذج البيانات (Data Model) هو صنف Dart يجمّع قيمًا مترابطة معًا. فبدلًا من تمرير متغيرات فردية وغير مترابطة بين عناصر الواجهة، تجمّعها داخل صنف. يصبح هذا الصنف الحاوية الوحيدة لقطعة من البيانات، ويحتفظ بتلك القيم طوال المدة التي تحتاجها.

إنشاء نموذج بيانات أساسي

// person_model.dart
class PersonModel {
  final String name;
  final int age;
  final DateTime birthDate;
 
  PersonModel({
    required this.name,
    required this.age,
    required this.birthDate,
  });
}

شرح سطرًا بسطر:

  • class PersonModel - يعرّف صنف Dart جديد باسم PersonModel
  • final String name - قيمة محفوظة داخل الصنف: اسم الشخص (لا يمكن تغييرها بعد ضبطها)
  • final int age - قيمة محفوظة داخل الصنف: عمر الشخص
  • final DateTime birthDate - قيمة محفوظة داخل الصنف: تاريخ ميلاد الشخص
  • PersonModel({required this.name, ...}) - الباني (Constructor)؛ يجب توفير كل الحقول عند إنشاء نسخة (Instance)

ربط نموذج بعنصر واجهة

مرّر النموذج كمعامل مُحدَّد النوع (Typed Parameter) إلى عنصر الواجهة الذي سيعرض بياناته:

class PersonCard extends StatelessWidget {
  final PersonModel person;  // only accepts a PersonModel — no other type
 
  const PersonCard({required this.person, super.key});
 
  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(person.name),
        Text('Age: ${person.age}'),
        Text('Born: ${person.birthDate.year}'),
      ],
    );
  }
}

الاستخدام:

PersonCard(
  person: PersonModel(
    name: 'Ahmed',
    age: 22,
    birthDate: DateTime(2003, 5, 15),
  ),
)

التمرين 9 - ابنِ صنف نموذج بيانات

  1. أنشئ ملفًا جديدًا lib/models/person_model.dart
  2. عرّف صنف PersonModel بثلاثة حقول: name (نص)، age (عدد صحيح)، birthDate (تاريخ)
  3. أضف باني (Constructor) يتطلب الحقول الثلاثة جميعها
  4. أنشئ StatelessWidget باسم PersonCard يستقبل PersonModel كمعامل مطلوب
  5. اعرض الحقول الثلاثة داخل عنصر الواجهة باستخدام عناصر Text

التمرين 10 - املأ قائمة من النماذج

  1. أنشئ List<PersonModel> تحتوي على ثلاث عناصر (أسماء وأعمار وتواريخ ميلاد مختلفة)
  2. استخدم Column لعرض PersonCard لكل عنصر في القائمة
  3. تحقق من أن تغيير قيمة داخل PersonModel يحدّث فورًا البطاقة المعروضة

طبقة المنطق (The Logic Layer)

ما هي طبقة المنطق؟

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

فرض الأنواع الصارم في عناصر الواجهة

مبدأ أساسي: يجب أن يستقبل عنصر الواجهة نوع البيانات الذي يتوقعه بالضبط. إذا كان عنصر الواجهة مصممًا للعمل مع PersonModel، فإن تمرير أي نوع آخر يُعد خطأً يكتشفه Dart وقت الترجمة (Compile Time).

class DaysCalculatorWidget extends StatelessWidget {
  final PersonModel person;  // strictly requires a PersonModel
 
  const DaysCalculatorWidget({required this.person, super.key});
 
  int calculateDaysAlive() {
    final today = DateTime.now();
    return today.difference(person.birthDate).inDays;
  }
 
  @override
  Widget build(BuildContext context) {
    return Text('Days alive: ${calculateDaysAlive()}');
  }
}

شرح سطرًا بسطر:

  • final PersonModel person - يفرض الباني (Constructor) نوعًا صارمًا؛ لا يُقبَل إلا PersonModel
  • calculateDaysAlive() - دالة المنطق، منفصلة تمامًا عن طريقة build
  • DateTime.now().difference(person.birthDate).inDays - يطرح تاريخ الميلاد من اليوم الحالي ويُرجع النتيجة بعدد الأيام الكاملة
  • build - تعرض النتيجة فقط؛ لا تُجري الحساب بنفسها

مسار تنفيذ المنطق

قبل كتابة منطق مخصص من الصفر، اسأل نفسك دائمًا:

هل أحتاج فعلًا لبناء هذا بنفسي، أم أن هناك حلًا جاهزًا موجودًا بالفعل؟

flowchart TD
    A[حدد المنطق المطلوب] --> B{ابحث في Flutter Pub}
    B -->|الحزمة موجودة| C[قيّم الحزمة]
    B -->|لا توجد حزمة| D[اكتب منطقًا مخصصًا]
    C --> E{تحقق من تبعياتها}
    E --> F[ادمج الحزمة]
    D --> G[نفّذ من الصفر]
    F --> H[المنطق مكتمل]
    G --> H

البحث عن الحزم في Flutter Pub

يستضيف مستودع حزم Flutter (pub.dev) حزمًا أنشأها مطورون آخرون. قبل تنفيذ منطق معقد يدويًا:

  1. اذهب إلى pub.dev
  2. ابحث عن كلمات مفتاحية تتعلق بمهمتك (مثلاً: "date"، "time difference"، "age calculator")
  3. قيّم شعبية الحزمة، وحالة صيانتها، والترخيص الخاص بها
  4. اقرأ توثيقها وتحقق من تبعياتها (Dependencies) - بعض الحزم تتطلب حزمًا أخرى لتعمل

فهم التبعيات (Dependencies)

التبعية هي عندما تعتمد حزمة على أخرى لكي تعمل بشكل صحيح:

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  intl: ^0.19.0    # date formatting package added from Pub

كيف تعمل التبعيات:

  • تستخدم الحزمة أ الحزمة ب داخليًا
  • عند إضافة الحزمة أ لمشروعك، يجب أن تكون الحزمة ب موجودة أيضًا
  • تشغيل flutter pub get يحل ويحمّل كل التبعيات المطلوبة تلقائيًا

بناء صنف منطق معزول

إنشاء صنف متكامل والتحقق منه بشكل مستقل هو أسلوب ممتاز لتطوير المنطق:

// lib/logic/days_calculator.dart
class DaysCalculator {
  final DateTime birthDate;
 
  DaysCalculator(this.birthDate);
 
  int calculateDaysAlive() {
    return DateTime.now().difference(birthDate).inDays;
  }
 
  bool isOver1000DaysOld() {
    return calculateDaysAlive() > 1000;
  }
}

شرح سطرًا بسطر:

  • class DaysCalculator - صنف منطق خالص: بدون عناصر واجهة، بدون UI، بدون BuildContext
  • final DateTime birthDate - المُدخَل الوحيد الذي يحتاجه الصنف
  • calculateDaysAlive() - يحسب ويُرجع عدد الأيام منذ الميلاد
  • isOver1000DaysOld() - يستخدم calculateDaysAlive() داخليًا للإجابة عن سؤال منطقي (Boolean)
  • يمكن اختبار هذا الصنف بشكل مستقل دون تشغيل التطبيق كاملًا

التحقق من الصنف:

void main() {
  final calc = DaysCalculator(DateTime(2003, 5, 15));
  print(calc.calculateDaysAlive());   // prints the number of days
  print(calc.isOver1000DaysOld());    // prints true or false
}

معالجة الأحداث في طبقة المنطق

تتعامل طبقة المنطق أيضًا مع الأحداث (Events) - وهي المواقف التي يحدث فيها شيء ما ويجب أن تتولى طبقة المنطق معالجته. تُطلِق الواجهة الحدث؛ وتعالجه طبقة المنطق؛ ثم تعود النتيجة إلى الواجهة.

// When a selection event occurs, the logic layer takes over
void onPersonSelected(PersonModel selectedPerson) {
  final calculator = DaysCalculator(selectedPerson.birthDate);
  final days = calculator.calculateDaysAlive();
  displayResult(days);  // passes the result back to the UI
}

لا تعرف طبقة المنطق ولا تهتم أبدًا بكيفية عرض النتيجة.

التمرين 11 - ابنِ عنصر واجهة حاسبة أيام صارم النوع

  1. استخدم PersonModel من القسم السابق
  2. أنشئ DaysCalculatorWidget يستقبل فقط PersonModel كمعامل مطلوب
  3. نفّذ calculateDaysAlive() داخل عنصر الواجهة باستخدام DateTime.now().difference(person.birthDate).inDays
  4. اعرض النتيجة داخل عنصر Text
  5. حاول تمرير int بسيط بدلًا من PersonModel للتأكد من أن Dart يرفضه وقت الترجمة

التمرين 12 - ابحث عن حزمة وادمجها

  1. افتح pub.dev في متصفحك
  2. ابحث عن "date difference" أو "age calculator"
  3. جد حزمة تحسب الفوارق الزمنية بين التواريخ
  4. أضفها إلى pubspec.yaml وشغّل flutter pub get
  5. اقرأ توثيق الحزمة وحدد كل تبعية تتطلبها
  6. أعد كتابة حساب الأيام لديك باستخدام الحزمة
  7. تأكد أن الناتج يطابق حسابك اليدوي

التمرين 13 - اعزل المنطق في صنف منفصل

  1. أنشئ ملفًا جديدًا lib/logic/days_calculator.dart
  2. عرّف صنف DaysCalculator بخاصية birthDate
  3. أضف calculateDaysAlive() تُرجع int
  4. أضف isOver1000DaysOld() تُرجع bool
  5. شغّل اختبارًا سريعًا باستخدام void main() لطباعة كلتا النتيجتين لتاريخ ميلاد معروف
  6. اربط الصنف بـ DaysCalculatorWidget عن طريق إنشاء نسخة منه داخل عنصر الواجهة

إدارة الحالة باستخدام StatefulWidget

ما هي الحالة (State)؟

الحالة (State) هي أي بيانات يمكن أن تتغير مع الوقت، ويجب أن يتسبب تغيّرها في تحديث الواجهة. في تطبيق حاسبة العمر، تُعد التواريخ المختارة والنتائج المحسوبة حالة - فعندما يختار المستخدم تاريخًا جديدًا، يجب أن يُعاد عرض الواجهة لإظهاره.

عنصر الواجهة الذي لا يحمل بيانات قابلة للتغيير هو StatelessWidget. أما عنصر الواجهة الذي يملك بيانات تتغير أثناء التشغيل فهو StatefulWidget.

StatelessWidget مقابل StatefulWidget

النوعمتى يُستخدمهل يمكن أن يتغير بعد البناء؟
StatelessWidgetعرض فقط، بدون تفاعللا
StatefulWidgetمدخلات المستخدم، النتائج المحسوبة، أي شيء يتحدثنعم، عبر setState()

نمط StatefulWidget

يُقسَّم StatefulWidget إلى صنفين:

// 1. The widget itself — immutable, holds configuration
class HomeScreen extends StatefulWidget {
  const HomeScreen({super.key});
 
  @override
  State<HomeScreen> createState() => _HomeScreenState();
}
 
// 2. The State object — holds the mutable data
class _HomeScreenState extends State<HomeScreen> {
  DateTime _birthDate = DateTime(2000, 1, 1);  // mutable state
  Map<String, int>? _age;                       // mutable state
 
  @override
  Widget build(BuildContext context) {
    return Text('$_birthDate');
  }
}

شرح سطرًا بسطر:

  • StatefulWidget - صنف عنصر الواجهة نفسه يبقى غير قابل للتغيير (Immutable)؛ فقط يعرف كيف ينشئ State
  • createState() - يُرجع كائن State الذي سيملك البيانات القابلة للتغيير
  • _HomeScreenState - الصنف الخاص (Private) حيث تعيش كل الحقول القابلة للتغيير
  • DateTime _birthDate - متغيّر حالة (State Variable)؛ تغييره يُطلق عملية إعادة بناء

setState() - مُحفِّز إعادة البناء

setState() هي الآلية التي تُخبر Flutter: "البيانات تغيّرت - أعد بناء الواجهة."

// Without setState: data changes but UI stays the same (stale)
_birthDate = newDate;  // UI never updates
 
// With setState: data changes AND Flutter schedules a rebuild
setState(() {
  _birthDate = newDate;  // UI updates on next frame
});

كيف تعمل:

  1. تستدعي setState(() { ... }) مع تغيير البيانات داخل الدالة المغلقة (Closure)
  2. يضع Flutter علامة على هذا العنصر بأنه "متسخ" (Dirty)
  3. في الإطار التالي، يستدعي Flutter build() مرة أخرى بالبيانات الجديدة
  4. تعكس الواجهة الحالة المُحدَّثة

ربط الحالة بالمعمارية ثلاثية الطبقات

void _calculate() {
  // DATA LAYER: build the typed model from current state
  final model = AgeInputModel(
    birthDate: _birthDate,
    todayDate: _todayDate,
  );
 
  // BUSINESS LOGIC LAYER: run the calculation
  final calculator = AgeCalculator(model);
 
  // UI LAYER: setState triggers rebuild with new results
  setState(() {
    _age = calculator.calculateAge();
    _nextBirthday = calculator.calculateNextBirthday();
  });
}

المسار: المستخدم ينقر CALCULATE → طبقة الواجهة تقرأ الحالة → تنشئ نموذج بيانات → تمرره لطبقة المنطق → يُرجع المنطق النتيجة → تُحدّث setState() الواجهة.

التمرين 14 - أضف حالة إلى حاسبة العمر

  1. حوّل HomeScreen من StatelessWidget إلى StatefulWidget
  2. عرّف _birthDate، _todayDate، _age، و _nextBirthday كمتغيرات حالة
  3. استدعِ setState() داخل onDateSelected بحيث يتحدث حقل التاريخ عندما يختار المستخدم تاريخًا
  4. استدعِ setState() داخل _calculate() بعد تشغيل AgeCalculator لعرض النتائج
  5. استدعِ setState() داخل _clear() لإعادة ضبط كل الحقول
  6. تحقق من: اختيار تاريخ جديد ← يعرض الحقل التاريخ الجديد؛ الضغط على CALCULATE ← تمتلئ مربعات النتائج؛ الضغط على CLEAR ← تُعاد المربعات إلى الصفر

الملخص

الموضوعالمفهوم الأساسي
المتطلباتحدد ما يحتاجه التطبيق قبل كتابة أي كود
أفضل ممارسات الواجهةمبدأ DRY: غيّر في مكان واحد، وليس عدة أماكن
نظام الثيماتTheme.of(context) لإدارة الألوان بشكل مركزي
التمريرSingleChildScrollView يحل مشكلة التجاوز على الشاشات الصغيرة
نماذج الواجهةأصناف Dart تحمل بيانات مُهيكلة لعناصر الواجهة
طبقة المنطقافصل الحسابات عن الواجهة؛ تحقق من Flutter Pub أولًا
إدارة الحالةStatefulWidget + setState() لبيانات الواجهة القابلة للتغيير

الدرس الأهم: المعمارية تأتي أولًا. تخطيط الطبقات الثلاث - الواجهة، ومنطق الأعمال، والبيانات - قبل كتابة الكود هو ما يجعل تطبيق Flutter قابلًا للصيانة واحترافيًا.