Flutter 13 يوليو 2026 144 مشاهدة

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

أنشئ صفحة ملف شخصي متكاملة في Flutter باستخدام HosteDay وGetX، مع تحديث البيانات والصورة وإدارة التحقق وتسجيل الخروج.

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

مقدمة

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

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

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

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

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


ما الذي سننجزه في هذا الدرس؟

بنهاية المقال ستكون لدينا صفحة حساب مستخدم تتضمن:

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

مراجعة الأجزاء السابقة

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

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

ويمكنك الوصول إلى نسخة المشروع عند نهاية الجزء الثالث من خلال الرابط التالي:

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


المتطلبات قبل البدء

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

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

المبتدئ يحتاج فقط إلى معرفة أساسيات Widgets وFuture وملفات Dart. أما معرفة Repository Pattern وDependency Injection فستساعدك على فهم التنظيم بصورة أعمق، وسنشرح دورهما أثناء التطبيق.


نقاط API الخاصة بحساب المستخدم

توفّر منصة HosteDay نقاط API اللازمة لإدارة حساب المستخدم. بعض العمليات تستخدم الرابط نفسه /api/user، لكن الاختلاف يكون في نوع طلب HTTP مثل GET أو PUT أو DELETE.

العملية الرابط الغرض
تسجيل الخروج https://a-y-service.hosteday.com/api/logout إنهاء جلسة المستخدم الحالية.
إعادة تعيين كلمة المرور https://a-y-service.hosteday.com/api/user/password-reset-email إرسال رابط إعادة تعيين كلمة المرور إلى بريد المستخدم المسجل.
جلب بيانات الحساب https://a-y-service.hosteday.com/api/user الحصول على بيانات المستخدم الحالي.
تحديث الحساب https://a-y-service.hosteday.com/api/user تحديث بيانات الحساب، مثل الاسم.
حذف الحساب https://a-y-service.hosteday.com/api/user حذف حساب المستخدم باستخدام نوع الطلب المناسب.
تحديث الصورة الشخصية https://a-y-service.hosteday.com/api/user/avatar رفع صورة جديدة وربطها بالحساب.
إعادة إرسال رسالة التحقق https://a-y-service.hosteday.com/api/user/email/verification-notification إرسال رسالة جديدة لتوثيق البريد الإلكتروني.
حالة التحقق من الحساب https://a-y-service.hosteday.com/api/user/email/verification-status معرفة هل البريد الإلكتروني موثّق أم لا.

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

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


كيف تتوزع المسؤوليات داخل الميزة؟

قبل كتابة الملفات، من المهم فهم طريقة التنظيم التي سنستخدمها:

  • Repository: يتعامل مباشرة مع حزمة HosteDay ويجمع عمليات الحساب في مكان واحد.
  • Controller: يدير حالة الصفحة، والتحميل، والأخطاء، وتنفيذ العمليات التي يطلبها المستخدم.
  • View: تبني واجهة الملف الشخصي وتعرض البيانات القادمة من Controller.
  • Binding: ينشئ Repository وController ويحقنهما عبر GetX عند فتح الصفحة.
  • Widgets: مكونات صغيرة قابلة لإعادة الاستخدام مثل رأس الملف الشخصي والبطاقات والعناصر.

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


إنشاء شجرة ملفات ميزة المستخدم

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

lib/features/user/
├── bindings
│   └── user_binding.dart
├── controllers
│   └── user_controller.dart
├── repositories
│   └── user_repository.dart
├── views
│   └── profile_view.dart
└── widgets
    ├── avatar_fallback.dart
    ├── profile_card.dart
    ├── profile_header.dart
    ├── profile_tile.dart
    └── unauthenticated_view.dart

شرح المجلدات بطريقة مبسطة

bindings مسؤول عن تجهيز الاعتماديات قبل فتح الصفحة. وcontrollers يحتوي على منطق الصفحة وحالات التحميل. أما repositories فهو الطبقة التي تتحدث مع HosteDay. ويحتوي views على الصفحة الرئيسية للميزة، بينما يقسم مجلد widgets الواجهة إلى أجزاء صغيرة حتى لا يصبح ملف الصفحة طويلًا وصعب الصيانة.


أولًا: إعداد مسار صفحة الحساب

حتى يتمكن المستخدم من الانتقال إلى صفحة الملف الشخصي، نحتاج إلى اسم للمسار، ودالة تنقل، وتعريف صفحة GetX.

ملف lib/app/routes/go_page.dart

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

أضف السطر التالي كما هو:

static Future<dynamic>? account() => Get.toNamed(AppRoutes.account);

ملف lib/app/routes/app_routes.dart

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

أضف السطر التالي:

static const account = '/account';

ملف lib/app/routes/app_pages.dart

هنا نربط اسم المسار بالصفحة وبـ UserBinding. عند فتح المسار، ينشئ GetX الاعتماديات اللازمة ثم يعرض ProfileView.

أضف التعريف التالي:

GetPage( name: AppRoutes.account, page: () => ProfileView(), binding: UserBinding(), ),

ثانيًا: تحديث القائمة الجانبية drawer.dart

وظيفة الملف

القائمة الجانبية هي نقطة الوصول الرئيسية إلى صفحة الحساب. وهي تتغير حسب حالة المصادقة:

  • عند تسجيل الدخول تعرض اسم المستخدم وبريده وصورته وحالة توثيق البريد.
  • تعرض رابط حسابي للمستخدم المسجل.
  • تعرض تنبيهًا عندما يكون البريد غير موثّق.
  • تعرض زر تسجيل الخروج.
  • عند عدم تسجيل الدخول تعرض روابط تسجيل الدخول وإنشاء الحساب.

كيف يعمل الكود؟

يقرأ الملف المستخدم الحالي من HosteDay.auth.currentUser مرة واحدة، ثم يحدد قيمة isAuthenticated. بعد ذلك يبني العناصر المناسبة للحالة الحالية. كما يتحقق من رابط الصورة قبل تمريره إلى NetworkImage، ويعرض الحرف الأول من الاسم عند عدم وجود صورة صالحة.

أما عملية تسجيل الخروج فتبدأ بمربع حوار للتأكيد، ثم تستعمل الدالة الممررة onLogout إن وجدت، أو تستدعي HosteDay.auth.signOut() مباشرة.

استبدل ملف 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;
   final currentRoute = Get.currentRoute;

   // حفظ المستخدم في متغير واحد لتجنب استخدام 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: 'الرئيسية',
                   selected: currentRoute == AppRoutes.services,
                   onTap: () {
                     _closeDrawer();
                     Get.offAllNamed(AppRoutes.services);
                   },
                 ),

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

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

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


                   if (!(user.emailVerified))
                     _DrawerTile(
                       icon: Icons.mark_email_unread_outlined,
                       selectedIcon: Icons.mark_email_read,
                       title: 'تأكيد البريد الإلكتروني',
                       selected: currentRoute == AppRoutes.resendEmailVerification,
                       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.account();
                       },
                     ),
                 ] else ...[
                   const _DrawerDivider(),

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

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

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

                 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: 'تسجيل الخروج',
                 selected: currentRoute == AppRoutes.services,
                 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,
     ),
   );
 }
}

ما الذي يستفيد منه كل مستوى؟

المبتدئ سيتعلم بناء قائمة تتغير حسب حالة المستخدم. والمستوى المتوسط سيرى طريقة تقسيم واجهة طويلة إلى Widgets خاصة داخل الملف نفسه. أما المتقدم فسيلاحظ فصل عملية تسجيل الخروج عبر Callback اختياري، مما يجعل القائمة أكثر مرونة عند إعادة استخدامها.


ثالثًا: إنشاء UserRepository

مسار الملف

lib/features/user/repositories/user_repository.dart

وظيفة Repository

يمثل هذا الملف طبقة وسيطة بين Controller وحزمة HosteDay. بدل أن يستدعي Controller دوال HosteDay.auth في كل عملية، يجمع Repository جميع عمليات المستخدم في واجهة موحدة وواضحة.

يتولى الملف العمليات التالية:

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

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

اكتب الكود التالي كما هو:

import 'dart:typed_data';


import 'package:hosteday_flutter/hosteday_flutter.dart';


class UserRepository {
 HosteDayUser? get currentUser {
   return HosteDay.auth.currentUser;
 }


 Stream<HosteDayUser?> authStateChanges() {
   return HosteDay.auth.authStateChanges();
 }


 Future<HosteDayUser> reloadUser() {
   return HosteDay.auth.reload();
 }


 Future<void> signOut() {
   return HosteDay.auth.signOut();
 }


 Future<void> sendEmailVerification() {
   return HosteDay.auth.sendEmailVerification();
 }


 Future<void> sendCurrentUserPasswordResetEmail() {
   return HosteDay.auth.sendCurrentUserPasswordResetEmail();
 }


 Future<HosteDayUser> updateProfile({required String name}) {
   if (name.trim().isEmpty) {
     throw ArgumentError('يجب إدخال اسم صالح لتحديث الملف الشخصي.');
   }


   return HosteDay.auth.updateProfile(name: name);
 }


 Future<HosteDayUser> updateAvatar({
   required Uint8List bytes,
   required String extension,
 }) {
   return HosteDay.auth.updateAvatar(bytes: bytes, extension: extension);
 }
}

شرح أهم الدوال

currentUser يعيد المستخدم المحفوظ حاليًا داخل حزمة HosteDay. وauthStateChanges يعيد Stream يمكن للتطبيق الاستماع إليه لمعرفة تسجيل الدخول أو الخروج أو تغير بيانات المستخدم.

الدالة reloadUser تجلب نسخة محدثة من بيانات الحساب، بينما updateProfile تتحقق أولًا من أن الاسم ليس فارغًا ثم ترسله إلى HosteDay. أما updateAvatar فتستقبل الصورة على شكل Uint8List مع امتدادها، ثم تترك للحزمة مسؤولية رفعها.


رابعًا: إنشاء UserController

مسار الملف

lib/features/user/controllers/user_controller.dart

وظيفة Controller

هذا الملف هو العقل الذي يدير صفحة الملف الشخصي. لا يبني عناصر واجهة، ولا يتصل مباشرة بنقاط API، بل ينسق بين View وRepository.

يحتفظ Controller بالمستخدم الحالي داخل متغير تفاعلي Rxn<HosteDayUser>. كما يعرّف متغير تحميل منفصلًا لكل عملية حتى تستطيع الواجهة إظهار مؤشر تحميل مناسب وتعطيل الأزرار أثناء تنفيذ الطلب.

تشمل الحالات الموجودة:

  • تحديث بيانات المستخدم.
  • تسجيل الخروج.
  • رفع الصورة.
  • إرسال رسالة التحقق.
  • تحديث الاسم.
  • إرسال رابط إعادة تعيين كلمة المرور.

ويجمع getter المسمى isBusy هذه الحالات، بحيث يمكن منع بدء عمليتين متعارضتين في الوقت نفسه.

اكتب الكود التالي كما هو:

import 'dart:async';


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';
import 'package:image_picker/image_picker.dart';


import '../repositories/user_repository.dart';


class UserController extends GetxController {
 UserController({
   UserRepository? repository,
 }) : _repository = repository ?? UserRepository();


 final UserRepository _repository;
 final ImagePicker _imagePicker = ImagePicker();


 final Rxn<HosteDayUser> user = Rxn<HosteDayUser>();


 final RxBool isRefreshing = false.obs;
 final RxBool isSigningOut = false.obs;
 final RxBool isUploadingAvatar = false.obs;
 final RxBool isSendingVerification = false.obs;
 final RxBool isUpdatingProfile = false.obs;
 final RxBool isSendingPasswordReset = false.obs;


 late TextEditingController nameController ;


 StreamSubscription<HosteDayUser?>? _userSubscription;


 bool get isBusy {
   return isRefreshing.value ||
       isSigningOut.value ||
       isUploadingAvatar.value ||
       isSendingVerification.value ||
       isSendingPasswordReset.value ||
       isUpdatingProfile.value;
 }


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


   user.value = _repository.currentUser;


   _userSubscription = _repository.authStateChanges().listen(
         (currentUser) {
       user.value = currentUser;
       nameController = TextEditingController(
         text: currentUser?.name
       );
     },
   );
 }


 Future<void> refreshUser() async {
   if (isBusy) {
     return;
   }


   isRefreshing.value = true;


   try {
     user.value = await _repository.reloadUser();
   } catch (error) {
     _showError(error);
   } finally {
     isRefreshing.value = false;
   }
 }


 Future<void> updateProfile({
   required String name,
 }) async {
   if (isBusy) {
     return;
   }


   final normalizedName = name.trim();


   if (normalizedName.isEmpty) {
     Get.snackbar(
       'بيانات غير صحيحة',
       'يرجى إدخال الاسم.',
       snackPosition: SnackPosition.BOTTOM,
     );


     return;
   }


   final currentName =
       user.value?.displayName?.trim() ?? '';


   if (normalizedName == currentName) {
     Get.snackbar(
       'لا توجد تغييرات',
       'الاسم الجديد مطابق للاسم الحالي.',
       snackPosition: SnackPosition.BOTTOM,
     );


     return;
   }


   isUpdatingProfile.value = true;


   try {
     final updatedUser =
     await _repository.updateProfile(
     name: normalizedName
     );


     user.value = updatedUser;


     Get.snackbar(
       'تم التحديث',
       'تم تحديث الملف الشخصي بنجاح.',
       snackPosition: SnackPosition.BOTTOM,
     );
   } catch (error) {
     _showError(error);
   } finally {
     isUpdatingProfile.value = false;
   }
 }


 Future<void> pickAndUploadAvatar(
     ImageSource source,
     ) async {
   if (isBusy) {
     return;
   }


   try {
     final image = await _imagePicker.pickImage(
       source: source,
       imageQuality: 85,
       maxWidth: 1600,
       maxHeight: 1600,
       requestFullMetadata: false,
     );


     if (image == null) {
       return;
     }


     isUploadingAvatar.value = true;


     final bytes = await image.readAsBytes();


     if (bytes.isEmpty) {
       throw StateError(
         'الصورة المحددة فارغة.',
       );
     }


     final extension = _extractExtension(image);


     final updatedUser =
     await _repository.updateAvatar(
       bytes: bytes,
       extension: extension,
     );


     user.value = updatedUser;


     Get.snackbar(
       'تم التحديث',
       'تم تحديث صورة الملف الشخصي بنجاح.',
       snackPosition: SnackPosition.BOTTOM,
     );
   } catch (error) {
     _showError(error);
   } finally {
     isUploadingAvatar.value = false;
   }
 }


 Future<void> sendEmailVerification() async {
   if (isBusy) {
     return;
   }


   isSendingVerification.value = true;


   try {
     await _repository.sendEmailVerification();


     Get.snackbar(
       'تم الإرسال',
       'تم إرسال رسالة التحقق إلى بريدك الإلكتروني.',
       snackPosition: SnackPosition.BOTTOM,
     );
   } catch (error) {
     _showError(error);
   } finally {
     isSendingVerification.value = false;
   }
 }


 Future<void> sendCurrentUserPasswordResetEmail() async {
   if (isBusy) {
     return;
   }


   isSendingPasswordReset.value = true;


   try {
     await _repository.sendCurrentUserPasswordResetEmail();


     Get.snackbar(
       'تم الإرسال',
       'تم إرسال رابط إعادة تعيين كلمة المرور إلى بريدك الإلكتروني.',
       snackPosition: SnackPosition.BOTTOM,
     );
   } catch (error) {
     _showError(error);
   } finally {
     isSendingPasswordReset.value = false;
   }
 }


 Future<void> signOut() async {
   if (isBusy) {
     return;
   }


   isSigningOut.value = true;


   try {
     await _repository.signOut();


     user.value = null;


     GoPage.services();
   } catch (error) {
     _showError(error);
   } finally {
     isSigningOut.value = false;
   }
 }


 String _extractExtension(XFile image) {
   final fileName =
   image.name.trim().toLowerCase();


   final dotIndex = fileName.lastIndexOf('.');


   if (dotIndex != -1 &&
       dotIndex < fileName.length - 1) {
     final extension =
     fileName.substring(dotIndex + 1);


     if (_supportedExtensions.contains(extension)) {
       return extension;
     }
   }


   final mimeType =
   image.mimeType?.trim().toLowerCase();


   switch (mimeType) {
     case 'image/jpeg':
     case 'image/jpg':
       return 'jpg';


     case 'image/png':
       return 'png';


     case 'image/webp':
       return 'webp';
   }


   throw const FormatException(
     'صيغة الصورة غير مدعومة. '
         'الصيغ المدعومة: JPG وPNG وWEBP.',
   );
 }


 void _showError(Object error) {
   final message = error is HosteDayException
       ? error.displayMessage
       : error
       .toString()
       .replaceFirst('Exception: ', '');


   Get.snackbar(
     'حدث خطأ',
     message,
     snackPosition: SnackPosition.BOTTOM,
   );
 }


 @override
 void onClose() {
   _userSubscription?.cancel();


   super.onClose();
 }


 static const Set<String> _supportedExtensions =
 <String>{
   'jpg',
   'jpeg',
   'png',
   'webp',
 };
}

شرح تدفق العمل داخل Controller

تهيئة المستخدم

عند تشغيل Controller تنفذ onInit. في البداية توضع قيمة المستخدم الحالي داخل user، ثم يبدأ الاستماع إلى authStateChanges. كلما تغير المستخدم، تتحدث القيمة التفاعلية ويتجهز nameController بالاسم الحالي.

تحديث البيانات

الدالة refreshUser تمنع التكرار عندما تكون هناك عملية قيد التنفيذ، ثم تستدعي reloadUser وتضع النتيجة داخل user. وبما أن الواجهة تستعمل Obx، فإنها تعيد بناء الأجزاء المتأثرة تلقائيًا.

تحديث الاسم

الدالة updateProfile تنظف الاسم باستخدام trim، وتتحقق من عدم فراغه، ثم تقارنه بالاسم الحالي. إذا لم توجد تغييرات تعرض رسالة للمستخدم، وإذا كان الاسم جديدًا ترسله إلى Repository وتعرض رسالة نجاح.

رفع الصورة الشخصية

الدالة pickAndUploadAvatar تستقبل مصدر الصورة، سواء المعرض أو الكاميرا. ثم تستخدم ImagePicker لاختيار الصورة مع تقليل الجودة والحجم إلى قيم مناسبة. بعد ذلك تقرأ الصورة كـ bytes، وتستخرج امتدادها، وترسل البيانات إلى updateAvatar.

الدالة _extractExtension تحاول معرفة الامتداد من اسم الملف أولًا، ثم تستخدم MIME Type عند الحاجة. ولا تقبل إلا JPG وJPEG وPNG وWEBP.

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

الدالة _showError تعرض displayMessage عندما يكون الخطأ من نوع HosteDayException. أما الأخطاء الأخرى فتحول إلى نص واضح قبل عرضها في Snackbar.

إغلاق الموارد

عند إغلاق Controller تنفذ onClose لإلغاء الاشتراك في Stream، وهذا يمنع استمرار الاستماع بعد مغادرة الصفحة.


خامسًا: إنشاء صفحة ProfileView

مسار الملف

lib/features/user/views/profile_view.dart

وظيفة الصفحة

هذه الصفحة مسؤولة عن عرض حالة الحساب وتحويل تفاعل المستخدم إلى أوامر ينفذها Controller. وهي لا تتعامل مع HosteDay مباشرة.

تعتمد الصفحة على GetView<UserController>، لذلك تستطيع الوصول إلى Controller من خلال المتغير controller دون إنشائه يدويًا. ويضمن UserBinding وجوده قبل فتح الصفحة.

اكتب الكود التالي كما هو:

import 'package:at_your_service/app/widgets/app_bar.dart';
import 'package:at_your_service/features/user/controllers/user_controller.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';


import '../widgets/profile_card.dart';
import '../widgets/profile_header.dart';
import '../widgets/profile_tile.dart';
import '../widgets/unauthenticated_view.dart';


class ProfileView extends GetView<UserController> {
 const ProfileView({super.key});


 @override
 Widget build(BuildContext context) {
   return Scaffold(
     appBar: appBar(
       title: 'الملف الشخصي',
       actions: <Widget>[
         Obx(
           () => IconButton(
             tooltip: 'تحديث',
             onPressed: controller.isBusy ? null : controller.refreshUser,
             icon: controller.isRefreshing.value
                 ? const SizedBox.square(
                     dimension: 20,
                     child: CircularProgressIndicator(strokeWidth: 2),
                   )
                 : const Icon(Icons.refresh),
           ),
         ),
       ],
     ),
     body: Obx(() {
       final user = controller.user.value;


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


       final name = _textOrFallback(
         user.displayName,
         fallback: 'مستخدم HosteDay',
       );


       final email = _textOrFallback(user.email);
       final id = _textOrFallback(user.id);
       final photoUrl = user.avatarUrl?.trim() ?? '';


       return RefreshIndicator(
         onRefresh: controller.refreshUser,
         child: ListView(
           physics: const AlwaysScrollableScrollPhysics(),
           padding: const EdgeInsets.all(16),
           children: <Widget>[
             ProfileHeader(
               name: name,
               email: email,
               photoUrl: photoUrl,
               emailVerified: user.emailVerified,
               uploadingAvatar: controller.isUploadingAvatar.value,
               onChangeAvatar: controller.isBusy
                   ? null
                   : _showAvatarSourceSheet,
             ),
             const SizedBox(height: 24),
             ProfileCard(
               children: <Widget>[
                 ProfileTile(
                   icon: Icons.person_outline,
                   title: 'الاسم',
                   value: name,
                   onTap: controller.isUpdatingProfile.value
                       ? null
                       : () => _showUpdateProfileDialog(
                           user.displayName?.trim() ?? '',
                         ),
                   trailing: controller.isUpdatingProfile.value
                       ? const SizedBox.square(
                           dimension: 20,
                           child: CircularProgressIndicator(strokeWidth: 2),
                         )
                       : const Icon(Icons.edit_outlined),
                 ),
                 const Divider(height: 1),
                 ProfileTile(
                   icon: Icons.email_outlined,
                   title: 'البريد الإلكتروني',
                   value: email,
                 ),
                 const Divider(height: 1),
                 ProfileTile(
                   icon: user.emailVerified
                       ? Icons.verified_outlined
                       : Icons.warning_amber_rounded,
                   title: 'حالة البريد',
                   value: user.emailVerified
                       ? 'تم التحقق من البريد'
                       : 'البريد غير موثّق',
                   valueColor: user.emailVerified
                       ? Colors.green
                       : Colors.orange,
                 ),
                 const Divider(height: 1),
                 ProfileTile(
                   icon: Icons.fingerprint,
                   title: 'معرّف المستخدم',
                   value: id,
                   selectable: true,
                 ),
               ],
             ),
             const SizedBox(height: 16),
             if (!user.emailVerified)
               SizedBox(
                 width: double.infinity,
                 child: OutlinedButton.icon(
                   onPressed:
                       controller.isSendingVerification.value ||
                           controller.isBusy
                       ? null
                       : controller.sendEmailVerification,
                   icon: controller.isSendingVerification.value
                       ? const SizedBox.square(
                           dimension: 18,
                           child: CircularProgressIndicator(strokeWidth: 2),
                         )
                       : const Icon(Icons.mark_email_unread_outlined),
                   label: Text(
                     controller.isSendingVerification.value
                         ? 'جاري إرسال رسالة التحقق...'
                         : 'إرسال رسالة التحقق',
                   ),
                 ),
               ),
             const SizedBox(height: 12),


             SizedBox(
               width: double.infinity,
               child: OutlinedButton.icon(
                 onPressed: controller.isBusy
                     ? null
                     : _showPasswordResetDialog,
                 icon: controller.isSendingPasswordReset.value
                     ? const SizedBox.square(
                         dimension: 18,
                         child: CircularProgressIndicator(strokeWidth: 2),
                       )
                     : const Icon(Icons.lock_reset_outlined),
                 label: Text(
                   controller.isSendingPasswordReset.value
                       ? 'جاري إرسال رابط إعادة التعيين...'
                       : 'إعادة تعيين كلمة المرور',
                 ),
               ),
             ),
             const SizedBox(height: 24),
             SizedBox(
               width: double.infinity,
               child: FilledButton.icon(
                 onPressed: controller.isBusy ? null : _showSignOutDialog,
                 icon: controller.isSigningOut.value
                     ? const SizedBox.square(
                         dimension: 18,
                         child: CircularProgressIndicator(strokeWidth: 2),
                       )
                     : const Icon(Icons.logout),
                 label: Text(
                   controller.isSigningOut.value
                       ? 'جاري تسجيل الخروج...'
                       : 'تسجيل الخروج',
                 ),
                 style: FilledButton.styleFrom(
                   padding: const EdgeInsets.symmetric(vertical: 14),
                   backgroundColor: Theme.of(context).colorScheme.error,
                   foregroundColor: Theme.of(context).colorScheme.onError,
                 ),
               ),
             ),
           ],
         ),
       );
     }),
   );
 }


 Future<void> _showUpdateProfileDialog(String currentName) async {
   final formKey = GlobalKey<FormState>();


   final updatedName = await Get.dialog<String>(
     AlertDialog(
       title: const Text('تحديث الملف الشخصي'),
       content: Form(
         key: formKey,
         child: TextFormField(
           controller: controller.nameController,
           autofocus: true,
           textInputAction: TextInputAction.done,
           maxLength: 100,
           decoration: const InputDecoration(
             labelText: 'الاسم',
             hintText: 'أدخل الاسم الجديد',
             prefixIcon: Icon(Icons.person_outline),
           ),
           validator: (value) {
             final name = value?.trim() ?? '';


             if (name.isEmpty) {
               return 'يرجى إدخال الاسم.';
             }


             if (name.length < 2) {
               return 'يجب ألا يقل الاسم عن حرفين.';
             }


             return null;
           },
           onFieldSubmitted: (_) {
             _submitProfileForm(
               formKey: formKey,
               nameController: controller.nameController,
             );
           },
         ),
       ),
       actions: <Widget>[
         TextButton(onPressed: () => Get.back(), child: const Text('إلغاء')),
         FilledButton.icon(
           onPressed: () {
             _submitProfileForm(
               formKey: formKey,
               nameController: controller.nameController,
             );
           },
           icon: const Icon(Icons.save_outlined),
           label: const Text('حفظ'),
         ),
       ],
     ),
     barrierDismissible: false,
   );


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


   await controller.updateProfile(name: updatedName.trim());
 }


 void _submitProfileForm({
   required GlobalKey<FormState> formKey,
   required TextEditingController nameController,
 }) {
   final isValid = formKey.currentState?.validate() ?? false;


   if (!isValid) {
     return;
   }


   Get.back<String>(result: nameController.text.trim());
 }


 Future<void> _showAvatarSourceSheet() async {
   await Get.bottomSheet<void>(
     SafeArea(
       child: Container(
         decoration: BoxDecoration(
           color: Get.theme.colorScheme.surface,
           borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
         ),
         padding: const EdgeInsets.only(top: 8, bottom: 16),
         child: Column(
           mainAxisSize: MainAxisSize.min,
           children: <Widget>[
             Container(
               width: 42,
               height: 4,
               margin: const EdgeInsets.only(bottom: 12),
               decoration: BoxDecoration(
                 color: Get.theme.colorScheme.outlineVariant,
                 borderRadius: BorderRadius.circular(100),
               ),
             ),
             const ListTile(
               title: Text(
                 'تغيير صورة الملف الشخصي',
                 style: TextStyle(fontWeight: FontWeight.bold),
               ),
             ),
             ListTile(
               leading: const Icon(Icons.photo_library_outlined),
               title: const Text('اختيار صورة من المعرض'),
               onTap: () {
                 Get.back();


                 controller.pickAndUploadAvatar(ImageSource.gallery);
               },
             ),
             ListTile(
               leading: const Icon(Icons.photo_camera_outlined),
               title: const Text('التقاط صورة بالكاميرا'),
               onTap: () {
                 Get.back();


                 controller.pickAndUploadAvatar(ImageSource.camera);
               },
             ),
             ListTile(
               leading: const Icon(Icons.close),
               title: const Text('إلغاء'),
               onTap: () => Get.back(),
             ),
           ],
         ),
       ),
     ),
     isScrollControlled: true,
   );
 }


 Future<void> _showSignOutDialog() async {
   final confirmed = await Get.dialog<bool>(
     AlertDialog(
       title: const Text('تسجيل الخروج'),
       content: const Text('هل أنت متأكد من تسجيل الخروج من الحساب؟'),
       actions: <Widget>[
         TextButton(
           onPressed: () {
             Get.back(result: false);
           },
           child: const Text('إلغاء'),
         ),
         FilledButton(
           onPressed: () {
             Get.back(result: true);
           },
           child: const Text('تسجيل الخروج'),
         ),
       ],
     ),
   );


   if (confirmed == true) {
     await controller.signOut();
   }
 }


 static String _textOrFallback(
   Object? value, {
   String fallback = 'غير متوفر',
 }) {
   final text = value?.toString().trim() ?? '';


   return text.isEmpty ? fallback : text;
 }


 Future<void> _showPasswordResetDialog() async {
   final confirmed = await Get.dialog<bool>(
     AlertDialog(
       title: const Text('إعادة تعيين كلمة المرور'),
       content: const Text(
         'سيتم إرسال رابط إعادة تعيين كلمة المرور إلى بريدك الإلكتروني. هل تريد المتابعة؟',
       ),
       actions: <Widget>[
         TextButton(
           onPressed: () {
             Get.back(result: false);
           },
           child: const Text('إلغاء'),
         ),
         FilledButton.icon(
           onPressed: () {
             Get.back(result: true);
           },
           icon: const Icon(Icons.send_outlined),
           label: const Text('إرسال الرابط'),
         ),
       ],
     ),
   );


   if (confirmed == true) {
     await controller.sendCurrentUserPasswordResetEmail();
   }
 }
}

شرح أقسام الصفحة

شريط التطبيق والتحديث

يحتوي AppBar على زر تحديث. عندما تكون عملية التحديث جارية، يتحول الرمز إلى CircularProgressIndicator. وعند انشغال Controller بعملية أخرى يصبح الزر معطلًا.

التحقق من وجود المستخدم

تقرأ الصفحة controller.user.value داخل Obx. إذا كانت القيمة null تعرض UnauthenticatedView، وهذا يمنع محاولة قراءة اسم أو بريد مستخدم غير موجود.

رأس الملف الشخصي

يستقبل ProfileHeader الاسم والبريد ورابط الصورة وحالة التوثيق وحالة رفع الصورة. وعند الضغط على تغيير الصورة تفتح الصفحة Bottom Sheet يتيح الاختيار بين المعرض والكاميرا.

بطاقة معلومات الحساب

تعرض ProfileCard مجموعة عناصر من نوع ProfileTile تشمل الاسم، والبريد الإلكتروني، وحالة البريد، ومعرّف المستخدم. ويمكن الضغط على الاسم لفتح مربع تحديث الملف الشخصي.

تحديث الاسم

تفتح _showUpdateProfileDialog نموذجًا يحتوي على TextFormField. يتحقق النموذج من أن الاسم غير فارغ ولا يقل عن حرفين. وعند الحفظ يعيد مربع الحوار الاسم الجديد، ثم ترسله الصفحة إلى controller.updateProfile.

إعادة توثيق البريد

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

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

يفتح الزر مربع تأكيد يوضح أن رابط إعادة التعيين سيصل إلى البريد الإلكتروني. بعد الموافقة تستدعي الصفحة sendCurrentUserPasswordResetEmail.

تسجيل الخروج

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

السحب للتحديث

تغلف الصفحة القائمة داخل RefreshIndicator، ولذلك يمكن للمستخدم سحب الصفحة إلى الأسفل للحصول على أحدث بيانات للحساب.


سادسًا: إنشاء UserBinding

مسار الملف

lib/features/user/bindings/user_binding.dart

وظيفة Binding

يحدد هذا الملف كيف ينشئ GetX الاعتماديات المطلوبة للميزة. أولًا يسجل UserRepository، ثم ينشئ UserController ويمرر إليه Repository المسجل.

الخيار fenix: true يسمح لـ GetX بإعادة إنشاء الاعتمادية عند الحاجة بعد حذفها من الذاكرة. وهذا مفيد عند مغادرة الصفحة ثم العودة إليها لاحقًا.

اكتب الكود التالي كما هو:

import 'package:at_your_service/features/user/controllers/user_controller.dart';
import 'package:at_your_service/features/user/repositories/user_repository.dart';
import 'package:get/get.dart';


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


   Get.lazyPut<UserController>(
         () => UserController(
       repository: Get.find<UserRepository>(),
     ),
     fenix: true,
   );
 }
}

لماذا لا ننشئ Controller داخل الصفحة؟

إنشاء Controller داخل ProfileView سيربط الصفحة بطريقة إنشاء الاعتماديات، ويصعّب الاختبار وإعادة الاستخدام. Binding يجعل الصفحة مسؤولة عن العرض فقط، بينما تتولى طبقة مستقلة تجهيز الكائنات المطلوبة.


سابعًا: ملفات Widgets المساعدة

تحتوي الميزة على الملفات التالية:

  • avatar_fallback.dart: يعرض بديلًا للصورة عند عدم وجود رابط صالح أو عند فشل التحميل.
  • profile_card.dart: حاوية موحدة لعرض معلومات الحساب داخل بطاقة.
  • profile_header.dart: يعرض الصورة والاسم والبريد وحالة التوثيق وزر تغيير الصورة.
  • profile_tile.dart: عنصر قابل لإعادة الاستخدام لعرض عنوان وقيمة وأيقونة وإجراء اختياري.
  • unauthenticated_view.dart: واجهة تظهر عندما يحاول شخص غير مسجل الوصول إلى صفحة الحساب.

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

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

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


تدفق البيانات داخل صفحة الملف الشخصي

يمكن تلخيص حركة البيانات كالتالي:

ProfileView
    ↓ يرسل أمر المستخدم
UserController
    ↓ يطلب العملية
UserRepository
    ↓ يستدعي
HosteDay.auth
    ↓ يعيد النتيجة
UserController يحدّث Rx
    ↓
Obx يعيد بناء واجهة ProfileView

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


خطوات اختبار الصفحة بعد الانتهاء

شغّل التطبيق ونفذ الاختبارات التالية بالترتيب:

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

مشكلات شائعة وحلولها

لا تظهر بيانات المستخدم

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

لا تعمل الصورة من المعرض أو الكاميرا

تأكد من إضافة image_picker وإكمال إعدادات الصلاحيات المطلوبة لكل منصة. تختلف إعدادات Android وiOS، وقد تحتاج الكاميرا إلى جهاز فعلي كي تعمل بصورة صحيحة.

يظهر رابط الصورة لكن الصورة لا تُعرض

يجب أن يكون الرابط كاملًا ويبدأ بـ http أو https. الكود الموجود في القائمة الجانبية يتحقق من صلاحية الرابط قبل استخدام NetworkImage.

زر العملية معطل

تعطّل الأزرار عندما تكون إحدى العمليات قيد التنفيذ لأن isBusy يعيد true. انتظر انتهاء العملية الحالية وتأكد من إعادة قيم التحميل داخل finally عند حدوث خطأ.

لا تظهر رسالة توثيق البريد

زر إعادة الإرسال يظهر فقط عندما تكون قيمة emailVerified مساوية لـ false. أعد تحميل المستخدم للتأكد من الحصول على أحدث حالة من الخادم.

لا تعمل دوال الصورة أو إعادة تعيين كلمة المرور

تأكد من تحديث حزمة hosteday_flutter إلى الإصدار الذي يحتوي على updateAvatar وsendCurrentUserPasswordResetEmail.


أسئلة شائعة

هل أحتاج إلى استدعاء API يدويًا باستخدام Dio أو http؟

لا. في هذا التطبيق نتعامل مع عمليات الحساب من خلال HosteDay.auth داخل حزمة hosteday_flutter، ولذلك لا نحتاج إلى كتابة طلبات HTTP يدويًا لهذه العمليات.

لماذا نستخدم Repository مع أن الحزمة توفر الدوال مباشرة؟

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

لماذا توجد عدة متغيرات تحميل؟

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

كيف تتحدث الواجهة تلقائيًا بعد تغير المستخدم؟

المستخدم مخزن داخل Rxn<HosteDayUser>، والصفحة تقرأه داخل Obx. عندما يضع Controller قيمة جديدة، يعيد GetX بناء الجزء الذي يعتمد عليها.

هل صفحة الحساب مناسبة لتطبيقات الإنتاج؟

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

أين أجد ملفات Widgets الكاملة؟

توجد في فرع الجزء الرابع من المشروع:

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


خلاصة الدرس

أنشأنا في هذا الجزء بنية متكاملة لإدارة حساب المستخدم داخل تطبيق Flutter باستخدام HosteDay وGetX. بدأنا بإعداد المسار، ثم حدّثنا القائمة الجانبية، وأنشأنا Repository لعمليات الحساب، وController لإدارة الحالة، وView لعرض البيانات والتفاعل، وBinding لحقن الاعتماديات.

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

يمكنك الاطلاع على الكود الكامل للجزء الرابع من هنا:

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