05 يوليو 2026 39 مشاهدة

من الفكرة إلى التطبيق: برمجة تطبيق خدمات وعمالة محلية خطوة بخطوة باستخدام flutter و hosteday إنشاء صفحات الخدمات.

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

A
Admin
الكاتب
من الفكرة إلى التطبيق: برمجة تطبيق خدمات وعمالة محلية خطوة بخطوة باستخدام flutter و hosteday إنشاء صفحات الخدمات.

في المقال الأول من سلسلة من الفكرة إلى التطبيق أنشأنا خادم API مجانيًا على منصة HosteDay لتطبيق Flutter، وأنشأنا جدولًا أوليًا باسم profiles، ثم ربطنا الخادم بالتطبيق باستخدام حزمة hosteday_flutter.

يمكنك مراجعة المقال الأول من هنا:

https://hosteday.com/blog/6/build-local-services-jobs-app-flutter-hosteday-1

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

  1. صفحة تعرض جميع الخدمات.
  2. صفحة تعرض تفاصيل خدمة واحدة اعتمادًا على المعرّف id.

سنستخدم حزمة GetX لإدارة الحالة والتنقل والـ Dependency Injection، مع تنظيم المشروع بأسلوب قريب من MVC لتسهيل صيانة التطبيق وتطويره لاحقًا.

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


لماذا نغيّر اسم جدول profiles إلى services؟

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

تتيح لك منصة HosteDay تغيير اسم الجدول بسهولة، دون الحاجة إلى إعادة إنشاء الخادم أو بناء API جديد من الصفر.

لتغيير اسم الجدول:

  1. ادخل إلى الخادم الخاص بالتطبيق في منصة HosteDay.
  2. انقر على زر إنشاء API.
  3. اضغط على أيقونة القلم الخاصة بالتعديل.
  4. غيّر اسم الجدول من profiles إلى services.
  5. اضغط على زر تحديث CRUD الآن.

بعد إتمام العملية، سيصبح لديك API خاص بالخدمات بدلًا من الملفات الشخصية.


ما الذي سنبنيه في هذا المقال؟

بنهاية المقال ستكون قد أنشأت بنية منظمة لتطبيق Flutter تشمل:

  • إعداد التطبيق وتهيئته عند التشغيل.
  • تعريف المسارات باستخدام GetX.
  • جلب الخدمات من HosteDay API.
  • دعم الترقيم Pagination.
  • دعم البحث في الخدمات.
  • عرض قائمة الخدمات.
  • عرض تفاصيل خدمة واحدة.
  • التعامل مع حالات التحميل والأخطاء وعدم وجود بيانات.
  • تجهيز واجهات الاتصال عبر واتساب وفيسبوك.

شجرة المشروع

سيكون هيكل المشروع في هذه المرحلة بالشكل التالي:

lib
├── app
│   ├── app_bootstrap.dart
│   ├── app.dart
│   ├── bindings
│   │   └── initial_binding.dart
│   └── routes
│       ├── app_pages.dart
│       ├── app_routes.dart
│       └── middlewares
├── features
│   ├── auth
│   ├── services
│   │   ├── bindings
│   │   │   ├── service_binding.dart
│   │   │   └── services_binding.dart
│   │   ├── controllers
│   │   │   ├── service_controller.dart
│   │   │   └── services_controller.dart
│   │   ├── models
│   │   │   └── services_response.dart
│   │   ├── repositories
│   │   │   └── services_repository.dart
│   │   ├── views
│   │   │   ├── services_view.dart
│   │   │   └── service_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
│   └── user
└── main.dart

المرحلة الأولى: إضافة الحزم المطلوبة

في ملف pubspec.yaml أضف الحزمتين التاليتين:

  • get
  • url_launcher

ثم شغّل الأمر التالي:

flutter pub get

كما أن الأكواد التالية تعتمد على flutter_dotenv لقراءة بيانات HosteDay من ملف البيئة .env، لذلك تأكد من أن إعداداته موجودة ضمن مشروعك كما تم تجهيزه في المقال السابق.


المرحلة الثانية: تجهيز بيانات الخدمات وإنشاء Model

قبل بناء واجهات التطبيق، نحتاج إلى إدخال بيانات تجريبية في جدول services حتى نتمكن من اختبار القائمة وصفحة التفاصيل.

إدخال بيانات تجريبية في HosteDay

اتبع الخطوات التالية:

  1. ادخل إلى مدير قاعدة البيانات.
  2. اختر جدول services.
  3. اختر خيار إدخال البيانات إلى services عبر JSON.
  4. أظهر الحقول المطلوبة.
  5. اضغط على زر نسخ القالب.
  6. افتح أي نموذج ذكاء اصطناعي.
  7. ألصق قالب JSON المنسوخ.
  8. استخدم أحد الأوامر الجاهزة لتوليد بيانات تجريبية مناسبة لتطبيق خدمات محلية.
  9. راجع بنية JSON الناتجة للتأكد من توافقها مع حقول الجدول.
  10. ألصق البيانات في مربع محتوى JSON.
  11. اضغط على زر توليد البيانات.

بعد إضافة البيانات، نحتاج إلى الحصول على الاستجابة الحقيقية من API حتى نتمكن من بناء Model مناسب في Flutter.

الحصول على استجابة API

  1. افتح مختبر API في HosteDay.
  2. من قسم services في الجانب الأيمن، اختر services/api.
  3. أضف الرمز المميز لحماية API الذي أنشأته في المقال الأول.
  4. اضغط على زر إرسال طلب.
  5. انسخ الاستجابة الناتجة.
  6. استخدم أي أداة لتحويل JSON إلى Dart.
  7. راجع الكود الناتج قبل إضافته إلى المشروع.

بعد تجهيز البيانات، أضف الملف التالي في المسار:

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;
}


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());
}


/// يحول الرابط المخزن بصيغة Markdown:
/// [https://example.com](https://example.com)
/// إلى رابط مباشر فقط.
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();
}

المرحلة الثالثة: إعداد التطبيق والمسارات

سنبدأ الآن بإعداد نقطة تشغيل التطبيق، ثم تهيئة HosteDay، وبعد ذلك نضيف مسارات الصفحات.

الملف الرئيسي

المسار:

lib/main.dart
import 'package:at_your_service/app/app.dart';
import 'package:at_your_service/app/app_bootstrap.dart';
import 'package:flutter/material.dart';


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


 await AppBootstrap.init();


 runApp(const App());
}

ملف التطبيق الرئيسي

المسار:

lib/app/app.dart
import 'package:at_your_service/app/bindings/initial_binding.dart';
import 'package:at_your_service/app/routes/app_pages.dart';
import 'package:at_your_service/app/routes/app_routes.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';


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


 @override
 Widget build(BuildContext context) {
   return GetMaterialApp(
     debugShowCheckedModeBanner: false,
     initialRoute: AppRoutes.services,
     getPages: AppPages.pages,
     initialBinding: InitialBinding(),
   );
 }
}

تهيئة HosteDay

المسار:

lib/app/app_bootstrap.dart
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:hosteday_flutter/hosteday_flutter.dart';


class AppBootstrap {
 static Future<void> init() async {
   await dotenv.load();


   await HosteDay.initializeApp(
     options: {
       HosteDayOptionKeys.projectDomain: dotenv.get('HOSTEDAY_PROJECT_DOMAIN'),
       HosteDayOptionKeys.apiToken: dotenv.get('HOSTEDAY_API_TOKEN'),
     },
   );
 }
}

الربط الأولي للتطبيق

المسار:

lib/app/bindings/initial_binding.dart
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:hosteday_flutter/hosteday_flutter.dart';


class AppBootstrap {
 static Future<void> init() async {
   await dotenv.load();


   await HosteDay.initializeApp(
     options: {
       HosteDayOptionKeys.projectDomain: dotenv.get('HOSTEDAY_PROJECT_DOMAIN'),
       HosteDayOptionKeys.apiToken: dotenv.get('HOSTEDAY_API_TOKEN'),
     },
   );
 }
}

تعريف الصفحات والمسارات

المسار:

lib/app/routes/app_pages.dart
import 'package:at_your_service/app/routes/app_routes.dart';
import 'package:at_your_service/features/auth/bindings/auth_binding.dart';
import 'package:at_your_service/features/auth/views/forgot_password_view.dart';
import 'package:at_your_service/features/auth/views/login_view.dart';
import 'package:at_your_service/features/auth/views/register_view.dart';
import 'package:at_your_service/features/auth/views/reset_password_view.dart';
import 'package:at_your_service/features/services/bindings/service_binding.dart';
import 'package:at_your_service/features/services/bindings/services_binding.dart';
import 'package:at_your_service/features/services/views/service_view.dart';
import 'package:at_your_service/features/services/views/services_view.dart';
import 'package:get/get.dart';


class AppPages {
 static final pages = [
   GetPage(
     name: AppRoutes.login,
     page: () => LoginView(),
     binding: AuthBinding(),
   ),
   GetPage(
     name: AppRoutes.register,
     page: () => RegisterView(),
     binding: AuthBinding(),
   ),
   GetPage(
     name: AppRoutes.forgotPassword,
     page: () => ForgotPasswordView(),
     binding: AuthBinding(),
   ),
   GetPage(
     name: AppRoutes.resetPassword,
     page: () => ResetPasswordView(),
     binding: AuthBinding(),
   ),
   GetPage(
     name: AppRoutes.services,
     page: () => const ServicesView(),
     binding: ProfilesBinding(),
   ),
   GetPage(
     name: AppRoutes.service,
     page: () => const ServiceView(),
     binding: ServiceBinding(),
   ),
 ];
}

المسار:

lib/app/routes/app_routes.dart
abstract class AppRoutes {
 static const login = '/login';
 static const register = '/register';
 static const forgotPassword = '/forgot-password';
 static const resetPassword = '/reset-password';


 static const account = '/account';
 static const editAccount = '/account/edit';
 static const changeAvatar = '/account/avatar';
 static const verifyEmail = '/account/verify-email';


 static const services = '/services';
 static const service = '/services/:id';
}

المرحلة الرابعة: بناء ميزة الخدمات

بعد تجهيز Model وإعداد المسارات، نبدأ بإنشاء الملفات الخاصة بالخدمات.

تعتمد هذه الميزة على عدة طبقات:

  • Repository للتواصل مع API.
  • Controller لإدارة الحالة والمنطق.
  • Bindings لحقن الاعتمادات.
  • Views لعرض الواجهات.
  • Widgets لتقسيم الواجهة إلى مكونات قابلة لإعادة الاستخدام.

Service Controller

المسار:

lib/features/services/controllers/service_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 ServiceController extends GetxController {
 ServiceController({required ServicesRepository repository})
   : _repository = repository;


 final ServicesRepository _repository;


 final service = Rxn<Service>();


 final isLoading = false.obs;
 final errorMessage = ''.obs;


 bool get hasProfile => service.value != null;


 Future<void> loadProfile(String serviceId) async {
   final normalizedId = serviceId.trim();


   if (normalizedId.isEmpty) {
     errorMessage.value = 'معرّف الملف الشخصي غير صالح.';
     return;
   }


   if (isLoading.value) {
     return;
   }


   isLoading.value = true;
   errorMessage.value = '';
   service.value = null;


   try {
     service.value = await _repository.service(normalizedId);
     print("DDD: ${service.value!.status}");
   } catch (error) {
     errorMessage.value = _readableError(error);
   } finally {
     isLoading.value = false;
   }
 }


 Future<void> refreshProfile(String profileId) {
   return loadProfile(profileId);
 }


 void clearProfile() {
   service.value = null;
   errorMessage.value = '';
 }


 String _readableError(Object error) {
   return error
       .toString()
       .replaceFirst('Exception: ', '')
       .replaceFirst('FormatException: ', '');
 }


 @override
 void onInit() {
   super.onInit();


   final profileId = Get.parameters['id'];


   if (profileId != null && profileId.trim().isNotEmpty) {
     loadProfile(profileId);
   }
 }


 @override
 void onClose() {
   clearProfile();
   super.onClose();
 }
}

Services Controller

المسار:

lib/features/services/controllers/services_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 ServicesController extends GetxController {
 ServicesController({required ServicesRepository repository})
   : _repository = repository;


 final ServicesRepository _repository;


 final services = <Service>[].obs;
 final selectedService = Rxn<Service>();


 final isLoading = false.obs;
 final isLoadingMore = false.obs;
 final isDetailsLoading = false.obs;


 final errorMessage = ''.obs;
 final detailsErrorMessage = ''.obs;
 final searchQuery = ''.obs;


 int _currentPage = 1;
 bool _hasMore = true;


 static const int _perPage = 15;


 bool get hasMore => _hasMore;


 Future<void> loadServices({String? search}) async {
   if (isLoading.value) {
     return;
   }


   isLoading.value = true;
   errorMessage.value = '';


   final normalizedSearch = search?.trim() ?? searchQuery.value.trim();


   searchQuery.value = normalizedSearch;
   _currentPage = 1;
   _hasMore = true;


   try {
     final pagination = await _repository.services(
       page: _currentPage,
       perPage: _perPage,
       search: normalizedSearch.isEmpty ? null : normalizedSearch,
     );


     services.assignAll(pagination.services);


     _hasMore = pagination.hasNextPage;
   } catch (error) {
     errorMessage.value = _readableError(error);
     services.clear();
   } finally {
     isLoading.value = false;
   }
 }


 Future<void> loadMoreServices() async {
   if (isLoading.value || isLoadingMore.value || !_hasMore) {
     return;
   }


   isLoadingMore.value = true;
   errorMessage.value = '';


   try {
     final nextPage = _currentPage + 1;


     final pagination = await _repository.services(
       page: nextPage,
       perPage: _perPage,
       search: searchQuery.value.isEmpty ? null : searchQuery.value,
     );


     services.addAll(pagination.services);


     _currentPage = pagination.currentPage;
     _hasMore = pagination.hasNextPage;
   } catch (error) {
     errorMessage.value = _readableError(error);
   } finally {
     isLoadingMore.value = false;
   }
 }


 Future<void> searchServices(String value) {
   return loadServices(search: value);
 }


 Future<void> loadServiceDetails(String serviceId) async {
   final normalizedId = serviceId.trim();


   if (normalizedId.isEmpty) {
     detailsErrorMessage.value = 'معرّف الملف الشخصي غير صالح.';
     return;
   }


   isDetailsLoading.value = true;
   detailsErrorMessage.value = '';
   selectedService.value = null;


   try {
     selectedService.value = await _repository.service(normalizedId);
   } catch (error) {
     detailsErrorMessage.value = _readableError(error);
   } finally {
     isDetailsLoading.value = false;
   }
 }


 void updateServiceInList(Service updatedService) {
   final index = services.indexWhere(
     (service) => service.id == updatedService.id,
   );


   if (index != -1) {
     services[index] = updatedService;
   }


   if (selectedService.value?.id == updatedService.id) {
     selectedService.value = updatedService;
   }
 }


 void removeServiceFromList(String serviceId) {
   final normalizedId = serviceId.trim();


   services.removeWhere((service) => service.id.toString() == normalizedId);


   if (selectedService.value?.id.toString() == normalizedId) {
     selectedService.value = null;
   }
 }


 void clearSelectedService() {
   selectedService.value = null;
   detailsErrorMessage.value = '';
 }


 String _readableError(Object error) {
   return error.toString().replaceFirst('Exception: ', '');
 }


 @override
 void onInit() {
   super.onInit();
   loadServices();
 }
}

Bindings الخاصة بالخدمات

المسار:

lib/features/services/bindings/service_binding.dart
import 'package:at_your_service/features/services/controllers/service_controller.dart';
import 'package:at_your_service/features/services/repositories/services_repository.dart';
import 'package:get/get.dart';


class ServiceBinding extends Bindings {
 @override
 void dependencies() {
   Get.lazyPut<ServiceController>(
         () => ServiceController(
       repository: Get.find<ServicesRepository>(),
     ),
   );
 }
}

المسار:

lib/features/services/bindings/services_binding.dart
import 'package:at_your_service/features/services/controllers/services_controller.dart';
import 'package:at_your_service/features/services/repositories/services_repository.dart';
import 'package:get/get.dart';


class ProfilesBinding extends Bindings {
 @override
 void dependencies() {
   Get.lazyPut<ServicesController>(
         () => ServicesController(
       repository: Get.find<ServicesRepository>(),
     ),
     fenix: true,
   );
 }
}

Services Repository

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

المسار:

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;
   }
 }
}

صفحة تفاصيل الخدمة

تعرض هذه الصفحة معلومات خدمة واحدة اعتمادًا على المعرّف الموجود في المسار:

/services/:id

المسار:

lib/features/services/views/service_view.dart
import 'package:at_your_service/features/services/controllers/service_controller.dart';
import 'package:at_your_service/features/services/models/services_response.dart';
import 'package:at_your_service/features/services/widgets/contact_section.dart';
import 'package:at_your_service/features/services/widgets/service_description.dart';
import 'package:at_your_service/features/services/widgets/service_empty_state.dart';
import 'package:at_your_service/features/services/widgets/service_information_section.dart';
import 'package:at_your_service/features/services/widgets/service_status_badge.dart';
import 'package:at_your_service/features/services/widgets/status_data.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';


class ServiceView extends GetView<ServiceController> {
 const ServiceView({super.key});


 @override
 Widget build(BuildContext context) {
   return Scaffold(
     body: Obx(() {
       final isLoading = controller.isLoading.value;
       final errorMessage = controller.errorMessage.value;
       final service = controller.service.value;


       if (isLoading && service == null) {
         return const Center(child: CircularProgressIndicator());
       }


       if (errorMessage.isNotEmpty && service == null) {
         return ServiceErrorState(
           message: errorMessage,
           onRetry: () {
             final profileId = Get.parameters['id'] ?? '';
             controller.loadProfile(profileId);
           },
         );
       }


       if (service == null) {
         return const ServiceEmptyState();
       }


       return CustomScrollView(
         slivers: [
           _ServiceHeader(service: service),
           SliverPadding(
             padding: const EdgeInsets.fromLTRB(16, 18, 16, 32),
             sliver: SliverList(
               delegate: SliverChildListDelegate([
                 ServiceDescription(description: service.description),
                 const SizedBox(height: 16),
                 ContactSection(service: service),
                 const SizedBox(height: 16),
                 ServiceInformationSection(service: service),
               ]),
             ),
           ),
         ],
       );
     }),
   );
 }
}


class _ServiceHeader extends StatelessWidget {
 const _ServiceHeader({required this.service});


 final Service service;


 @override
 Widget build(BuildContext context) {
   final theme = Theme.of(context);
   final colorScheme = theme.colorScheme;


   return SliverAppBar(
     pinned: true,
     expandedHeight: 315,
     stretch: true,
     backgroundColor: colorScheme.surface,
     surfaceTintColor: Colors.transparent,
     leading: IconButton(
       onPressed: Get.back,
       icon: const Icon(Icons.arrow_back_ios_new_rounded),
     ),
     flexibleSpace: FlexibleSpaceBar(
       collapseMode: CollapseMode.pin,
       background: SafeArea(
         child: Padding(
           padding: const EdgeInsets.fromLTRB(20, 58, 20, 20),
           child: Column(
             children: [
               _LargeServiceAvatar(
                 title: service.title,
                 avatarUrl: service.avatar,
               ),
               const SizedBox(height: 16),
               Text(
                 service.title.isEmpty ? 'خدمة بدون عنوان' : service.title,
                 textAlign: TextAlign.center,
                 maxLines: 2,
                 overflow: TextOverflow.ellipsis,
                 style: theme.textTheme.headlineSmall?.copyWith(
                   fontWeight: FontWeight.w800,
                 ),
               ),
               const SizedBox(height: 10),
               ServiceStatusBadge(status: service.status),
             ],
           ),
         ),
       ),
     ),
   );
 }
}


class _LargeServiceAvatar extends StatelessWidget {
 const _LargeServiceAvatar({required this.title, required this.avatarUrl});


 final String title;
 final String? avatarUrl;


 @override
 Widget build(BuildContext context) {
   final imageUrl = avatarUrl?.trim();


   if (imageUrl == null || imageUrl.isEmpty) {
     return CircleAvatar(
       radius: 58,
       child: Text(
         _firstLetter(title),
         style: Theme.of(
           context,
         ).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w800),
       ),
     );
   }


   return Container(
     width: 116,
     height: 116,
     decoration: BoxDecoration(
       shape: BoxShape.circle,
       border: Border.all(
         color: Theme.of(context).colorScheme.outlineVariant,
         width: 3,
       ),
     ),
     child: ClipOval(
       child: Image.network(
         imageUrl,
         fit: BoxFit.cover,
         errorBuilder: (_, __, ___) {
           return CircleAvatar(
             child: Text(
               _firstLetter(title),
               style: Theme.of(context).textTheme.headlineMedium?.copyWith(
                 fontWeight: FontWeight.w800,
               ),
             ),
           );
         },
       ),
     ),
   );
 }


 String _firstLetter(String value) {
   final normalized = value.trim();


   if (normalized.isEmpty) {
     return '?';
   }


   return normalized.characters.first.toUpperCase();
 }
}

صفحة قائمة الخدمات

تعرض هذه الصفحة جميع الخدمات القادمة من HosteDay API، مع حالات التحميل والخطأ وعدم وجود بيانات، وإمكانية فتح صفحة تفاصيل كل خدمة.

المسار:

lib/features/services/views/services_view.dart
import 'package:at_your_service/app/routes/app_routes.dart';
import 'package:at_your_service/features/services/controllers/services_controller.dart';
import 'package:at_your_service/features/services/widgets/error_state.dart';
import 'package:at_your_service/features/services/widgets/service_card.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';


class ServicesView extends GetView<ServicesController> {
 const ServicesView({super.key});


 @override
 Widget build(BuildContext context) {
   return Scaffold(
     appBar: AppBar(title: const Text('الملفات الشخصية')),
     body: Obx(() {
       final isLoading = controller.isLoading.value;
       final errorMessage = controller.errorMessage.value;
       final profiles = controller.services;


       if (isLoading && profiles.isEmpty) {
         return const Center(child: CircularProgressIndicator());
       }


       if (errorMessage.isNotEmpty && profiles.isEmpty) {
         return ErrorState(
           message: errorMessage,
           onRetry: controller.loadServices,
         );
       }


       if (profiles.isEmpty) {
         return RefreshIndicator(
           onRefresh: controller.loadServices,
           child: ListView(
             physics: const AlwaysScrollableScrollPhysics(),
             children: const [
               SizedBox(height: 160),
               Icon(Icons.people_outline, size: 64, color: Colors.grey),
               SizedBox(height: 16),
               Center(
                 child: Text(
                   'لا توجد ملفات شخصية حاليًا.',
                   style: TextStyle(fontSize: 16),
                 ),
               ),
             ],
           ),
         );
       }


       return RefreshIndicator(
         onRefresh: controller.loadServices,
         child: ListView.separated(
           padding: const EdgeInsets.all(16),
           physics: const AlwaysScrollableScrollPhysics(),
           itemCount: profiles.length,
           separatorBuilder: (_, __) => const SizedBox(height: 10),
           itemBuilder: (context, index) {
             final profile = profiles[index];


             return ServiceCard(
               service: profile,
               onTap: () {
                 Get.toNamed(
                   AppRoutes.service.replaceFirst(
                     ':id',
                     profile.id.toString(),
                   ),
                 );
               },
             );
           },
         ),
       );
     }),
   );
 }
}

Widgets الخاصة بالخدمات

تتكون واجهة الخدمات من مجموعة Widgets صغيرة لتجنب وضع كل عناصر الواجهة داخل ملف واحد كبير.

أضف الملفات التالية داخل المسار:

lib/features/services/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

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


تشغيل التطبيق

بعد إضافة الملفات وإكمال Widgets المساعدة، شغّل التطبيق:

flutter run

ستحصل على تطبيق يحتوي على:

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

كود المشروع الكامل

يمكنك الحصول على مشروع هذه المقالة كاملًا من GitHub:

https://github.com/mustafa3max/at-your-service/tree/blog-2


ماذا بعد؟

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

سننشئ الصفحات التالية:

  1. تسجيل الدخول.
  2. إنشاء حساب جديد.
  3. استعادة كلمة المرور.
  4. إعادة تعيين كلمة المرور.

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