Flutter 19 يونيو 2026 435 مشاهدة

hosteday_flutter: حزمة Flutter لتبسيط التكامل مع HosteDay APIs

حزمة `hosteday_flutter` توفر طريقة بسيطة ومنظمة لربط تطبيقات Flutter مع HosteDay APIs، مع دعم تسجيل الدخول، إدارة المستخدم، إرسال الطلبات، الهيدرز، التوكنات، وحماية الروابط باستخدام `X-Api-Token`.

M
Mustafamax
الكاتب
hosteday_flutter: حزمة Flutter لتبسيط التكامل مع HosteDay APIs

استخدام hosteday_flutter لربط تطبيق Flutter بمنصة Hosteday

عند بناء تطبيق Flutter يعتمد على Backend، تحتاج عادةً إلى كتابة الكثير من الكود للتعامل مع الروابط، والطلبات، والمصادقة، والتوكنات، والأخطاء، والصفحات، والصور، والاتصال اللحظي.

حزمة hosteday_flutter تختصر هذه الطبقة وتوفر واجهة موحدة للتعامل مع خدمات Hosteday مباشرة من تطبيق Flutter.

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

final products = await Hosteday.client.index('products');

أو:

final user = await Hosteday.auth.signInWithEmailAndPassword(
  email: email,
  password: password,
);

في هذا الدليل سنبدأ من الصفر، ثم ننتقل تدريجيًا إلى الإمكانات الأكثر تقدمًا.


1. تثبيت الحزمة

أضف الحزمة إلى مشروع Flutter:

flutter pub add hosteday_flutter

ثم استوردها:

import 'package:hosteday_flutter/hosteday_flutter.dart';

في pubspec.yaml ستظهر بصورة مشابهة:

dependencies:
  flutter:
    sdk: flutter

  hosteday_flutter: ^2.3.0

2. تهيئة Hosteday

قبل استخدام أي جزء من الحزمة يجب تهيئة Hosteday.

أبسط إعداد يحتاج فقط إلى دومين مشروعك:

import 'package:flutter/material.dart';
import 'package:hosteday_flutter/hosteday_flutter.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await Hosteday.initializeApp(
    options: {
      HostedayOptionKeys.projectDomain: 'max.hosteday.com',
    },
  );

  runApp(const App());
}

لا تحتاج إلى كتابة:

https://

ولا تحتاج إلى إضافة:

/api

يكفي دومين المشروع:

max.hosteday.com

بعد التهيئة أصبحت خدمات Hosteday متاحة من خلال:

Hosteday.client
Hosteday.auth
Hosteday.config
Hosteday.realtime

3. التهيئة في التطبيقات المولدة

إذا كان التطبيق يتم إنشاؤه تلقائيًا، فمن الأفضل ألا يكون الدومين ومعلومات المشروع مكتوبة مباشرة داخل Dart.

يمكن وضعها في:

assets/config/hosteday.json

مثال:

{
  "project_domain": "max.hosteday.com",
  "project_api_key": null,
  "realtime_app_key": null,
  "realtime_host": null
}

ثم إضافة الملف إلى pubspec.yaml:

flutter:
  assets:
    - assets/config/hosteday.json

وفي main.dart:

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await Hosteday.initializeFromAsset();

  runApp(const App());
}

بهذه الطريقة يستطيع مولد التطبيقات تجهيز ملف الإعدادات، بينما يبقى كود Flutter نفسه عامًا وقابلًا لإعادة الاستخدام.


4. كيف تبني الحزمة روابط API؟

لنفترض أن دومين المشروع هو:

max.hosteday.com

عند كتابة:

await Hosteday.client.get('products');

ستستخدم الحزمة:

https://max.hosteday.com/api/products

وكل هذه الصيغ تشير إلى المورد نفسه:

Hosteday.client.get('products');

Hosteday.client.get('/products');

Hosteday.client.get('/api/products');

لذلك لا تحتاج إلى تكرار /api في كل مكان.

هذا يجعل كود التطبيق أبسط:

Hosteday.client.get('products');
Hosteday.client.get('categories');
Hosteday.client.get('orders');

5. قراءة قائمة من البيانات

للقوائم يفضل استخدام:

Hosteday.client.index()

مثال:

final page = await Hosteday.client.index('products');

البيانات موجودة مباشرة في:

final products = page.items;

والنوع هو:

List<Map<String, dynamic>>

مثال كامل:

final page = await Hosteday.client.index(
  'products',
  withAuth: false,
);

for (final product in page.items) {
  print(product['name']);
  print(product['price']);
}

الميزة هنا أن التطبيق لا يحتاج إلى فك استجابة Laravel المتداخلة يدويًا.

إذا أعاد السيرفر استجابة pagination، تتولى الحزمة استخراج العناصر ووضعها داخل:

page.items

6. قراءة سجل واحد

لجلب منتج واحد استخدم:

final product = await Hosteday.client.show(
  'products',
  id: 5,
);

ثم:

print(product['name']);
print(product['price']);

وتقوم الحزمة ببناء الرابط:

/api/products/5

بدل كتابة:

Hosteday.client.get('/products/5');

يمكنك ببساطة فصل اسم المورد عن المعرّف:

Hosteday.client.show(
  'products',
  id: 5,
);

وهذا الأسلوب أوضح خصوصًا داخل Repository أو مولد التطبيقات.


7. متى أستخدم get بدل index وshow؟

يمكنك استخدام:

final response = await Hosteday.client.get('products');

لكن get() يعيد استجابة السيرفر الكاملة.

على سبيل المثال قد تحصل على:

{
  "success": true,
  "message": "Products retrieved successfully.",
  "data": {
    "current_page": 1,
    "data": []
  }
}

هذا مفيد عندما تريد التعامل مع الاستجابة الخام.

أما إذا كان هدفك الحصول على المنتجات مباشرة، فاستخدم:

final page = await Hosteday.client.index('products');

final products = page.items;

وإذا أردت سجلًا واحدًا:

final product = await Hosteday.client.show(
  'products',
  id: 5,
);

القاعدة البسيطة هي:

الهدف الطريقة المناسبة
قائمة سجلات index()
سجل واحد show()
الاستجابة الخام كاملة get()

8. إنشاء سجل جديد

لإنشاء منتج:

final response = await Hosteday.client.post(
  'products',
  body: {
    'name': 'iPhone',
    'price': 850,
    'stock_quantity': 10,
  },
);

إذا كان Endpoint عامًا:

final response = await Hosteday.client.post(
  'products',
  body: {
    'name': 'iPhone',
    'price': 850,
  },
  withAuth: false,
);

أما إذا كان يتطلب مستخدمًا مسجلًا:

final response = await Hosteday.client.post(
  'products',
  body: {
    'name': 'iPhone',
    'price': 850,
  },
  withAuth: true,
);

9. تحديث سجل

يمكن تمرير id بصورة منفصلة:

await Hosteday.client.put(
  'products',
  id: 15,
  body: {
    'name': 'iPhone 17',
    'price': 900,
  },
);

وتقوم الحزمة ببناء:

/api/products/15

للتحديث الجزئي يمكن استخدام PATCH إذا كان Backend يدعمه:

await Hosteday.client.patch(
  'products',
  id: 15,
  body: {
    'price': 875,
  },
);

10. حذف سجل

await Hosteday.client.delete(
  'products',
  id: 15,
);

وهذا يستهدف:

/api/products/15

وبذلك تصبح العمليات الأساسية واضحة جدًا:

Hosteday.client.index('products');
Hosteday.client.show('products', id: 15);

Hosteday.client.post('products', body: {...});

Hosteday.client.put(
  'products',
  id: 15,
  body: {...},
);

Hosteday.client.patch(
  'products',
  id: 15,
  body: {...},
);

Hosteday.client.delete(
  'products',
  id: 15,
);

11. الحصول على رابط المشروع

توفر الحزمة رابط المشروع مباشرة:

final host = Hosteday.client.host;

إذا كان المشروع:

max.hosteday.com

فالقيمة ستكون:

https://max.hosteday.com

أما رابط API:

final api = Hosteday.client.apiBaseUrl;

فسوف يكون:

https://max.hosteday.com/api

هذا مفيد عندما تحتاج إلى إنشاء روابط عامة دون كتابة الدومين يدويًا.


12. الصفحات الثابتة

يمكن لمشروع Hosteday توفير صفحات ثابتة مثل:

/info
/privacy
/about

ولا تحتاج إلى تركيب رابطها يدويًا.

استخدم:

final infoUrl = Hosteday.client.staticPages['info'];

final privacyUrl =
    Hosteday.client.staticPages['privacy'];

النتيجة:

https://max.hosteday.com/info

https://max.hosteday.com/privacy

يمكن بعد ذلك فتح الرابط باستخدام url_launcher أو عرضه داخل WebView حسب تصميم التطبيق.

اسم الخاصية هو:

staticPages

وليس static لأن static كلمة محجوزة في Dart.


13. الصور والملفات

من أكثر الأمور التي تسبب تكرارًا في التطبيقات تركيب رابط الصورة.

قد يعيد API:

{
  "image": "products/phone.png"
}

يمكن تحويل المسار إلى رابط كامل باستخدام:

final imageUrl = Hosteday.client.storageUrl(
  product['image'],
);

فتصبح النتيجة:

https://max.hosteday.com/storage/products/phone.png

ثم:

Image.network(
  Hosteday.client.storageUrl(product['image']),
);

ومن الأفضل دائمًا التأكد من وجود الصورة:

final image = product['image'] as String?;

if (image != null && image.isNotEmpty) {
  final imageUrl = Hosteday.client.storageUrl(image);

  print(imageUrl);
}

إذا كانت القيمة أصلًا رابطًا كاملًا، تستطيع storageUrl() التعامل معها دون إضافة مسار Hosteday إليها.


14. البحث

يمكن البحث مباشرة:

final page = await Hosteday.client.index(
  'products',
  search: 'phone',
);

أو عند استخدام الاستجابة الخام:

final response = await Hosteday.client.get(
  'products',
  search: 'phone',
);

وتقوم الحزمة ببناء معامل:

?search=phone

تلقائيًا.


15. Query Parameters

يمكن إضافة معاملات أخرى:

final response = await Hosteday.client.get(
  'products',
  queryParameters: {
    'category_id': 5,
    'page': 2,
  },
);

ويمكن دمجها مع البحث:

final response = await Hosteday.client.get(
  'products',
  search: 'phone',
  queryParameters: {
    'category_id': 5,
    'page': 2,
  },
);

16. الفلاتر

تدعم index() الفلاتر أيضًا.

مثال:

final page = await Hosteday.client.index(
  'products',
  filters: {
    'status': ['active', 'pending'],
  },
);

ويمكن دمج الفلاتر والبحث والصفحات:

final page = await Hosteday.client.index(
  'products',
  search: 'phone',
  page: 1,
  filters: {
    'status': ['active'],
  },
);

17. العلاقات

إذا كان Endpoint يعتمد على علاقة، يمكن تمريرها بصورة واضحة:

final page = await Hosteday.client.index(
  'products',
  relationField: 'category_id',
  relationValue: 5,
);

وستحوّل الحزمة القيم إلى معاملات Backend المناسبة.

يمكن كذلك استخدامها مع سجل واحد:

final product = await Hosteday.client.show(
  'products',
  id: 15,
  relationField: 'category_id',
  relationValue: 5,
);

لا تحتاج إلى كتابة:

relation_field
relation_value

يدويًا.


18. Pagination

index() لا يعيد العناصر فقط، بل معلومات الصفحة أيضًا:

final page = await Hosteday.client.index(
  'products',
  page: 1,
);

يمكنك قراءة:

page.items;
page.currentPage;
page.perPage;
page.hasNextPage;
page.hasPreviousPage;

مثال:

if (page.hasNextPage) {
  final nextPage = await Hosteday.client.index(
    'products',
    page: page.currentPage + 1,
  );

  print(nextPage.items);
}

بهذا لا تحتاج إلى تحليل بنية Pagination الخاصة بـ Laravel يدويًا.


19. تسجيل الدخول

المصادقة لها واجهة مستقلة:

Hosteday.auth

لتسجيل الدخول:

try {
  final credential =
      await Hosteday.auth.signInWithEmailAndPassword(
    email: 'customer@example.com',
    password: 'password123',
  );

  print(credential.user.id);
  print(credential.user.email);
} on HostedayException catch (error) {
  print(error.displayMessage);
}

بعد نجاح تسجيل الدخول تتولى الحزمة إدارة جلسة المستخدم والتوكن.

لذلك لا تحتاج إلى استخراج access_token يدويًا في كل شاشة.


20. إنشاء حساب

final credential =
    await Hosteday.auth.createUserWithEmailAndPassword(
  email: 'customer@example.com',
  password: 'password123',
  additionalData: {
    'name': 'Mustafa',
  },
);

ويمكن تمرير حقول إضافية:

await Hosteday.auth.createUserWithEmailAndPassword(
  email: email,
  password: password,
  additionalData: {
    'name': name,
    'phone': phone,
  },
);

21. المستخدم الحالي

بعد تسجيل الدخول:

final user = Hosteday.auth.currentUser;

ثم:

if (user != null) {
  print(user.id);
  print(user.displayName);
  print(user.email);
  print(user.emailVerified);
  print(user.photoUrl);
}

لا تحتاج إلى تخزين بيانات المستخدم في متغير عالمي خاص بك حتى تعرف من هو المستخدم الحالي.


22. متابعة حالة تسجيل الدخول

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

StreamBuilder(
  stream: Hosteday.auth.authStateChanges(),
  builder: (context, snapshot) {
    final user = snapshot.data;

    if (user == null) {
      return const LoginPage();
    }

    return const HomePage();
  },
);

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


23. حفظ الجلسة بعد إغلاق التطبيق

التهيئة الافتراضية تستخدم تخزينًا مؤقتًا في الذاكرة.

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

await Hosteday.initializeApp(
  options: {
    HostedayOptionKeys.projectDomain:
        'max.hosteday.com',
  },
  authStorage:
      HostedaySharedPreferencesAuthStorage(),
);

عندها تستطيع الحزمة استعادة جلسة المستخدم في التشغيل التالي.


24. نسيان كلمة المرور

await Hosteday.auth.sendPasswordResetEmail(
  email: email,
);

25. التحقق من البريد

await Hosteday.auth.sendEmailVerification();

وعند الحاجة إلى تحديث بيانات المستخدم من السيرفر:

await Hosteday.auth.reload();

26. تسجيل الخروج

await Hosteday.auth.signOut();

الحزمة تتولى تنظيف الجلسة المحلية وتحديث حالة المصادقة.


27. الطلبات المحمية

ليس كل Endpoint يحتاج إلى مستخدم مسجل.

لطلب عام:

final products = await Hosteday.client.index(
  'products',
  withAuth: false,
);

لطلب يحتاج إلى المستخدم الحالي:

final orders = await Hosteday.client.index(
  'orders',
  withAuth: true,
);

عند استخدام:

withAuth: true

تقوم الحزمة باستخدام جلسة المستخدم وإرسال Authorization المناسب.

لذلك لا تحتاج إلى كتابة:

headers: {
  'Authorization': 'Bearer ...',
}

في كل طلب.


28. Project API Key ليس User Token

هناك فرق مهم بين مفتاح المشروع وتوكن المستخدم.

إذا كانت حماية API الخاصة بالمشروع مفعلة، يمكن إضافة المفتاح أثناء التهيئة:

await Hosteday.initializeApp(
  options: {
    HostedayOptionKeys.projectDomain:
        'max.hosteday.com',

    HostedayOptionKeys.projectApiKey:
        projectApiKey,
  },
);

تتعامل الحزمة معه من خلال هيدر المشروع:

X-Api-Token

أما توكن المستخدم فيرتبط بتسجيل الدخول ويتم استخدامه عند:

withAuth: true

إذن هما شيئان مختلفان:

القيمة وظيفتها
projectApiKey تعريف أو حماية مشروع Hosteday
User access token تعريف المستخدم المسجل

ولا ينبغي استخدام أحدهما بدل الآخر.


29. التعامل مع الأخطاء

جميع أخطاء SDK المهمة يمكن التعامل معها من خلال:

HostedayException

مثال:

try {
  final page = await Hosteday.client.index(
    'products',
    search: 'phone',
    timeout: const Duration(seconds: 15),
  );

  print(page.items);
} on HostedayException catch (error) {
  print(error.statusCode);
  print(error.message);
  print(error.displayMessage);
}

message مفيدة غالبًا أثناء التطوير:

error.message

أما النص المناسب للعرض للمستخدم فيمكن أخذه من:

error.displayMessage

ولأخطاء Validation:

final emailError =
    error.firstErrorFor('email');

مثال:

try {
  await Hosteday.auth.signInWithEmailAndPassword(
    email: email,
    password: password,
  );
} on HostedayException catch (error) {
  final message =
      error.firstErrorFor('email') ??
      error.displayMessage;

  print(message);
}

30. Realtime

يمكن استخدام REST بالكامل دون تفعيل Realtime.

لكن عندما يحتاج التطبيق إلى تحديثات لحظية، مرر إعدادات Realtime:

await Hosteday.initializeApp(
  options: {
    HostedayOptionKeys.projectDomain:
        'max.hosteday.com',

    HostedayOptionKeys.realtimeAppKey:
        realtimeAppKey,

    HostedayOptionKeys.realtimeHost:
        realtimeHost,
  },
);

ثم:

if (Hosteday.config.hasRealtime) {
  await Hosteday.connectRealtime();
}

ولفصل الاتصال:

await Hosteday.disconnectRealtime();

توفر الحزمة قنوات:

Public
Private
Presence

وتحتاج القنوات الخاصة وPresence إلى مستخدم مسجل.

مثال لنشر حدث عام:

await Hosteday.client.publishPublicEvent(
  channel: 'products',
  event: 'updated',
  payload: {
    'id': 15,
  },
);

31. مثال عملي صغير

هذا مثال يبدأ من التهيئة ويعرض المنتجات الحقيقية:

import 'package:flutter/material.dart';
import 'package:hosteday_flutter/hosteday_flutter.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await Hosteday.initializeApp(
    options: {
      HostedayOptionKeys.projectDomain:
          'max.hosteday.com',
    },
  );

  runApp(const App());
}

class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: const ProductsPage(),
    );
  }
}

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

  @override
  State<ProductsPage> createState() =>
      _ProductsPageState();
}

class _ProductsPageState extends State<ProductsPage> {
  Future<List<Map<String, dynamic>>>
      loadProducts() async {
    final page = await Hosteday.client.index(
      'products',
      withAuth: false,
    );

    return page.items;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Products'),
      ),
      body: FutureBuilder<
          List<Map<String, dynamic>>>(
        future: loadProducts(),
        builder: (context, snapshot) {
          if (snapshot.connectionState ==
              ConnectionState.waiting) {
            return const Center(
              child: CircularProgressIndicator(),
            );
          }

          if (snapshot.hasError) {
            return Center(
              child: Text(
                snapshot.error.toString(),
              ),
            );
          }

          final products =
              snapshot.data ?? const [];

          if (products.isEmpty) {
            return const Center(
              child: Text('No products'),
            );
          }

          return ListView.builder(
            itemCount: products.length,
            itemBuilder: (context, index) {
              final product =
                  products[index];

              final image =
                  product['image'] as String?;

              return ListTile(
                leading: image == null ||
                        image.isEmpty
                    ? null
                    : Image.network(
                        Hosteday.client
                            .storageUrl(image),
                        width: 50,
                        height: 50,
                        fit: BoxFit.cover,
                      ),
                title: Text(
                  product['name']
                          ?.toString() ??
                      '',
                ),
                subtitle: Text(
                  product['price']
                          ?.toString() ??
                      '',
                ),
              );
            },
          );
        },
      ),
    );
  }
}

لاحظ أن التطبيق لم يحتج إلى معرفة شكل رابط API أو كيفية استخراج data.data.

طلب واحد:

Hosteday.client.index('products')

يعيد قائمة جاهزة للاستخدام.


32. الشكل المقترح داخل مشروع حقيقي

في تطبيق صغير تستطيع استدعاء الحزمة مباشرة من الشاشة.

لكن في المشاريع المتوسطة والكبيرة يفضل أن تكون الحزمة داخل Repository.

مثلًا:

final class ProductsRepository {
  Future<List<Map<String, dynamic>>>
      index({
    String? search,
  }) async {
    final page = await Hosteday.client.index(
      'products',
      search: search,
      withAuth: false,
    );

    return page.items;
  }

  Future<Map<String, dynamic>> show(
    int id,
  ) {
    return Hosteday.client.show(
      'products',
      id: id,
      withAuth: false,
    );
  }
}

ثم لا تحتاج الواجهة إلى معرفة أي شيء عن API:

final products =
    await repository.index();

وهذا يجعل تغيير الواجهة أو إضافة GetX وBloc وRiverpod لاحقًا أسهل بكثير.


33. أي واجهة أستخدم؟

عند العمل بالحزمة فكر بهذه الصورة:

Hosteday.initializeApp(...)

للتهيئة.

Hosteday.client

لبيانات مشروعك مثل:

products
categories
orders
services
posts
Hosteday.auth

لتسجيل الدخول والحساب والمستخدم الحالي والجلسة.

Hosteday.client.host
Hosteday.client.staticPages
Hosteday.client.storageUrl(...)

لبناء الروابط العامة وروابط الصفحات والصور.

Hosteday.realtime

عندما يحتاج التطبيق إلى بيانات لحظية.

هذا الفصل يجعل بنية التطبيق أسهل في الفهم ولا يحتاج المطور إلى التعامل مع تفاصيل HTTP الداخلية في كل مرة.


34. ملاحظة عند التحديث من الإصدارات القديمة

الكود الجديد يستخدم:

Hosteday

وليس:

HosteDay

وكذلك:

HostedayException
HostedayOptionKeys
HostedaySharedPreferencesAuthStorage

التسميات القديمة ما زالت موجودة للتوافق مع المشاريع السابقة، لكنها مهملة، لذلك يفضل استخدام Hosteday... في أي مشروع جديد.

كذلك لم يعد الأسلوب الموصى به إنشاء عميل يدوي:

late final HosteDayClient hosteday;

ثم تمرير الروابط والهيدرز يدويًا.

الأسلوب الحديث يبدأ من:

await Hosteday.initializeApp(...);

ثم يستخدم:

Hosteday.client
Hosteday.auth

في جميع أجزاء التطبيق.


الخلاصة

hosteday_flutter لم تعد مجرد Wrapper بسيط حول GET وPOST.

الحزمة توفر طبقة متكاملة بين تطبيق Flutter ومشروع Hosteday، تبدأ من تهيئة المشروع ثم إدارة روابط API، وقراءة البيانات والـ Pagination، وإنشاء وتحديث وحذف السجلات، وبناء روابط الصور والصفحات الثابتة، وإدارة تسجيل الدخول والجلسة، وصولًا إلى Realtime.

لبداية بسيطة يكفي أن تتذكر ثلاثة أسطر:

await Hosteday.initializeApp(
  options: {
    HostedayOptionKeys.projectDomain:
        'max.hosteday.com',
  },
);

final page =
    await Hosteday.client.index('products');

final products = page.items;

ومن هذه النقطة تستطيع إضافة المصادقة، والبحث، والفلترة، والصور، والصفحات، وRealtime عند حاجة التطبيق إليها.

الفكرة الأساسية هي أن تكتب منطق تطبيق Flutter، وتترك للحزمة تفاصيل الاتصال بمنصة Hosteday.