Flutter 18 يوليو 2026 141 مشاهدة

من الفكرة إلى التطبيق: برمجة تطبيق خدمات وعمالة محلية خطوة بخطوة باستخدام flutter و hosteday إنشاء العمليات الاساسية index, show, create, update, delete

تعلّم خطوة بخطوة كيفية إنشاء خدمات محلية وتحديثها وحذفها في تطبيق Flutter باستخدام GetX وحزمة hosteday_flutter، مع تنظيم المشروع وشرح الموديل والمستودع ووحدات التحكم.

M
Mustafamax
الكاتب
من الفكرة إلى التطبيق: برمجة تطبيق خدمات وعمالة محلية خطوة بخطوة باستخدام flutter و hosteday إنشاء العمليات الاساسية index, show, create, update, delete

تحتاج تطبيقات الخدمات والعمالة المحلية إلى أكثر من مجرد عرض البيانات؛ إذ يجب أن يتمكن المستخدم من إضافة خدمته، تعديل معلوماتها، ثم حذفها عند الحاجة. في هذا الجزء من سلسلة بناء تطبيق خدمات محلية باستخدام Flutter وHosteDay، سنكمل العمليات الأساسية على جدول الخدمات وننفّذ صفحات الإنشاء والتحديث والحذف مع ربطها بواجهة API.

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

ماذا ستتعلم في هذا المقال؟

بعد إكمال الشرح ستكون لديك البنية اللازمة لتنفيذ عمليات CRUD الأساسية:

  • index: عرض جميع الخدمات.
  • show: عرض خدمة واحدة بالاعتماد على المعرّف.
  • create: إنشاء خدمة جديدة.
  • update: تحديث خدمة موجودة بالاعتماد على المعرّف.
  • delete: حذف خدمة بالاعتماد على المعرّف.

سبق أن نفّذنا عمليتي index وshow في الجزء الثاني من السلسلة. يمكنك الرجوع إلى مقال بناء تطبيق خدمات وعمالة محلية باستخدام Flutter وHosteDay – الجزء الثاني، ثم العودة إلى هذا الدليل لإكمال الإنشاء والتحديث والحذف.

مهم: حدّث حزمة hosteday_flutter في مشروعك قبل المتابعة، لأن هذا الجزء يعتمد على التغييرات الجديدة التي أضيفت إلى الحزمة.

بنية ملفات ميزة الخدمات

يساعد تقسيم ميزة الخدمات إلى bindings وcontrollers وmodels وrepositories وviews وwidgets على فصل المسؤوليات، وتسهيل صيانة المشروع وتطويره لاحقًا.

lib/features/services/
├── bindings
│   ├── service_binding.dart
│   ├── service_create_binding.dart
│   ├── services_binding.dart
│   └── service_update_binding.dart
├── controllers
│   ├── service_controller.dart
│   ├── service_create_controller.dart
│   ├── services_controller.dart
│   └── service_update_controller.dart
├── models
│   └── services_response.dart
├── repositories
│   └── services_repository.dart
├── views
│   ├── create_view.dart
│   ├── services_view.dart
│   ├── service_view.dart
│   └── update_view.dart
└── widgets
    ├── contact_button.dart
    ├── contact_section.dart
    ├── details_card.dart
    ├── error_state.dart
    ├── information_row.dart
    ├── section_title.dart
    ├── service_avatar.dart
    ├── service_card.dart
    ├── service_description.dart
    ├── service_empty_state.dart
    ├── service_information_section.dart
    ├── service_status_badge.dart
    └── status_data.dart

إعداد مسارات إنشاء الخدمة وتحديثها

سنبدأ بتسجيل صفحات الإنشاء والتحديث في GetX، ثم نعرّف أسماء المسارات ونضيف دوال التنقل إليها.

تسجيل الصفحات في app_pages.dart

أضف المقطعين التاليين إلى الملف lib/app/routes/app_pages.dart:

GetPage(
 name: AppRoutes.serviceCreate,
 page: () => const CreateView(),
 binding: ServiceCreateBinding(),
),
GetPage(
 name: AppRoutes.serviceUpdate,
 page: () => const UpdateView(),
 binding: ServiceUpdateBinding(),
),

يربط كل GetPage بين اسم المسار والواجهة المقابلة له، ويحدد الـBinding المسؤول عن تجهيز Controller والاعتماديات قبل بناء الصفحة. بهذه الطريقة تصل صفحة الإنشاء إلى ServiceCreateController، بينما تحصل صفحة التحديث على ServiceUpdateController عند فتحها.

تعريف أسماء المسارات في app_routes.dart

أضف الثابتين التاليين إلى الملف lib/app/routes/app_routes.dart:

static const serviceCreate = '/service-create';
static const serviceUpdate = '/service-update/:id';

يمثل serviceCreate مسار صفحة إضافة خدمة جديدة، بينما يحتوي serviceUpdate على المعامل الديناميكي :id لتمييز الخدمة المطلوب تحديثها.

إضافة دوال التنقل في go_page.dart

أضف الكود التالي إلى الملف lib/app/routes/go_page.dart:

static Future<dynamic>? serviceCreate() => Get.toNamed(AppRoutes.serviceCreate);
static Future<Service?> serviceUpdate(Service service) async {
 final result = await Get.toNamed(
   AppRoutes.serviceUpdate,
   arguments: service,
 );


 return result is Service ? result : null;
}

تفتح الدالة serviceCreate() صفحة إنشاء الخدمة. أما serviceUpdate() فتستقبل كائن Service وترسله إلى صفحة التحديث من خلال arguments. وعند إغلاق الصفحة تتحقق الدالة من النتيجة؛ فإذا كانت خدمة محدثة تعيدها إلى الصفحة السابقة، وإلا تعيد null. يساعد تحديد النوع Future<Service?> على تجنب تعارض نوع Route مع نوع النتيجة المنتظرة.

إنشاء Bindings للخدمات

تتولى Bindings حقن الاعتماديات عند فتح الصفحة، وهو ما يحافظ على الواجهات خفيفة ويجعل إنشاء Controllers منظمًا.

ملف service_create_binding.dart

أنشئ الملف lib/features/services/bindings/service_create_binding.dart وأضف إليه الكود التالي:

import 'package:at_your_service/features/services/controllers/service_create_controller.dart';
import 'package:at_your_service/features/services/repositories/services_repository.dart';
import 'package:get/get.dart';
import 'package:hosteday_flutter/hosteday_flutter.dart';


class ServiceCreateBinding extends Bindings {
 @override
 void dependencies() {
   Get.lazyPut<ServiceCreateController>(
         () => ServiceCreateController(
       repository: Get.find<ServicesRepository>(),
           userId: HosteDay.auth.currentUser!.id
     ),
     fenix: true,
   );
 }
}

ينشئ هذا الـBinding كائنًا من ServiceCreateController عند الحاجة، ويمرر إليه مستودع الخدمات ومعرّف المستخدم المسجل حاليًا. يسمح الخيار fenix: true لـGetX بإعادة إنشاء Controller إذا حُذف من الذاكرة ثم طُلِب مرة أخرى.

يعتمد السطر HosteDay.auth.currentUser!.id على وجود مستخدم مسجل الدخول، لذلك ينبغي منع الوصول إلى صفحة الإنشاء للزائر غير المصادق عليه.

ملف service_update_binding.dart

أنشئ الملف lib/features/services/bindings/service_update_binding.dart وأضف إليه الكود التالي:

import 'package:at_your_service/features/services/controllers/service_update_controller.dart';
import 'package:at_your_service/features/services/models/services_response.dart';
import 'package:at_your_service/features/services/repositories/services_repository.dart';
import 'package:get/get.dart';


class ServiceUpdateBinding extends Bindings {
 @override
 void dependencies() {
   final argument = Get.arguments;


   if (argument is! Service) {
     throw ArgumentError(
       'يجب تمرير كائن Service عند فتح صفحة تحديث الخدمة',
     );
   }


   Get.lazyPut<ServiceUpdateController>(
         () => ServiceUpdateController(
       repository: Get.find<ServicesRepository>(),
       service: argument,
     ),
   );
 }
}

يقرأ هذا الـBinding القيمة المرسلة عبر Get.arguments ويتأكد من أنها كائن من نوع Service. إذا لم تكن كذلك، يرمي ArgumentError برسالة واضحة بدل إنشاء صفحة تحديث بلا بيانات. وبعد نجاح التحقق يحقن المستودع والخدمة المحددة في ServiceUpdateController.

إنشاء Controller لإضافة خدمة جديدة

أنشئ الملف lib/features/services/controllers/service_create_controller.dart، ثم أضف الكود التالي كما هو:

import 'package:at_your_service/features/services/models/services_response.dart';
import 'package:at_your_service/features/services/repositories/services_repository.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';


class ServiceCreateController extends GetxController {
 ServiceCreateController({
   required ServicesRepository repository,
   required this.userId,
 }) : _repository = repository;


 final ServicesRepository _repository;


 /// معرّف المستخدم المسجل حالياً.
 final String userId;


 final formKey = GlobalKey<FormState>();


 final titleController = TextEditingController();
 final descriptionController = TextEditingController();
 final avatarController = TextEditingController();
 final facebookController = TextEditingController();
 final whatsappController = TextEditingController();


 final selectedStatus = ServiceStatus.active.obs;
 final isSubmitting = false.obs;


 String? validateTitle(String? value) {
   final title = value?.trim() ?? '';


   if (title.isEmpty) {
     return 'يرجى كتابة عنوان الخدمة';
   }


   if (title.length > 255) {
     return 'يجب ألا يتجاوز العنوان 255 حرفاً';
   }


   return null;
 }


 String? validateDescription(String? value) {
   final description = value?.trim() ?? '';


   if (description.isNotEmpty && description.length < 10) {
     return 'يجب ألا يقل الوصف عن 10 أحرف';
   }


   return null;
 }


 String? validateOptionalUrl(String? value) {
   final text = value?.trim() ?? '';


   if (text.isEmpty) {
     return null;
   }


   final uri = Uri.tryParse(text);


   if (uri == null ||
       !uri.hasScheme ||
       !uri.hasAuthority ||
       (uri.scheme != 'http' && uri.scheme != 'https')) {
     return 'يرجى إدخال رابط صحيح يبدأ بـ http أو https';
   }


   return null;
 }


 String? _optionalText(String value) {
   final normalized = value.trim();
   return normalized.isEmpty ? null : normalized;
 }


 Future<void> createService() async {
   FocusManager.instance.primaryFocus?.unfocus();


   if (isSubmitting.value) {
     return;
   }


   if (!(formKey.currentState?.validate() ?? false)) {
     return;
   }


   if (userId.trim().isEmpty) {
     Get.snackbar(
       'تعذر إنشاء الخدمة',
       'لم يتم العثور على معرّف المستخدم الحالي',
       snackPosition: SnackPosition.BOTTOM,
     );
     return;
   }


   isSubmitting.value = true;


   try {
     final service = Service.create(
       userId: userId.trim(),
       title: titleController.text.trim(),
       description: _optionalText(descriptionController.text),
       avatar: _optionalText(avatarController.text),
       socials: ServiceSocials(
         facebook: _optionalText(facebookController.text),
         whatsapp: _optionalText(whatsappController.text),
       ),
       status: selectedStatus.value,
     );


     final result = await _repository.create(service);


     if (!result.success || !result.data) {
       Get.snackbar(
         'تعذر إنشاء الخدمة',
         result.message ?? 'لم يتم إنشاء الخدمة، حاول مرة أخرى',
         snackPosition: SnackPosition.BOTTOM,
       );
       return;
     }


     Get.back(result: true);


     Get.snackbar(
       'تم إنشاء الخدمة',
       result.message ?? 'تمت إضافة الخدمة بنجاح',
       snackPosition: SnackPosition.BOTTOM,
     );
   } catch (error) {
     Get.snackbar(
       'حدث خطأ',
       _errorMessage(error),
       snackPosition: SnackPosition.BOTTOM,
     );
   } finally {
     isSubmitting.value = false;
   }
 }


 String _errorMessage(Object error) {
   final message = error.toString().trim();


   if (message.isEmpty) {
     return 'تعذر الاتصال بالخادم';
   }


   return message.replaceFirst('Exception: ', '');
 }


 @override
 void onClose() {
   titleController.dispose();
   descriptionController.dispose();
   avatarController.dispose();
   facebookController.dispose();
   whatsappController.dispose();


   super.onClose();
 }
}

شرح ServiceCreateController

يجمع هذا Controller منطق نموذج إنشاء الخدمة في مكان واحد، ويمكن تلخيص مسؤولياته كالتالي:

  • يستقبل ServicesRepository ومعرّف المستخدم userId من الـBinding بدل إنشائهما داخل الواجهة.
  • يستخدم formKey للتحقق من جميع الحقول قبل إرسال الطلب.
  • يدير حقول العنوان والوصف والصورة وروابط فيسبوك وواتساب من خلال TextEditingController.
  • يحفظ حالة الخدمة المختارة داخل selectedStatus، ويستخدم isSubmitting لمنع تكرار الطلب عند الضغط على زر الحفظ أكثر من مرة.

تتحقق الدالة validateTitle() من وجود عنوان ومن ألا يزيد طوله على 255 حرفًا. وتسمح validateDescription() بوصف فارغ، لكنها تشترط ألا يقل طوله عن عشرة أحرف عند إدخاله. أما validateOptionalUrl() فتسمح بترك الرابط فارغًا، وتقبل فقط رابطًا صحيحًا يبدأ ببروتوكول http أو https.

تستخدم الدالة الخاصة _optionalText() القيمة null بدل النص الفارغ؛ وهذا يمنع إرسال سلاسل فارغة للحقول الاختيارية. بعد ذلك تنفذ createService() التسلسل التالي:

  1. تغلق لوحة المفاتيح وتمنع إرسال طلب ثانٍ أثناء تنفيذ الطلب الأول.
  2. تتحقق من صحة النموذج ومن وجود معرّف المستخدم.
  3. تنشئ كائن Service بواسطة Service.create().
  4. ترسل الكائن إلى _repository.create(service).
  5. تعرض رسالة نجاح أو خطأ بحسب استجابة API.
  6. تعيد isSubmitting إلى false داخل finally مهما كانت نتيجة الطلب.

وأخيرًا، تحرر onClose() جميع وحدات التحكم النصية عند إغلاق Controller لتجنب الاحتفاظ بموارد لم تعد مستخدمة.

إنشاء Controller لتحديث الخدمة

أنشئ الملف lib/features/services/controllers/service_update_controller.dart وضع فيه الكود التالي:

import 'package:at_your_service/features/services/models/services_response.dart';
import 'package:at_your_service/features/services/repositories/services_repository.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';


class ServiceUpdateController extends GetxController {
 ServiceUpdateController({
   required ServicesRepository repository,
   required Service service,
 })  : _repository = repository,
       _service = service,
       titleController = TextEditingController(
         text: service.title,
       ),
       descriptionController = TextEditingController(
         text: service.description ?? '',
       ),
       avatarController = TextEditingController(
         text: service.avatar ?? '',
       ),
       facebookController = TextEditingController(
         text: service.socials.facebook ?? '',
       ),
       whatsappController = TextEditingController(
         text: service.socials.whatsapp ?? '',
       ),
       selectedStatus = (
           service.status == ServiceStatus.unknown
               ? ServiceStatus.active
               : service.status
       ).obs;


 final ServicesRepository _repository;
 final Service _service;


 Service get service => _service;


 final formKey = GlobalKey<FormState>();


 final TextEditingController titleController;
 final TextEditingController descriptionController;
 final TextEditingController avatarController;
 final TextEditingController facebookController;
 final TextEditingController whatsappController;


 final Rx<ServiceStatus> selectedStatus;
 final isSubmitting = false.obs;


 String? validateTitle(String? value) {
   final title = value?.trim() ?? '';


   if (title.isEmpty) {
     return 'يرجى كتابة عنوان الخدمة';
   }


   if (title.length > 255) {
     return 'يجب ألا يتجاوز العنوان 255 حرفاً';
   }


   return null;
 }


 String? validateDescription(String? value) {
   final description = value?.trim() ?? '';


   if (description.isNotEmpty && description.length < 10) {
     return 'يجب ألا يقل الوصف عن 10 أحرف';
   }


   return null;
 }


 String? validateOptionalUrl(String? value) {
   final text = value?.trim() ?? '';


   if (text.isEmpty) {
     return null;
   }


   final uri = Uri.tryParse(text);


   if (uri == null ||
       !uri.hasScheme ||
       !uri.hasAuthority ||
       (uri.scheme != 'http' && uri.scheme != 'https')) {
     return 'يرجى إدخال رابط صحيح يبدأ بـ http أو https';
   }


   return null;
 }


 String? _optionalText(String? value) {
   final normalized = value?.trim() ?? '';


   return normalized.isEmpty ? null : normalized;
 }


 bool get hasChanges {
   return titleController.text.trim() != _service.title.trim() ||
       _optionalText(descriptionController.text) !=
           _optionalText(_service.description) ||
       _optionalText(avatarController.text) !=
           _optionalText(_service.avatar) ||
       _optionalText(facebookController.text) !=
           _optionalText(_service.socials.facebook) ||
       _optionalText(whatsappController.text) !=
           _optionalText(_service.socials.whatsapp) ||
       selectedStatus.value != _service.status;
 }


 Future<void> updateService() async {
   FocusManager.instance.primaryFocus?.unfocus();


   if (isSubmitting.value) {
     return;
   }


   if (!(formKey.currentState?.validate() ?? false)) {
     return;
   }


   if (!hasChanges) {
     Get.snackbar(
       'لا توجد تغييرات',
       'عدّل أحد الحقول أولاً قبل حفظ الخدمة',
       snackPosition: SnackPosition.BOTTOM,
     );


     return;
   }


   isSubmitting.value = true;


   Service? updatedService;
   String? successMessage;


   try {
     updatedService = Service(
       id: _service.id,
       userId: _service.userId,
       avatar: _optionalText(avatarController.text),
       socials: ServiceSocials(
         facebook: _optionalText(facebookController.text),
         whatsapp: _optionalText(whatsappController.text),
       ),
       title: titleController.text.trim(),
       description: _optionalText(descriptionController.text),
       createdAt: _service.createdAt,
       updatedAt: DateTime.now(),
       status: selectedStatus.value,
     );


     final result = await _repository.update(updatedService);


     if (!result.success || !result.data) {
       Get.snackbar(
         'تعذر تحديث الخدمة',
         result.message ?? 'لم يتم حفظ التعديلات، حاول مرة أخرى',
         snackPosition: SnackPosition.BOTTOM,
       );


       updatedService = null;
       return;
     }


     successMessage = result.message;
   } catch (error) {
     updatedService = null;


     Get.snackbar(
       'حدث خطأ',
       _errorMessage(error),
       snackPosition: SnackPosition.BOTTOM,
     );
   } finally {
     isSubmitting.value = false;
   }


   if (updatedService != null) {
     Get.back(result: updatedService);


     Get.snackbar(
       'تم تحديث الخدمة',
       successMessage ?? 'تم حفظ التعديلات بنجاح',
       snackPosition: SnackPosition.BOTTOM,
     );
   }
 }


 String _errorMessage(Object error) {
   final message = error.toString().trim();


   if (message.isEmpty) {
     return 'تعذر الاتصال بالخادم';
   }


   return message.replaceFirst('Exception: ', '');
 }


 @override
 void onClose() {
   titleController.dispose();
   descriptionController.dispose();
   avatarController.dispose();
   facebookController.dispose();
   whatsappController.dispose();


   super.onClose();
 }
}

شرح ServiceUpdateController

يشبه Controller التحديث Controller الإنشاء، لكنه يبدأ بتعبئة الحقول من بيانات الخدمة الحالية. يستقبل كائن Service في الباني، ثم يضع العنوان والوصف والصورة وروابط التواصل والحالة داخل الحقول التفاعلية، حتى تظهر البيانات الحالية فور فتح صفحة التعديل.

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

تنفذ updateService() الخطوات التالية:

  1. تتحقق من النموذج ومن عدم وجود طلب جارٍ.
  2. تتأكد من تعديل حقل واحد على الأقل.
  3. تنشئ نسخة محدثة من Service مع الاحتفاظ بالمعرّف ومعرّف المستخدم وتاريخ الإنشاء.
  4. ترسل البيانات إلى _repository.update(updatedService).
  5. تعيد الخدمة المحدثة إلى الصفحة السابقة عبر Get.back(result: updatedService) عند نجاح العملية.
  6. تعرض رسائل مناسبة للنجاح أو الفشل، ثم تعيد حالة الإرسال إلى وضعها الطبيعي.

كما ينظف onClose() وحدات التحكم النصية بعد انتهاء استخدامها.

تحديث موديل الخدمات

عدّل الملف lib/features/services/models/services_response.dart ليحتوي على الكود التالي:

import 'dart:convert';


class ServicesResponse {
 final bool success;
 final String? message;
 final ServicePagination data;


 const ServicesResponse({
   required this.success,
   required this.message,
   required this.data,
 });


 factory ServicesResponse.fromJson(Map<String, dynamic> json) {
   return ServicesResponse(
     success: json['success'] == true,
     message: json['message']?.toString(),
     data: ServicePagination.fromJson(
       Map<String, dynamic>.from(json['data'] ?? {}),
     ),
   );
 }


 Map<String, dynamic> toJson() {
   return {
     'success': success,
     'message': message,
     'data': data.toJson(),
   };
 }
}


class ServicePagination {
 final int currentPage;
 final String? currentPageUrl;
 final List<Service> services;
 final String? firstPageUrl;
 final int? from;
 final String? nextPageUrl;
 final String? path;
 final int perPage;
 final String? prevPageUrl;
 final int? to;


 const ServicePagination({
   required this.currentPage,
   required this.currentPageUrl,
   required this.services,
   required this.firstPageUrl,
   required this.from,
   required this.nextPageUrl,
   required this.path,
   required this.perPage,
   required this.prevPageUrl,
   required this.to,
 });


 factory ServicePagination.fromJson(Map<String, dynamic> json) {
   final rawProfiles = json['data'];


   return ServicePagination(
     currentPage: _toInt(json['current_page']),
     currentPageUrl: json['current_page_url']?.toString(),
     services: rawProfiles is List
         ? rawProfiles
         .whereType<Map>()
         .map(
           (item) => Service.fromJson(
         Map<String, dynamic>.from(item),
       ),
     )
         .toList()
         : const [],
     firstPageUrl: json['first_page_url']?.toString(),
     from: _toNullableInt(json['from']),
     nextPageUrl: json['next_page_url']?.toString(),
     path: json['path']?.toString(),
     perPage: _toInt(json['per_page']),
     prevPageUrl: json['prev_page_url']?.toString(),
     to: _toNullableInt(json['to']),
   );
 }


 bool get hasNextPage => nextPageUrl != null && nextPageUrl!.isNotEmpty;


 bool get hasPreviousPage => prevPageUrl != null && prevPageUrl!.isNotEmpty;


 Map<String, dynamic> toJson() {
   return {
     'current_page': currentPage,
     'current_page_url': currentPageUrl,
     'data': services.map((service) => service.toJson()).toList(),
     'first_page_url': firstPageUrl,
     'from': from,
     'next_page_url': nextPageUrl,
     'path': path,
     'per_page': perPage,
     'prev_page_url': prevPageUrl,
     'to': to,
   };
 }
}


class Service {
 final int id;
 final String userId;
 final String? avatar;
 final ServiceSocials socials;
 final String title;
 final String? description;
 final DateTime? createdAt;
 final DateTime? updatedAt;
 final ServiceStatus status;


 const Service({
   required this.id,
   required this.userId,
   required this.avatar,
   required this.socials,
   required this.title,
   required this.description,
   required this.createdAt,
   required this.updatedAt,
   required this.status,
 });


 factory Service.fromJson(Map<String, dynamic> json) {
   return Service(
     id: _toInt(json['id']),
     userId: json['user_id']?.toString() ?? '',
     avatar: _extractUrl(json['avatar']?.toString()),
     socials: ServiceSocials.fromDynamic(json['socials']),
     title: json['title']?.toString() ?? '',
     description: json['desc']?.toString(),
     createdAt: _toDateTime(json['created_at']),
     updatedAt: _toDateTime(json['updated_at']),
     status: ServiceStatus.fromValue(json['status']?.toString()),
   );
 }


 Map<String, dynamic> toJson() {
   return {
     'id': id,
     'user_id': userId,
     'avatar': avatar,
     'socials': socials.toJson(),
     'title': title,
     'desc': description,
     'created_at': createdAt?.toIso8601String(),
     'updated_at': updatedAt?.toIso8601String(),
     'status': status.value,
   };
 }


 String? get facebookUrl => socials.facebook;


 String? get whatsappUrl => socials.whatsapp;


 factory Service.create({
   required String userId,
   required String title,
   String? avatar,
   String? description,
   required ServiceSocials socials,
   ServiceStatus status = ServiceStatus.active,
 }) {
   return Service(
     id: 0,
     userId: userId,
     avatar: avatar,
     socials: socials,
     title: title,
     description: description,
     createdAt: null,
     updatedAt: null,
     status: status,
   );
 }


 Map<String, dynamic> toCreateJson() {
   final socialLinks = socials.toJson()
     ..removeWhere(
           (_, value) =>
       value == null ||
           (value is String && value.trim().isEmpty),
     );


   return <String, dynamic>{
     'user_id': userId,
     'avatar': avatar,
     'socials': socialLinks,
     'title': title,
     'desc': description,
     'status': status.value,
   }..removeWhere((_, value) => value == null);
 }


 Map<String, dynamic> toUpdateJson() {
   return {
     'avatar': avatar,
     'socials': socials.toJson(),
     'title': title,
     'desc': description,
     'status': status.value,
   };
 }
}


class ServiceSocials {
 final String? facebook;
 final String? whatsapp;


 const ServiceSocials({
   required this.facebook,
   required this.whatsapp,
 });


 factory ServiceSocials.fromDynamic(dynamic value) {
   if (value == null) {
     return const ServiceSocials(
       facebook: null,
       whatsapp: null,
     );
   }


   try {
     final decoded = value is String ? jsonDecode(value) : value;


     if (decoded is Map) {
       return ServiceSocials(
         facebook: _extractUrl(decoded['facebook']?.toString()),
         whatsapp: _extractUrl(decoded['whatsapp']?.toString()),
       );
     }
   } catch (_) {
     // عند وصول JSON غير صالح، نعيد قيماً فارغة بدون إيقاف التطبيق.
   }


   return const ServiceSocials(
     facebook: null,
     whatsapp: null,
   );
 }


 Map<String, dynamic> toJson() {
   return {
     'facebook': facebook,
     'whatsapp': whatsapp,
   };
 }
}


enum ServiceStatus {
 draft('draft'),
 published('published'),
 active('active'),
 paused('paused'),
 unknown('unknown');


 final String value;


 const ServiceStatus(this.value);


 factory ServiceStatus.fromValue(String? value) {
   return ServiceStatus.values.firstWhere(
         (status) => status.value == value,
     orElse: () => ServiceStatus.unknown,
   );
 }
}


int _toInt(dynamic value) {
 if (value is int) return value;


 return int.tryParse(value?.toString() ?? '') ?? 0;
}


int? _toNullableInt(dynamic value) {
 if (value == null) return null;


 if (value is int) return value;


 return int.tryParse(value.toString());
}


DateTime? _toDateTime(dynamic value) {
 if (value == null) return null;


 return DateTime.tryParse(value.toString());
}


String? _extractUrl(String? value) {
 if (value == null || value.trim().isEmpty) {
   return null;
 }


 final markdownUrl = RegExp(r'^\[(.*?)\]\((.*?)\)$');
 final match = markdownUrl.firstMatch(value.trim());


 if (match != null) {
   return match.group(2)?.trim();
 }


 return value.trim();
}


class ActionResultModel {
 final bool success;
 final String? message;
 final bool data;


 const ActionResultModel({
   required this.success,
   this.message,
   required this.data,
 });


 factory ActionResultModel.fromJson(Map<String, dynamic> json) {
   return ActionResultModel(
     success: json['success'] == true,
     message: json['message']?.toString(),
     data: json['data'] == true,
   );
 }


 Map<String, dynamic> toJson() {
   return {
     'success': success,
     'message': message,
     'data': data,
   };
 }
}

شرح موديل الخدمات

يضم هذا الملف عدة أصناف، ولكل صنف مسؤولية محددة:

الصنف المسؤولية
ServicesResponse قراءة الاستجابة العامة القادمة من API، بما فيها حالة النجاح والرسالة وبيانات الصفحات.
ServicePagination تمثيل بيانات ترقيم الصفحات وقائمة الخدمات وروابط الصفحة السابقة والتالية.
Service تمثيل الخدمة وتحويلها من JSON وإليه، وتجهيز بيانات الإنشاء والتحديث.
ServiceSocials قراءة روابط فيسبوك وواتساب سواء وصلت كخريطة JSON أو كنص JSON.
ServiceStatus حصر الحالات الممكنة للخدمة وتحويل النص القادم من API إلى قيمة enum.
ActionResultModel تمثيل نتيجة عمليات الإنشاء والتحديث والحذف.

تقرأ ServicesResponse.fromJson() الغلاف الخارجي للاستجابة، ثم تمرر data إلى ServicePagination.fromJson(). ويحول ServicePagination عناصر القائمة إلى كائنات Service، مع توفير hasNextPage وhasPreviousPage لتسهيل بناء أزرار التنقل بين الصفحات.

داخل Service توجد ثلاثة أنواع مهمة من التحويل:

  • fromJson() يحول بيانات API إلى كائن Dart.
  • toCreateJson() يجهز حقول إنشاء الخدمة ويحذف القيم الاختيارية الفارغة.
  • toUpdateJson() يجهز الحقول القابلة للتحديث.

تتعامل ServiceSocials.fromDynamic() مع الحقل socials سواء أرسله الخادم ككائن JSON أو كسلسلة نصية تحتوي على JSON. وإذا كانت القيمة غير صالحة، تعيد روابط فارغة دون إيقاف التطبيق. كذلك تتولى _extractUrl() استخراج الرابط إذا وصل بصيغة Markdown، أو إرجاعه كما هو إذا كان رابطًا عاديًا.

أما الدوال _toInt() و_toNullableInt() و_toDateTime() فتجعل قراءة القيم القادمة من API أكثر أمانًا عند اختلاف نوع القيمة أو غيابها.

ربط التطبيق بواجهة API عبر Repository

عدّل الملف lib/features/services/repositories/services_repository.dart ليحتوي على الكود التالي:

import 'dart:async';


import 'package:at_your_service/features/services/models/services_response.dart';
import 'package:hosteday_flutter/hosteday_flutter.dart';


class ServicesRepository {
 static const String servicesPath = '/api/services';


 Future<ServicePagination> services({
   int page = 1,
   int perPage = 15,
   String? search,
 }) async {
   final safePage = page < 1 ? 1 : page;
   final safePerPage = perPage.clamp(1, 100);


   final queryParameters = <String, String>{
     'page': safePage.toString(),
     'per_page': safePerPage.toString(),
   };


   final normalizedSearch = search?.trim();


   if (normalizedSearch != null && normalizedSearch.isNotEmpty) {
     queryParameters['search'] = normalizedSearch;
   }


   final uri = Uri(path: servicesPath, queryParameters: queryParameters);


   try {
     final response = await HosteDay.client.get(uri.toString());
     final servicesResponse = ServicesResponse.fromJson(response);


     return servicesResponse.data;
   } catch (error) {
     rethrow;
   }
 }


 Future<Service> service(String profileId) async {
   try {
     final response = await HosteDay.client.get(
       '$servicesPath/$profileId',
     );
     return Service.fromJson(response["data"]);
   } catch (error) {
     rethrow;
   }
 }






 Future<ActionResultModel> create(Service service) async {
   final response = await HosteDay.client.post(
     servicesPath,
     body: service.toCreateJson(),
     withAuth: true,
   );


   return ActionResultModel.fromJson(
     Map<String, dynamic>.from(response),
   );
 }


 Future<ActionResultModel> update(Service service) async {
   final response = await HosteDay.client.put(
     servicesPath,
     id: service.id,
     relationField: 'user_id',
     relationValue: service.userId,
     body: service.toJson(),
     withAuth: true,
   );


   return ActionResultModel.fromJson(
     Map<String, dynamic>.from(response),
   );
 }


 Future<ActionResultModel> delete(Service service) async {
   final response = await HosteDay.client.delete(
     servicesPath,
     id: service.id,
     relationField: 'user_id',
     relationValue: service.userId,
     withAuth: true,
   );


   return ActionResultModel.fromJson(
     Map<String, dynamic>.from(response),
   );
 }
}

شرح ServicesRepository

يفصل Repository الاتصال بالخادم عن الواجهات وControllers. وبذلك لا تحتاج صفحات Flutter إلى معرفة تفاصيل المسارات أو معاملات الطلب، بل تستدعي دالة واضحة وتتعامل مع موديل Dart جاهز.

تستخدم جميع العمليات المسار الأساسي /api/services:

الدالة نوع الطلب وظيفتها
services() GET جلب قائمة الخدمات مع رقم الصفحة وعدد العناصر والبحث الاختياري.
service() GET جلب خدمة واحدة باستخدام المعرّف.
create() POST إنشاء خدمة جديدة وإرسال بيانات toCreateJson().
update() PUT تحديث الخدمة مع تمرير المعرّف وحقول التحقق من ملكية المستخدم.
delete() DELETE حذف الخدمة بعد تمرير المعرّف وبيانات العلاقة مع المستخدم.

تضبط services() رقم الصفحة على قيمة لا تقل عن 1، وتحصر perPage بين 1 و100، ثم تضيف كلمة البحث فقط عندما تكون غير فارغة. بعد استلام الاستجابة تحولها إلى ServicesResponse وتعيد بيانات ServicePagination مباشرة.

تستخدم عمليات create() وupdate() وdelete() الخيار withAuth: true لأن تغيير البيانات يتطلب مستخدمًا مصادقًا عليه. وفي التحديث والحذف يُمرر relationField: 'user_id' مع relationValue: service.userId لربط العملية بصاحب الخدمة، بينما يحول ActionResultModel استجابة الخادم إلى نتيجة موحدة يسهل التعامل معها داخل Controllers.

صفحات واجهة الخدمات

جرى إنشاء الملفات التالية أو تعديلها لربط Controllers بالواجهات:

  • lib/features/services/views/create_view.dart
  • lib/features/services/views/service_view.dart
  • lib/features/services/views/update_view.dart

لم ندرج أكواد هذه الواجهات هنا حتى يبقى المقال مركزًا على منطق CRUD ولا يصبح أطول من اللازم. يمكنك الاطلاع على التطبيق الكامل للأكواد من فرع المقال في مستودع المشروع على GitHub.

كيف تتدفق البيانات داخل التطبيق؟

عند فتح صفحة إنشاء خدمة، يجهز ServiceCreateBinding الـController ويمرر إليه المستودع ومعرّف المستخدم. تجمع الصفحة القيم من المستخدم، ثم يحولها ServiceCreateController إلى كائن Service ويرسلها إلى ServicesRepository، الذي ينفذ طلب POST بواسطة HosteDay.client.

وفي التحديث، تُمرر الخدمة المحددة إلى صفحة التعديل. يتحقق ServiceUpdateBinding من نوعها، ثم يملأ ServiceUpdateController الحقول بالقيم الحالية. بعد التعديل يرسل Repository طلب PUT، وتعود الخدمة المحدثة إلى الصفحة السابقة لتحديث العرض مباشرة.

أما الحذف، فيمرر الخدمة إلى ServicesRepository.delete()، الذي يرسل المعرّف ومعلومات علاقة المستخدم ضمن طلب DELETE مع المصادقة.

اختبار عمليات إنشاء الخدمة وتحديثها وحذفها

قبل اعتماد الميزة، اختبر الحالات التالية:

  • فتح صفحة الإنشاء أثناء تسجيل الدخول والتأكد من وصول معرّف المستخدم.
  • محاولة حفظ نموذج بلا عنوان ومراجعة رسالة التحقق.
  • إدخال رابط لا يبدأ بـhttp أو https والتأكد من رفضه.
  • إنشاء خدمة صحيحة والتأكد من ظهور رسالة النجاح.
  • فتح صفحة التحديث والتأكد من ظهور القيم الحالية في الحقول.
  • الضغط على الحفظ دون تعديل أي قيمة والتأكد من ظهور رسالة «لا توجد تغييرات».
  • تعديل حقل واحد والتأكد من عودة كائن الخدمة المحدث إلى الصفحة السابقة.
  • حذف خدمة تخص المستخدم الحالي والتأكد من تحديث قائمة الخدمات بعد نجاح الطلب.
  • اختبار انقطاع الاتصال أو فشل API والتأكد من عرض رسالة خطأ مفهومة.

أسئلة شائعة حول CRUD في Flutter وHosteDay

ما المقصود بعمليات CRUD؟

CRUD هو اختصار للعمليات الأساسية على البيانات: الإنشاء Create، والقراءة Read، والتحديث Update، والحذف Delete. في هذا التطبيق تقابلها عمليات إنشاء الخدمة وعرضها وتعديلها وحذفها.

لماذا نستخدم Repository في تطبيق Flutter؟

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

ما فائدة Bindings في GetX؟

تجهز Bindings الـControllers والاعتماديات في الوقت المناسب عند فتح المسار. وهذا يقلل الترابط بين الصفحة ومنطق العمل، وينظم دورة حياة الكائنات داخل التطبيق.

لماذا نمرر relationField وrelationValue عند التحديث والحذف؟

يستخدمهما الطلب لربط السجل بالمستخدم الذي يملكه، بحيث تُنفذ العملية على الخدمة المرتبطة بمعرّف المستخدم المحدد إلى جانب معرّف الخدمة.

كيف أمنع إرسال الطلب أكثر من مرة؟

يستخدم كل Controller المتغير التفاعلي isSubmitting. عند بدء الطلب تصبح قيمته true، وأي ضغط إضافي يتوقف مبكرًا، ثم تعود قيمته إلى false داخل finally بعد انتهاء العملية.

الخلاصة

أصبح لدينا الآن تنظيم متكامل لعمليات إنشاء الخدمات وتحديثها وحذفها في تطبيق Flutter باستخدام GetX وحزمة hosteday_flutter. فصلنا التنقل وحقن الاعتماديات والتحقق من النماذج وتحويل JSON والاتصال بواجهة API، وهو ما يجعل الكود أوضح وأسهل في التوسع مع نمو التطبيق.

إذا كنت تريد بناء API وإدارة قاعدة بيانات لمشروع Flutter دون الانشغال بإعداد بنية خلفية معقدة، يمكنك التعرف إلى مزايا منصة HosteDay والبدء في ربط تطبيقك بخدماتها.