Flutter 11 يوليو 2026 186 مشاهدة

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

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

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

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

في هذا الجزء من سلسلة «من الفكرة إلى التطبيق» سنضيف نظام المصادقة إلى تطبيق الخدمات والعمالة المحلية الذي نبنيه باستخدام Flutter وGetX وHosteDay.

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

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

https://hosteday.com/blog/7/build-local-services-jobs-app-flutter-hosteday-2

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

بنهاية هذا الجزء ستكون لدينا بنية متكاملة لميزة المصادقة، تشمل:

  • صفحة تسجيل الدخول.
  • صفحة إنشاء حساب جديد.
  • صفحة إرسال رابط استعادة كلمة المرور.
  • مستودعًا مسؤولًا عن التواصل مع نظام المصادقة.
  • نماذج واضحة لبيانات الطلبات.
  • ربط Controllers وRepositories باستخدام GetX Bindings.
  • مسارات تنقل خاصة بصفحات المصادقة.
  • شريط تطبيق وقائمة جانبية تتغير بحسب حالة تسجيل المستخدم.

واجهات المصادقة المتوفرة في HosteDay

عند إنشاء خادم جديد على HosteDay، تكون واجهات المصادقة الأساسية متوفرة افتراضيًا، ومنها:

https://a-y-service.hosteday.com/api/auth/login

https://a-y-service.hosteday.com/api/auth/register

https://a-y-service.hosteday.com/api/auth/forgot-password

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

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

رابط الحزمة:

https://pub.dev/packages/hosteday_flutter

تحديث حزمة hosteday_flutter قبل البدء

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

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

lib/app/app_bootstrap.dart

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

HosteDayOptionKeys.apiToken => HosteDayOptionKeys.projectApiKey

بعد options أضف إعداد التخزين التالي. وظيفته حفظ جلسة المستخدم ورمز المصادقة محليًا باستخدام Shared Preferences، حتى لا يضطر المستخدم إلى تسجيل الدخول من جديد في كل مرة يُعاد فيها تشغيل التطبيق.

authStorage: HosteDaySharedPreferencesAuthStorage()

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

تنظيم ميزة المصادقة

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

للمبتدئ، يمكن فهم المجلدات بهذه الصورة:

  • bindings: تسجيل الاعتماديات وربط Controllers بالـRepositories.
  • controllers: إدارة حالة الصفحات وتنفيذ أوامر المستخدم.
  • models: تمثيل البيانات المرسلة إلى عمليات المصادقة.
  • repositories: عزل التعامل مع HosteDay عن الواجهة.
  • views: صفحات المصادقة.
  • widgets: عناصر واجهة قابلة لإعادة الاستخدام داخل صفحات المصادقة.

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

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

lib/features/auth/
├── bindings
│   └── auth_binding.dart
├── controllers
│   ├── forgot_password_controller.dart
│   ├── login_controller.dart
│   └── register_controller.dart
├── models
│   └── auth_requests.dart
├── repositories
│   └── auth_repository.dart
├── views
│   ├── forgot_password_view.dart
│   ├── login_view.dart
│   └── register_view.dart
└── widgets
    ├── input.dart
    └── message_box.dart

تسجيل AuthRepository في InitialBinding

ملف InitialBinding هو المكان الذي نسجل فيه الاعتماديات العامة التي يحتاج إليها التطبيق. سنضيف إليه AuthRepository إلى جانب ServicesRepository.

استخدام Get.lazyPut يعني أن GetX لن ينشئ الكائن إلا عند الحاجة إليه. أما fenix: true فتعني أن GetX يستطيع إعادة إنشاء الكائن تلقائيًا إذا حُذف من الذاكرة ثم طُلب مرة أخرى.

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

افتح الملف:

lib/app/bindings/initial_binding.dart

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

import 'package:at_your_service/features/auth/repositories/auth_repository.dart';
import 'package:at_your_service/features/services/repositories/services_repository.dart';
import 'package:get/get.dart';


class InitialBinding extends Bindings {
 @override
 void dependencies() {
   Get.lazyPut<ServicesRepository>(
         () => ServicesRepository(),
     fenix: true,
   );


   Get.lazyPut<AuthRepository>(
         () => AuthRepository(),
     fenix: true,
   );
 }
}

إضافة صفحات المصادقة إلى AppPages

يحتوي ملف AppPages على تعريف الصفحات التي يمكن الوصول إليها من خلال نظام التنقل في GetX. كل GetPage يربط بين ثلاثة عناصر رئيسية:

  1. اسم المسار.
  2. الصفحة التي ستظهر للمستخدم.
  3. الـBinding الذي يجهز Controller والاعتماديات اللازمة للصفحة.

يمنع هذا الأسلوب إنشاء Controllers يدويًا داخل الواجهات، ويجعل تحميل كل Controller مرتبطًا بالصفحة التي تحتاج إليه.

افتح الملف:

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/login_view.dart';
import 'package:at_your_service/features/auth/views/register_view.dart';
import 'package:at_your_service/features/auth/views/forgot_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: LoginBinding(),
   ),
   GetPage(
     name: AppRoutes.register,
     page: () => RegisterView(),
     binding: RegisterBinding(),
   ),
   GetPage(
     name: AppRoutes.forgotPassword,
     page: () => ForgotPasswordView(),
     binding: ForgotPasswordBinding(),
   ),
   ///
   GetPage(
     name: AppRoutes.services,
     page: () => const ServicesView(),
     binding: ProfilesBinding(),
   ),
   GetPage(
     name: AppRoutes.service,
     page: () => const ServiceView(),
     binding: ServiceBinding(),
   ),
 ];
}

تعريف أسماء المسارات في AppRoutes

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

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

افتح الملف:

lib/app/routes/app_routes.dart

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

abstract class AppRoutes {
 static const login = '/login';
 static const register = '/register';
 static const forgotPassword = '/forgot-password';
 static const resendEmailVerification = '/send-password-reset-email';


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


 static const privacyPolicy = '/privacy-policy';
 static const termsOfService = '/terms-of-service';
}

إنشاء طبقة مبسطة للتنقل باستخدام GoPage

يعمل الصنف GoPage كواجهة مختصرة فوق Get.toNamed. بدلًا من تكرار استدعاء GetX واسم المسار في جميع الصفحات، نستدعي دالة واضحة مثل GoPage.login() أو GoPage.services().

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

افتح الملف:

lib/app/routes/go_page.dart

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

import 'package:at_your_service/app/routes/app_routes.dart';
import 'package:get/get.dart';


class GoPage {
 static Future<dynamic>? login() => Get.toNamed(AppRoutes.login);
 static Future<dynamic>? register() => Get.toNamed(AppRoutes.register);
 static Future<dynamic>? forgotPassword() => Get.toNamed(AppRoutes.forgotPassword);
///
 static Future<dynamic>? account() => Get.toNamed(AppRoutes.account);
 static Future<dynamic>? editAccount() => Get.toNamed(AppRoutes.editAccount);
 static Future<dynamic>? changeAvatar() => Get.toNamed(AppRoutes.changeAvatar);
 static Future<dynamic>? verifyEmail() => Get.toNamed(AppRoutes.verifyEmail);
///
 static Future<dynamic>? services() => Get.toNamed(AppRoutes.services);
 static Future<dynamic>? service() => Get.toNamed(AppRoutes.service);
 ///
}

إنشاء عناصر الواجهة العامة

سننشئ مجلدًا باسم widgets داخل المسار التالي:

lib/app/widgets

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

فصل هذه العناصر يمنع تكرار الكود، ويحافظ على شكل موحد في جميع أجزاء التطبيق.

إنشاء شريط التطبيق AppBar

الدالة appBar تعيد عنصرًا من النوع PreferredSizeWidget، وهو النوع الذي تتوقعه الخاصية appBar داخل Scaffold.

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

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

أنشئ الملف:

lib/app/widgets/app_bar.dart

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

import 'package:at_your_service/app/routes/go_page.dart';
import 'package:flutter/material.dart';


PreferredSizeWidget appBar({
 String? title,
 bool centerTitle = true,
 List<Widget> actions = const [],
}) {
 return AppBar(
   title: title == null ? null : Text(title),
   centerTitle: centerTitle,
   actions: [
     IconButton(onPressed: ()=>GoPage.services(), icon: Icon(Icons.house)),
   ],
 );
}

إنشاء القائمة الجانبية AppDrawer

القائمة الجانبية في هذا التطبيق ليست قائمة ثابتة؛ بل تتغير بحسب حالة المستخدم.

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

تعرض القائمة أيضًا روابط عامة مثل الخدمات، والإعدادات، والمساعدة، ومعلومات التطبيق.

يحفظ الكود قيمة HosteDay.auth.currentUser في متغير واحد، ثم يتحقق من وجود المستخدم قبل عرض البيانات. كما يفصل أجزاء القائمة إلى Widgets داخلية صغيرة مثل _DrawerHeader و_DrawerTile و_DrawerDivider، ما يحسن قابلية القراءة ويقلل تكرار التنسيق.

تتولى الدالة _confirmLogout عرض نافذة تأكيد قبل تسجيل الخروج، بينما تحول _readableError رسالة الخطأ إلى صيغة أبسط للمستخدم. أما _validAvatarUrl فتتحقق من صلاحية رابط الصورة قبل تمريره إلى NetworkImage.

أنشئ الملف:

lib/app/widgets/drawer.dart

ثم استخدم الكود التالي كما هو:

import 'package:at_your_service/app/routes/app_routes.dart';
import 'package:at_your_service/app/routes/go_page.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:hosteday_flutter/hosteday_flutter.dart';


class AppDrawer extends StatelessWidget {
 const AppDrawer({
   super.key,
   this.onLogout,
 });


 final Future<void> Function()? onLogout;


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


   // حفظ المستخدم في متغير واحد لتجنب استخدام currentUser!
   // ولضمان أن جميع أجزاء الواجهة تستخدم القيمة نفسها.
   final user = HosteDay.auth.currentUser;
   final isAuthenticated = user != null;


   return Drawer(
     child: SafeArea(
       child: Column(
         children: [
           _DrawerHeader(
             isAuthenticated: isAuthenticated,
             userName: user?.name,
             userEmail: user?.email,
             avatarUrl: user?.avatarUrl,
             isEmailVerified: user?.emailVerified ?? false,
           ),


           Expanded(
             child: ListView(
               padding: const EdgeInsets.symmetric(
                 horizontal: 12,
                 vertical: 8,
               ),
               children: [
                 const _DrawerSectionTitle(
                   title: 'الرئيسية',
                 ),


                 _DrawerTile(
                   icon: Icons.home_outlined,
                   selectedIcon: Icons.home,
                   title: 'الرئيسية',
                   onTap: () {
                     _closeDrawer();
                     Get.offAllNamed(AppRoutes.services);
                   },
                 ),


                 _DrawerTile(
                   icon: Icons.design_services_outlined,
                   selectedIcon: Icons.design_services,
                   title: 'الخدمات',
                   onTap: () {
                     _closeDrawer();
                     GoPage.services();
                   },
                 ),


                 if (isAuthenticated) ...[
                   const _DrawerDivider(),


                   const _DrawerSectionTitle(
                     title: 'حسابي',
                   ),


                   _DrawerTile(
                     icon: Icons.account_circle_outlined,
                     selectedIcon: Icons.account_circle,
                     title: 'حسابي',
                     onTap: () {
                       _closeDrawer();
                       GoPage.account();
                     },
                   ),


                   _DrawerTile(
                     icon: Icons.edit_outlined,
                     selectedIcon: Icons.edit,
                     title: 'تعديل الحساب',
                     onTap: () {
                       _closeDrawer();
                       GoPage.editAccount();
                     },
                   ),


                   _DrawerTile(
                     icon: Icons.add_a_photo_outlined,
                     selectedIcon: Icons.add_a_photo,
                     title: 'تغيير الصورة الشخصية',
                     onTap: () {
                       _closeDrawer();
                       GoPage.changeAvatar();
                     },
                   ),


                   if (!(user.emailVerified))
                     _DrawerTile(
                       icon: Icons.mark_email_unread_outlined,
                       selectedIcon: Icons.mark_email_read,
                       title: 'تأكيد البريد الإلكتروني',
                       trailing: Container(
                         padding: const EdgeInsets.symmetric(
                           horizontal: 8,
                           vertical: 3,
                         ),
                         decoration: BoxDecoration(
                           color: colorScheme.errorContainer,
                           borderRadius: BorderRadius.circular(20),
                         ),
                         child: Text(
                           'مطلوب',
                           style: TextStyle(
                             color: colorScheme.onErrorContainer,
                             fontSize: 11,
                             fontWeight: FontWeight.w600,
                           ),
                         ),
                       ),
                       onTap: () {
                         _closeDrawer();
                         GoPage.verifyEmail();
                       },
                     ),
                 ] else ...[
                   const _DrawerDivider(),


                   const _DrawerSectionTitle(
                     title: 'المصادقة',
                   ),


                   _DrawerTile(
                     icon: Icons.login_outlined,
                     selectedIcon: Icons.login,
                     title: 'تسجيل الدخول',
                     onTap: () {
                       _closeDrawer();
                       GoPage.login();
                     },
                   ),


                   _DrawerTile(
                     icon: Icons.person_add_alt_1_outlined,
                     selectedIcon: Icons.person_add_alt_1,
                     title: 'إنشاء حساب',
                     onTap: () {
                       _closeDrawer();
                       GoPage.register();
                     },
                   ),


                   _DrawerTile(
                     icon: Icons.lock_reset_outlined,
                     selectedIcon: Icons.lock_reset,
                     title: 'استعادة كلمة المرور',
                     onTap: () {
                       _closeDrawer();
                       GoPage.forgotPassword();
                     },
                   ),
                 ],


                 const _DrawerDivider(),


                 const _DrawerSectionTitle(
                   title: 'التطبيق',
                 ),


                 _DrawerTile(
                   icon: Icons.settings_outlined,
                   selectedIcon: Icons.settings,
                   title: 'الإعدادات',
                   onTap: () {
                     _closeDrawer();


                     // أضف المسار عند إنشاء صفحة الإعدادات:
                     // Get.toNamed(AppRoutes.settings);
                   },
                 ),


                 _DrawerTile(
                   icon: Icons.help_outline,
                   selectedIcon: Icons.help,
                   title: 'المساعدة والدعم',
                   onTap: () {
                     _closeDrawer();


                     // أضف المسار عند إنشاء صفحة الدعم:
                     // Get.toNamed(AppRoutes.support);
                   },
                 ),


                 _DrawerTile(
                   icon: Icons.info_outline,
                   selectedIcon: Icons.info,
                   title: 'عن التطبيق',
                   onTap: () {
                     _closeDrawer();


                     showAboutDialog(
                       context: context,
                       applicationName: 'في خدمتك',
                       applicationVersion: '1.0.0',
                       applicationIcon: Icon(
                         Icons.design_services,
                         size: 42,
                         color: colorScheme.primary,
                       ),
                       children: const [
                         Text(
                           'تطبيق لعرض الخدمات المحلية والتواصل مع مقدمي الخدمات.',
                         ),
                       ],
                     );
                   },
                 ),
               ],
             ),
           ),


           if (isAuthenticated) ...[
             const Divider(height: 1),
             Padding(
               padding: const EdgeInsets.all(12),
               child: _DrawerTile(
                 icon: Icons.logout_outlined,
                 selectedIcon: Icons.logout,
                 title: 'تسجيل الخروج',
                 foregroundColor: colorScheme.error,
                 onTap: () {
                   _confirmLogout(context);
                 },
               ),
             ),
           ],


           Padding(
             padding: const EdgeInsets.only(
               bottom: 12,
               left: 16,
               right: 16,
             ),
             child: Text(
               'الإصدار 1.0.0',
               style: Theme.of(context).textTheme.bodySmall?.copyWith(
                 color: colorScheme.onSurfaceVariant,
               ),
             ),
           ),
         ],
       ),
     ),
   );
 }


 void _closeDrawer() {
   if (Get.isDialogOpen == true) {
     return;
   }


   if (Get.isOverlaysOpen) {
     Get.back();
     return;
   }


   if (Get.key.currentState?.canPop() == true) {
     Get.back();
   }
 }


 Future<void> _confirmLogout(BuildContext context) async {
   final shouldLogout = await showDialog<bool>(
     context: context,
     builder: (dialogContext) {
       return AlertDialog(
         icon: const Icon(
           Icons.logout_outlined,
         ),
         title: const Text(
           'تسجيل الخروج',
         ),
         content: const Text(
           'هل أنت متأكد من رغبتك في تسجيل الخروج؟',
         ),
         actions: [
           TextButton(
             onPressed: () {
               Navigator.of(dialogContext).pop(false);
             },
             child: const Text(
               'إلغاء',
             ),
           ),
           FilledButton(
             onPressed: () {
               Navigator.of(dialogContext).pop(true);
             },
             child: const Text(
               'تسجيل الخروج',
             ),
           ),
         ],
       );
     },
   );


   if (shouldLogout != true) {
     return;
   }


   _closeDrawer();


   try {
     if (onLogout != null) {
       await onLogout!.call();
     } else {
       await HosteDay.auth.signOut();
     }


     Get.offAllNamed(AppRoutes.login);
   } catch (error) {
     Get.snackbar(
       'تعذر تسجيل الخروج',
       _readableError(error),
       snackPosition: SnackPosition.BOTTOM,
     );
   }
 }


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


class _DrawerHeader extends StatelessWidget {
 const _DrawerHeader({
   required this.isAuthenticated,
   required this.userName,
   required this.userEmail,
   required this.avatarUrl,
   required this.isEmailVerified,
 });


 final bool isAuthenticated;
 final String? userName;
 final String? userEmail;
 final String? avatarUrl;
 final bool isEmailVerified;


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


   return Container(
     width: double.infinity,
     padding: const EdgeInsets.all(20),
     decoration: BoxDecoration(
       color: colorScheme.primaryContainer,
     ),
     child: isAuthenticated
         ? _buildAuthenticatedHeader(context)
         : _buildGuestHeader(context),
   );
 }


 Widget _buildAuthenticatedHeader(BuildContext context) {
   final colorScheme = Theme.of(context).colorScheme;


   final cleanName = userName?.trim();
   final cleanEmail = userEmail?.trim();
   final validAvatarUrl = _validAvatarUrl;


   return Column(
     crossAxisAlignment: CrossAxisAlignment.start,
     children: [
       CircleAvatar(
         radius: 34,
         backgroundColor: colorScheme.primary,
         foregroundImage: validAvatarUrl != null
             ? NetworkImage(validAvatarUrl)
             : null,
         onForegroundImageError: validAvatarUrl != null
             ? (exception, stackTrace) {
           debugPrint(
             'Unable to load user avatar: $exception',
           );
         }
             : null,
         child: validAvatarUrl == null
             ? Text(
           _firstLetter(cleanName),
           style: TextStyle(
             color: colorScheme.onPrimary,
             fontSize: 26,
             fontWeight: FontWeight.bold,
           ),
         )
             : null,
       ),
       const SizedBox(height: 14),
       Text(
         cleanName?.isNotEmpty == true
             ? cleanName!
             : 'المستخدم',
         maxLines: 1,
         overflow: TextOverflow.ellipsis,
         style: Theme.of(context).textTheme.titleMedium?.copyWith(
           color: colorScheme.onPrimaryContainer,
           fontWeight: FontWeight.bold,
         ),
       ),
       const SizedBox(height: 4),
       Row(
         children: [
           Expanded(
             child: Text(
               cleanEmail?.isNotEmpty == true
                   ? cleanEmail!
                   : 'لا يوجد بريد إلكتروني',
               maxLines: 1,
               overflow: TextOverflow.ellipsis,
               style: Theme.of(context).textTheme.bodySmall?.copyWith(
                 color: colorScheme.onPrimaryContainer.withValues(
                   alpha: 0.75,
                 ),
               ),
             ),
           ),
           const SizedBox(width: 8),
           Tooltip(
             message: isEmailVerified
                 ? 'البريد الإلكتروني مؤكد'
                 : 'البريد الإلكتروني غير مؤكد',
             child: Icon(
               isEmailVerified
                   ? Icons.verified_outlined
                   : Icons.warning_amber_outlined,
               size: 18,
               color: isEmailVerified
                   ? Colors.green
                   : colorScheme.error,
             ),
           ),
         ],
       ),
     ],
   );
 }


 Widget _buildGuestHeader(BuildContext context) {
   final colorScheme = Theme.of(context).colorScheme;


   return Column(
     crossAxisAlignment: CrossAxisAlignment.start,
     children: [
       CircleAvatar(
         radius: 34,
         backgroundColor: colorScheme.primary,
         child: Icon(
           Icons.person_outline,
           size: 36,
           color: colorScheme.onPrimary,
         ),
       ),
       const SizedBox(height: 14),
       Text(
         'مرحبًا بك',
         style: Theme.of(context).textTheme.titleMedium?.copyWith(
           color: colorScheme.onPrimaryContainer,
           fontWeight: FontWeight.bold,
         ),
       ),
       const SizedBox(height: 4),
       Text(
         'سجّل الدخول للوصول إلى حسابك وخدماتك.',
         style: Theme.of(context).textTheme.bodySmall?.copyWith(
           color: colorScheme.onPrimaryContainer.withValues(
             alpha: 0.75,
           ),
         ),
       ),
     ],
   );
 }


 String? get _validAvatarUrl {
   final value = avatarUrl?.trim();


   if (value == null || value.isEmpty) {
     return null;
   }


   final uri = Uri.tryParse(value);


   if (uri == null) {
     return null;
   }


   if (!uri.hasScheme) {
     return null;
   }


   if (uri.scheme != 'http' && uri.scheme != 'https') {
     return null;
   }


   return value;
 }


 String _firstLetter(String? name) {
   final value = name?.trim();


   if (value == null || value.isEmpty) {
     return 'م';
   }


   return String.fromCharCode(value.runes.first).toUpperCase();
 }
}


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


 final String title;


 @override
 Widget build(BuildContext context) {
   return Padding(
     padding: const EdgeInsetsDirectional.only(
       start: 16,
       top: 10,
       bottom: 6,
     ),
     child: Text(
       title,
       style: Theme.of(context).textTheme.labelMedium?.copyWith(
         color: Theme.of(context).colorScheme.primary,
         fontWeight: FontWeight.bold,
       ),
     ),
   );
 }
}


class _DrawerTile extends StatelessWidget {
 const _DrawerTile({
   required this.icon,
   required this.selectedIcon,
   required this.title,
   required this.onTap,
   this.trailing,
   this.foregroundColor,
   this.selected = false,
 });


 final IconData icon;
 final IconData selectedIcon;
 final String title;
 final VoidCallback onTap;
 final Widget? trailing;
 final Color? foregroundColor;
 final bool selected;


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


   final effectiveColor = foregroundColor ??
       (selected ? colorScheme.primary : colorScheme.onSurface);


   return ListTile(
     selected: selected,
     selectedColor: foregroundColor ?? colorScheme.primary,
     selectedTileColor: colorScheme.primaryContainer.withValues(
       alpha: 0.45,
     ),
     leading: Icon(
       selected ? selectedIcon : icon,
       color: effectiveColor,
     ),
     title: Text(
       title,
       style: TextStyle(
         color: effectiveColor,
         fontWeight: selected
             ? FontWeight.w700
             : FontWeight.w500,
       ),
     ),
     trailing: trailing,
     shape: RoundedRectangleBorder(
       borderRadius: BorderRadius.circular(12),
     ),
     contentPadding: const EdgeInsets.symmetric(
       horizontal: 14,
       vertical: 2,
     ),
     onTap: onTap,
   );
 }
}


class _DrawerDivider extends StatelessWidget {
 const _DrawerDivider();


 @override
 Widget build(BuildContext context) {
   return const Padding(
     padding: EdgeInsets.symmetric(
       vertical: 8,
     ),
     child: Divider(
       height: 1,
     ),
   );
 }
}

ربط Controllers بمستودع المصادقة

ملف auth_binding.dart مسؤول عن إنشاء Controller المناسب لكل صفحة، وتمرير AuthRepository إليه.

لدينا ثلاثة Bindings مستقلة:

  • RegisterBinding لصفحة إنشاء الحساب.
  • LoginBinding لصفحة تسجيل الدخول.
  • ForgotPasswordBinding لصفحة استعادة كلمة المرور.

استخدام Binding مستقل لكل صفحة يعني أن الصفحة تحصل على اعتمادياتها عند فتحها، بدلًا من تحميل جميع Controllers منذ بداية تشغيل التطبيق.

كما أن جلب AuthRepository باستخدام Get.find<AuthRepository>() يعتمد على تسجيله سابقًا داخل InitialBinding.

افتح الملف:

lib/features/auth/bindings/auth_binding.dart

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

import 'package:at_your_service/features/auth/controllers/forgot_password_controller.dart';
import 'package:at_your_service/features/auth/controllers/login_controller.dart';
import 'package:at_your_service/features/auth/controllers/register_controller.dart';
import 'package:at_your_service/features/auth/repositories/auth_repository.dart';
import 'package:get/get.dart';


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


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


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

إنشاء نماذج بيانات طلبات المصادقة

يحتوي ملف auth_requests.dart على أصناف بسيطة تمثل البيانات التي تحتاج إليها كل عملية:

  • LoginInput يحمل البريد الإلكتروني وكلمة المرور.
  • RegisterInput يحمل الاسم والبريد الإلكتروني وكلمة المرور.
  • SendPasswordResetEmailInput يحمل البريد الإلكتروني المطلوب إرسال رابط الاستعادة إليه.

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

كما تتولى دالة toJson تنظيف بعض القيم باستخدام trim وتحويل الكائن إلى خريطة عند الحاجة.

افتح الملف:

lib/features/auth/models/auth_requests.dart

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

class LoginInput {
 final String email;
 final String password;


 const LoginInput({
   required this.email,
   required this.password,
 });


 Map<String, dynamic> toJson() {
   return {
     'email': email.trim(),
     'password': password,
   };
 }
}


class RegisterInput {
 final String name;
 final String email;
 final String password;


 const RegisterInput({
   required this.name,
   required this.email,
   required this.password,
 });


 Map<String, dynamic> toJson() {
   return {
     'name': name.trim(),
     'email': email.trim(),
     'password': password,
   };
 }
}


class SendPasswordResetEmailInput {
 final String email;


 const SendPasswordResetEmailInput({
   required this.email,
 });


 Map<String, dynamic> toJson() {
   return {
     'email': email.trim(),
   };
 }
}

إنشاء AuthRepository

يمثل AuthRepository طبقة وسيطة بين Controllers وحزمة HosteDay. لا تحتاج الواجهة إلى معرفة تفاصيل HosteDay.auth، بل تستدعي دوال واضحة مثل login وregister وsendPasswordResetEmail.

هذا الفصل مهم لعدة أسباب:

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

تستخدم دالة login البريد الإلكتروني وكلمة المرور لتسجيل الدخول، ثم تعيد المستخدم.

وتستخدم دالة register بيانات التسجيل، مع إرسال الاسم داخل additionalData. أما sendPasswordResetEmail فترسل طلب استعادة كلمة المرور من خلال الحزمة مباشرة.

افتح الملف:

lib/features/auth/repositories/auth_repository.dart

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

import 'package:at_your_service/features/auth/models/auth_requests.dart';
import 'package:hosteday_flutter/hosteday_flutter.dart';


class AuthRepository {
 Future<HosteDayUser> login(LoginInput input) async {
   final response = await HosteDay.auth.signInWithEmailAndPassword(
     email: input.email,
     password: input.password,
   );


   return response.user;
 }


 Future<HosteDayUser?> register(RegisterInput input) async {
   final response = await HosteDay.auth.createUserWithEmailAndPassword(
     email: input.email,
     password: input.password,
     additionalData: <String, dynamic>{
       'name': input.name,
     },
   );


   return response.user;
 }


 Future<void> sendPasswordResetEmail(
     SendPasswordResetEmailInput input,
     ) async {
   await HosteDay.auth.sendPasswordResetEmail(
     email: input.email,
   );
 }
}

بقية ملفات Controllers وViews وWidgets

بهذا نكون قد جهزنا البنية الأساسية لميزة المصادقة، وربطنا المستودع، والمسارات، والـBindings، ونماذج الطلبات، وعناصر الواجهة العامة.

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

  • lib/features/auth/controllers
  • lib/features/auth/views
  • lib/features/auth/widgets

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

رابط الفرع الخاص بالمقال:

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

كيف تعمل طبقات المصادقة معًا؟

عندما يفتح المستخدم صفحة تسجيل الدخول، ينفذ GetX الخطوات التالية:

  1. يطابق المسار مع الصفحة المسجلة داخل AppPages.
  2. يشغل LoginBinding.
  3. ينشئ LoginController.
  4. يمرر إليه AuthRepository المسجل مسبقًا.
  5. يستقبل Controller بيانات النموذج من الواجهة.
  6. يحول البيانات إلى LoginInput.
  7. يستدعي AuthRepository.login.
  8. ينفذ المستودع عملية تسجيل الدخول عبر HosteDay.auth.
  9. تحفظ HosteDay الجلسة محليًا باستخدام HosteDaySharedPreferencesAuthStorage.

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

ملاحظات للمبتدئين

لا تقلق إذا بدت كثرة الملفات غير ضرورية في البداية. الهدف من هذا التنظيم ليس زيادة التعقيد، بل منع التعقيد عندما يكبر التطبيق.

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

حاول فهم مسؤولية كل طبقة بدلًا من حفظ الكود:

  • الصفحة تعرض الواجهة.
  • Controller يدير الحالة والأحداث.
  • Model يمثل البيانات.
  • Repository يتعامل مع الخدمة الخارجية.
  • Binding يربط الاعتماديات.
  • Routes تنظم التنقل.

ملاحظات للمطورين المحترفين

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

  • طبقة موحدة لتحويل أخطاء HosteDay إلى رسائل محلية.
  • اختبارات وحدات لـControllers وRepositories.
  • حراس مسارات لمنع المستخدم غير المسجل من فتح صفحات الحساب.
  • إدارة تفاعلية لحالة المستخدم بدلًا من قراءة currentUser مباشرة في أكثر من موضع.
  • حالات تحميل ونجاح وفشل موحدة.
  • دعم إعادة إرسال رسالة التحقق من البريد الإلكتروني.
  • فصل الواجهات المشتركة ضمن Design System خاص بالتطبيق.

الخلاصة

أنشأنا في هذا المقال الأساس المنظم لميزة المصادقة في تطبيق الخدمات المحلية باستخدام Flutter وGetX وHosteDay.

بدأنا بتحديث إعدادات الحزمة وحفظ الجلسة محليًا، ثم نظمنا ملفات الميزة، وسجلنا الاعتماديات، وأضفنا المسارات، وأنشأنا عناصر التنقل، ونماذج الطلبات، ومستودع المصادقة.

الميزة الأهم في هذا الأسلوب هي أن التطبيق لا يتعامل يدويًا مع روابط API أو تخزين رمز المصادقة؛ إذ تتولى حزمة hosteday_flutter هذه التفاصيل، بينما يبقى كود التطبيق مركزًا على تجربة المستخدم وتنظيم منطق العمل.