إنتقل إلى المحتوى الرئيسي

تطبيقات Flutter

نظرة عامة

يحتوي المستودع على تطبيقين مستقلين يشتركان في معايير الأمان والتشغيل، لكن لكل منهما حزمة واختبارات خاصة:

التطبيقالمسارالمسؤولية
تطبيق العميلapps/mobile/customer/البحث والحجز والتذاكر والدعم
تطبيق السائقapps/mobile/driver/الرحلات وقائمة الركاب ومسح QR والتتبع والتحصيل

خط الأدوات المتحقق منه هو Flutter 3.38.5 وDart 3.10.4. يستخدم Android في التطبيقين Java 17 وAGP 8.12.1 وGradle 8.13 وKotlin 2.3.21.

الحزم الأساسية

الحزمةالنسخة الحاليةالاستخدام
flutter_riverpod3.0.xالحالة والاعتماديات باستخدام NotifierProvider
go_router17.4.xالتنقل وحواجز المصادقة
supabase_flutter2.17.xالمصادقة والاشتراكات والعمليات المباشرة المسموح بها
flutter_secure_storage10.3.xالرموز والبيانات الحساسة
connectivity_plus7.3.xرصد الاتصال وتشغيل المزامنة
geolocator14.0.xالموقع والتتبع
flutter_local_notifications20.1.xالإشعارات المحلية
mobile_scanner7.4.xمسح التذاكر في تطبيق السائق
google_sign_in7.2.xدخول Google الأصلي للمسافرين على Android وiOS

ملفا pubspec.lock جزء من مصدر الحقيقة ويجب تحديثهما مع pubspec.yaml. لا يعتمد التطبيقان حالياً على توليد Riverpod أو JSON في مسار البناء، لذلك لا توجد خطوة build_runner مطلوبة للتشغيل العادي.

تنظيم الشفرة

apps/mobile/{customer,driver}/
├── lib/
│ ├── main.dart
│ ├── core/ # الإعداد، التوجيه، الخدمات، الثيم والمكونات العامة
│ ├── data/ # النماذج وخدمات البيانات (في تطبيق العميل)
│ └── features/ # ميزات مستقلة حسب المجال
├── test/ # اختبارات unit وwidget وscreen
├── integration_test/ # موجود عند الحاجة إلى رحلة كاملة
├── android/
├── ios/
└── pubspec.yaml

القواعد الأساسية:

  • الحالة الجديدة تُكتب باستخدام Riverpod 3 وNotifier/AsyncNotifier، ولا يُضاف StateNotifierProvider القديم.
  • يبقى GoRouter هو مصدر الحقيقة للتنقل، وتُقرأ معاملات المسار من GoRouterState.
  • كل StreamSubscription وTimer وcontroller طويل العمر له مالك واضح ويُلغى في dispose أو دالة إيقاف صريحة.
  • لا تُشغّل طلبات رفع الموقع بالتوازي؛ ينتظر التتبع الطلب الجاري قبل إرسال العينة التالية.
  • بعد أي await في واجهة مستخدم، يجب التحقق من mounted قبل استخدام context أو تحديث الحالة.

واجهة الحركة وصور المسارات

يحزم التطبيقان خط Cairo-Variable.ttf محلياً ويعرّفان عائلتي Cairo وRoboto في pubspec.yaml. اسم Roboto هنا alias محلي لنفس ملف Cairo، لأن CanvasKit يطلب تلك العائلة كـ fallback افتراضي إن لم يجدها في FontManifest.json. لا تعتمد الواجهة أو fallback المحرك على google_fonts أو fonts.gstatic.com. يجب أن يثبت بناء الويب وجود العائلتين في build/web/assets/FontManifest.json، وأن يستخدم --no-web-resources-cdn كي تخرج ملفات CanvasKit تحت build/web/canvaskit/. تسمح سياسة المحتوى بـ'wasm-unsafe-eval' اللازم لتجميع WebAssembly، ولا تسمح بـ'unsafe-eval' الأوسع. تبقى رخصة OFL ضمن أصول كل تطبيق.

يستخدم تتبع الأخطاء على Flutter Web عميل Sentry بلغة Dart ويرسل الأحداث إلى GlitchTip مباشرة، مع تعطيل التهيئة التلقائية لعميل Sentry JavaScript. لذلك لا يحمّل التطبيق سكربتات من browser.sentry-cdn.com ولا يحتاج إلى توسيع سياسة المحتوى لطرف ثالث. تبقى تهيئة SDK الأصلية مفعّلة على Android وiOS لدعم تقارير الأعطال الأصلية.

يعرض تطبيق العميل صورة WebP محلية مختلفة لكل واحد من مسارات العرض الأساسية، ويختار fallback ثابتاً للمسارات الأخرى. تحمل الصورة وسم وصول يوضح أنها صورة توضيحية، بينما يبقى شعار الشركة مستقلاً؛ عند غياب الشعار تظهر علامة حروف مميزة من اسم الشركة. لا تحتاج الصور إلى اتصال شبكي، لأنها مدرجة تحت assets/images/routes/.

تحترم حركة الضغط في بطاقات الرحلات وحركة دخول بطاقة الرحلة التالية إعداد MediaQuery.disableAnimationsOf(context). عند طلب تقليل الحركة لا يطبق التحجيم أو الانتقال، ويظهر المحتوى مباشرة في حالته النهائية.

يدعم تطبيق العميل العربية والإنجليزية عبر Flutter gen-l10n وكتالوجات ARB، ويحفظ localeProvider الاختيار في Hive. تضبط اللغة النصوص والتواريخ وأسماء بيانات الرحلة واتجاه الواجهة والأسهم، ولا تقتصر على تبديل سلاسل مرئية. راجع تعريب وترجمة تجربة العميل لعقد إضافة التركية لاحقاً. يوجد مبدل اللغة في رأس الشاشة وفي Navigation Rail الموسع، وتستخدم تسميات الشريط عائلة Cairo صراحة حتى لا ترجع نسخة Flutter Web إلى خط النظام.

يقدم تطبيق العميل Google Sign-In أصلياً للمسافرين فقط. يطلب SDK رمزاً لجمهور Web/server واحد ثم يرسله إلى خادم شام باص للتحقق والربط وإصدار جلسة Supabase؛ لا يثق التطبيق ببيانات الملف الشخصي ولا يخزن Google secret. تحتفظ المصادقة بمسار العودة الكامل وتنتقل إلى استكمال الهاتف فقط عندما تعيد API needsPhone=true. يفشل بناء Android وiOS الموقع عند غياب إعداد Google لأن الحزمة المنشورة تعد بهذه الطريقة. أما نسخة الويب المستقلة فتخفي زر Google عند غياب Web Client ID وتبقي البحث والمصادقة بالهاتف والبريد متاحة؛ لا يجوز أن يؤدي تعطل مزود اختياري إلى منع runApp() أو إبقاء شاشة البدء فوق التطبيق.

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

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

تملك FavoriteRoutesService حالة المسارات المفضلة في تطبيق العميل. تبقى مسارات الضيف غير المتزامنة في Hive وقابلة للاستخدام دون اتصال. بعد تسجيل الدخول يقرأ التطبيق هوية الحساب القانونية، يرسل مسارات الضيف إلى /api/favorites بصورة idempotent، ثم يستبدل ذاكرة ذلك الحساب فقط بإجابة الخادم القانونية. تحمل كل خريطة مخزنة ownerId حتى لا تظهر مفضلات مسافر لمسافر آخر على جهاز مشترك. عمليات الحساب server-first: لا يحذف التطبيق أو يضيف محلياً إذا رفض الخادم العملية. أما فشل التحديث فيبقي آخر نسخة صحيحة ظاهرة مع حالة cached/offline وزر إعادة محاولة، ولا يتحول إلى نجاح فارغ. يرفض نموذج FavoriteRoute استجابة API إذا اختلف معرف المدينة المتداخلة عن معرف العلاقة.

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

عقود الشبكة

ينفذ تطبيق العميل العمليات التجارية عبر ApiService باتجاه مسارات Next.js. تُرجع الدوال العامة كائناً من النوع Map<String, dynamic> بعد التحقق من أن جسم الاستجابة كائن JSON:

  • الاستجابة الناجحة الفارغة تُطبع إلى {}.
  • جسم JSON غير الكائني ينتج خطأ INVALID_RESPONSE بدلاً من تمرير قيمة dynamic إلى الشاشة.
  • تنسّق الخدمة تحديث رمز الجلسة بحيث ينتظر كل الطلبات تحديثاً واحداً جارياً.
  • إعادة المحاولة محصورة بأخطاء الشبكة والمهلات وأخطاء الخادم القابلة للمحاولة.

تبقى Supabase Auth والاشتراكات الحية قنوات مباشرة مشروعة. راجع أنماط API للتطبيقات للحد الفاصل بين REST والاتصال المباشر.

يُعرّف apps/customer/src/lib/cors-policy.ts عقد CORS الواحد لطلبات تطبيقات Flutter Web. يستخدمه كل من proxy ومسارات API، ويشمل X-ShamBus-Client و Idempotency-Key إلى جانب ترويسات المصادقة وCSRF. لا تضف ترويسة إلى ApiService من دون إضافتها إلى هذا العقد المشترك واختبار طلب OPTIONS من أصل الموبايل؛ اختلاف allow-list بين proxy وroute handler يمنع المتصفح قبل وصول الطلب إلى منطق المنتج.

يعد /chat/:tripId مساراً محمياً في تطبيق العميل. كما يعيد endpoint الرسائل 401 قبل استعلام قاعدة البيانات عندما لا توجد جلسة، كي لا يتحول رفض RLS إلى 500. أما رابط /trips القديم فيحتاج كائن بحث حي؛ عند فتحه مباشرةً أو تحديثه من دون مدينتي الانطلاق والوصول يعيد GoRouter المستخدم إلى الصفحة الرئيسية بدلاً من إرسال بحث فارغ ينتج 400.

هوية موقع البحث في تطبيق العميل

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

يحمل JourneySearchRouteState معرف الموقع ونوعه لكل طرف ويكتب fromType و toType في الرابط. ترسل ApiService.searchJourneys حقلاً واحداً فقط لكل طرف: *_city_id أو *_stop_id. تقبل الروابط القديمة من دون نوع كمدينة فقط. تحفظ خدمة سجل البحث الاختيار الدقيق وتهاجر خرائط Hive القديمة التي كانت تحمل معرفي المدينتين؛ لذلك لا تتحول محطة مختارة إلى بحث أوسع عند فتحها من السجل.

أُزيل مسار /city-selection والشاشة القديمة بعد انتقال شاشة البحث إلى نافذة المواقع الموحدة، حتى لا يبقى تنفيذان متنافسان. خلال النشر المتدرج فقط، يرجع getJourneyLocations إلى /cities إذا لم يتوفر endpoint الجديد، مع بقاء السلوك المتراجع بحث مدينة صريحاً.

اختيار رحلة السائق

تعرض شاشة السائق الرئيسية رحلات يوم الجهاز المحلي أولاً، مع أولوية للرحلة النشطة ثم أقرب رحلة بحالة ASSIGNED. تُحوّل أوقات المغادرة القادمة من قاعدة البيانات إلى المنطقة الزمنية المحلية قبل مقارنة اليوم، حتى لا تختفي رحلة قرب منتصف الليل بسبب اختلاف UTC.

إذا لم توجد رحلة اليوم، تعرض الشاشة أقرب رحلة مستقبلية مكلّف بها السائق مع تنبيه واضح بدلاً من حالة فارغة. تُستبعد الرحلات COMPLETED وCANCELLED وNO_SHOW. يضمن ذلك بقاء تفاصيل الرحلة وكشف الركاب والمسح والتحصيل قابلة للوصول، بما في ذلك العروض التجريبية المنشأة بعد آخر موعد مغادرة يومي.

تستخدم إجراءات بطاقة الرحلة أسماء GoRouter المعرفة scan، manifest، وmap مع assignmentId؛ لا تنشئ مسارات عامة مثل /scanner أو /map لأن الشاشتين تحتاجان سياق التكليف لتطبيق RLS وتحميل الرحلة.

تمر حمولات GoRouterState.extra عبر normalizeRouteExtra قبل الاستخدام. يسمح المطبّع بخرائط مفاتيحها نصية فقط وينسخ Map<dynamic, dynamic> الذي قد يعيده Flutter Web إلى العقد المكتوب. لا تستخدم state.extra as Map<String, dynamic> مباشرةً؛ هذا ينجح في VM وقد يفشل في حزمة الويب المصغرة.

تُحل هوية السائق في قاعدة البيانات أولاً عبر drivers.auth_user_id = auth.uid()، وهو الربط القياسي للحسابات الحالية وحسابات العرض. يبقى الرجوع عبر company_users.auth_user_id ثم drivers.company_user_id للتوافق مع الحسابات القديمة فقط. تستخدم الشاشات وخدمة التنبيهات محللاً مركزياً واحداً لهذا العقد، ولا يجوز مقارنة Auth UUID مباشرةً مع company_user_id.

ينزّل السائق رموز التذاكر للتحقق دون اتصال عبر get_trip_qr_tokens. تعيد الدالة رقم المقعد كعدد صحيح مطابق لعقد Hive، وتحوّل قيمة bookings.seat_number النصية تحويلاً آمناً مع رجوع إلى أول قيمة في seat_numbers. لأنها SECURITY DEFINER، تتحقق أيضاً أن auth.uid() يمثل p_driver_id وأن السائق مكلّف بالرحلة؛ لا يكفي تمرير UUID لسائق آخر.

يسجل تحصيل السائق، سواء كان فورياً أو من طابور العمل دون اتصال، عبر RPC واحدة هي collect_trip_payment. تقفل الدالة التكليف والحجز، وتتحقق من هوية السائق وانتماء الحجز للرحلة والمبلغ المتبقي وصلاحية العرض التجريبي، ثم تنشئ trip_payments وتحدّث حالة الحجز وإجمالي التكليف داخل معاملة واحدة. لكل حجز سجل تحصيل سائق واحد؛ إعادة العملية تعيد السجل الموجود ولا تزيد الإجمالي مرة أخرى. لا يملك تطبيق السائق امتياز كتابة مباشر على جدول المدفوعات، ولا يستخدم increment_assignment_collected.

ينشئ التطبيق إيصالاً محلياً بعد نجاح RPC في الوضع المتصل. في الوضع غير المتصل يحفظ الإيصال بحالة pending ويرسل نفس العملية عبر RPC عند عودة الشبكة، ثم يعلّم الإيصال synced. يجب ألا تعتبر الواجهة الحجز مدفوعاً عند فشل الطلب، وألا تعرض رسالة PostgREST الخام للمستخدم.

تصل اشتراكات محادثة الرحلة إلى Realtime عبر المسار المحمي /realtime/v1/ في Kong، الذي يحافظ على apikey أثناء ترقية WebSocket ويقصر الاتصال على مجموعتي anon وadmin المعروفتين. يشغّل حاوي Realtime ترحيلاته وبذرة المستأجر الذاتي بصورة idempotent عند البدء، ويصل Kong إليه باسم realtime-dev.supabase-realtime حتى يطابق المستأجر المزروع. لا تفتح منفذ Realtime الداخلي مباشرةً للإنترنت.

تستخدم محادثة السائق معرف drivers.id الذي يعيده محلل الهوية، لا Auth UUID. تتحقق سياسات trip_messages من is_own_driver والتكليف، ولا تمنح دور المتصفح سوى تعديل is_read؛ محتوى الرسالة غير قابل للإعادة بعد الإرسال، ودور anon لا يملك أي امتياز على الجدول.

تستخدم نسختا Flutter Web نقطة بدء مخصصة تضيف رقم البناء إلى main.dart.js، وتزيل تسجيلات ومخابئ service worker القديمة الخاصة بـ Flutter قبل تحميل الحزمة مباشرةً. تبقى ملفات shell والحزمة الأساسية تحت no-cache في Nginx، بينما تبقى الأصول ذات المحتوى الثابت قابلة للتخزين الطويل. يمنع ذلك اجتماع HTML جديد مع حزمة قديمة أو توقف loader خلف worker منتظر بعد النشر.

يعرض تطبيق السائق DriverBootstrapGate كأول frame قبل تهيئة أي مخزن أو إضافة منصة. تقتصر البوابة الحرجة على Hive وSupabase مع مهلة زمنية وحالة خطأ عربية قابلة لإعادة المحاولة؛ وتبدأ خدمات الاتصال والمزامنة والبيانات غير المتصلة والموقع والإشعارات بعد ذلك بصورة recoverable لا تحجب الواجهة. لذلك لا تستطيع إضافة متصفح بطيئة أو غير مدعومة أن تترك صفحة بيضاء بلا مسار تعافٍ.

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

يملك تطبيق السائق حداً واحداً مكتوباً لبروتوكول الدخول هو DriverAuthenticationGateway. يتولى مسارات password challenge وTOTP وتهيئة TOTP وتبادل demo capability، ويتحقق من JSON والجلسة ويصنف المهلة وأخطاء الاتصال. لا تضيف الشاشات طلبات http.post مستقلة لهذه العمليات.

يعيد حد كلمة المرور نتيجة sealed: إما DriverPasswordChallenge لحساب شركة تشغيلية، أو DriverDemoSession لشخصية تنتمي إلى شركة عرض نشطة وغير منتهية فقط. في الحالة الثانية يربط التطبيق refresh token عبر Supabase ثم يشترك في الإشعارات قبل فتح مساحة السائق. لا يستطيع العميل طلب هذا الاستثناء أو استنتاجه؛ الخادم وحده يصنف الشركة، وتبقى كل الحسابات غير التجريبية خلف TOTP الإلزامي.

يطبق driver_route_policy.dart مفهوم المسار العام بحدود segment صحيحة؛ لا يعد /login-malicious مساراً عاماً. كما يرفض فتح /two-factor أو /two-factor-setup بدون challengeId من تدفق كلمة المرور. يستخدم TOTP والإعداد حقولاً مشتركة LTR تطبّع الأرقام العربية والفارسية إلى Latin، ويبقى الرمز الاحتياطي متوافقاً مع XXXX-XXXX.

تحتفظ نسخة الويب بمفتاح تشفير Hive في flutter_secure_storage المبني على Web Crypto ضمن الأصل الآمن، وتستخدم namespace إصدارياً يبدأ بـ shambus_driver_web_v2_. أما iOS وAndroid فيحتفظان بأسماء الصناديق الأصلية وبمخازن مفاتيح النظام كي لا تفقد التطبيقات المثبتة طوابيرها غير المتصلة عند الترقية. لا تحذف نسخة الويب قواعد IndexedDB عند كل إعادة تحميل؛ إعادة استخدام المفتاح المشفر تمنع تعارض المفتاح من دون عملية حذف قد تبقى محجوبة من اتصال متصفح أقدم.

يطبق تطبيق العميل العقد نفسه عبر مفتاح Web Crypto دائم وnamespace يبدأ بـ shambus_customer_web_v2_، مع إبقاء أسماء صناديق iOS وAndroid القديمة دون تغيير. تُفتح صناديق العميل المستقلة بالتوازي بعد حل مفتاح واحد، وتعرض نقطة بدء الويب سطحاً عربياً مملوكاً للتطبيق حتى حدث flutter-first-frame. يمنع ذلك حذف قواعد IndexedDB بالتتابع عند كل تحديث، ويزيل الصفحة البيضاء التي كانت تظهر للمستخدم العائد أثناء التخلص من صناديق مشفرة بمفتاح جلسة قديم.

تحقق المتصفح المحلي

لا ينجح route-flow لمجرد تحميل index.html. ينتظر مشغل Playwright اختفاء #shambus-bootstrap وظهور مضيف أول frame من Flutter قبل فحص أخطاء الصفحة والـconsole وطلبات HTTP. تُشغّل هذه البوابة على profile-mode محلياً لأن محمّل وحدات DDC المؤجلة في debug قد يتوقف داخل سياق متصفح جديد ولا يمثل حزمة المستخدم.

في 2026-08-23 نجحت كل مسارات GoRouter المعلنة في متصفح Chromium جديد: 28/28 لتطبيق العميل و14/14 لتطبيق السائق. كما نجح flutter analyze للتطبيقين، ونجحت اختبارات العميل 1461/1461 واختبارات السائق 598/598. يثبت ذلك first frame والتوجيه وعدم وجود أخطاء متصفح أو HTTP غير متوقعة في هذه المسارات محلياً؛ لا يثبت إضافات iOS/Android أو الأذونات أو الأداء على جهاز ضعيف أو العمل دون اتصال على جهاز حقيقي، ولا يحل محل بناء release ونشره وفحصه.

التخزين الآمن

تستخدم النسخة الحالية flutter_secure_storage 10.3.1. يحافظ تطبيق العميل على اسم ملف Android وبادئة المفاتيح القديمة أثناء ترحيل بيانات v9 إلى AES-GCM حتى لا يفقد المستخدمون جلساتهم عند الترقية، بينما يقتصر namespace الإصدار الجديد على الويب. لا تغيّر أسماء صناديق native أو تحذف خيارات الترحيل في الإصدار نفسه؛ يجب أن يكون الانتقال اللاحق إصداراً مرحلياً ومختبراً على تثبيت مُرقّى، لا تثبيت جديد فقط.

إعداد البيئة

لا تُكتب مفاتيح الإنتاج في Dart. مرّرها باستخدام --dart-define:

المتغيرالتطبيقانملاحظات
SUPABASE_URLنعميجب ألا يشير إلى localhost في release
SUPABASE_ANON_KEYنعممفتاح العميل القابل للنشر
WEB_API_URLنعمأصل بوابة Next.js
CERTIFICATE_PINSعند تفعيل التثبيتبصمات SHA-256 مفصولة بفواصل
NTFY_URLاختياريخادم الإشعارات الذاتي
GOOGLE_MAPS_API_KEYاختياريمفتاح مقيّد بالمنصة للخريطة المضمنة
IS_DEVELOPMENTتطوير فقطافتراضياً false
DEV_BYPASS_OTPالسائق، تطوير فقطافتراضياً فارغ ولا يعمل دون IS_DEVELOPMENT=true

يتحقق EnvConfig.assertConfiguredForRelease عند بدء نسخة release ويوقف التطبيق مبكراً إذا كانت القيم المطلوبة مفقودة أو تشير إلى localhost. كما يرفض تطبيق السائق release مبنياً مع IS_DEVELOPMENT=true.

الخريطة المضمنة ليست اعتماداً حرجاً. إذا كان GOOGLE_MAPS_API_KEY فارغاً، لا ينشئ التطبيق GoogleMap: يستمر السائق بإرسال GPS، ويستمر العميل باستقبال الموقع والحالة وETA، ويعرض التطبيقان إجراءً واضحاً لفتح الخرائط الخارجية. تستخدم صور Web مفتاح GOOGLE_MAPS_API_KEY_WEB وتحقنه في index.html وقت البناء فقط عند وجوده؛ تستخدم البنايات الأصلية مفتاح Android أو iOS المقيدين بدلاً منه.

يجلب تتبع العميل snapshot محدوداً بكود الحجز كل 10 ثوانٍ عبر get_public_trip_tracking؛ لا يمنح anon قراءة مباشرة للحجوزات أو GPS ولا يحتاج إلى اشتراك Realtime لا يستطيع RLS تفويضه بكود URL. يحتفظ العميل بآخر snapshot عند فشل عابر ويُظهر عمر الموقع، بينما تحجب قاعدة البيانات سجل GPS خارج نافذة الرحلة.

يسجل التطبيقان إشعارات ntfy عبر POST /api/push/subscribe بعد المصادقة. تعتمد القناة على وجود ntfy_topic لا على device_type، لذلك تستخدم نسخة Flutter Web ntfy بصورة صحيحة من دون مفاتيح VAPID. تربط API الموضوع بهوية الجلسة وتحوّل هوية السائق إلى company_user_id قبل الكتابة في push_subscriptions. التسجيل idempotent على push_token، ويجمع التطبيق الطلبات المتزامنة في محاولة واحدة؛ لا تبدأ polling أو WebSocket إلا بعد استجابة نجاح. عند تسجيل الخروج ينتظر إكمال أي محاولة جارية ثم يعطل الاشتراك المملوك للمستخدم نفسه.

تستخدم نسختا Flutter Web استراتيجية المسار (usePathUrlStrategy) مع fallback خادم Nginx إلى index.html. لذلك تعمل الروابط النظيفة مثل /track/:bookingCode و/trip/:assignmentId/map عند فتحها مباشرة أو تحديث المتصفح، ولا تعتمد الروابط المشتركة على صيغة /#/ القديمة. يلتقط تطبيق السائق المسار قبل بوابة bootstrap غير المتزامنة ثم يمرره إلى GoRouter؛ يمنع ذلك استبدال رابط التكليف بـ/splash أثناء تجهيز Hive وSupabase.

مثال تطوير محلي للسائق مع OTP تجريبي صريح:

cd apps/mobile/driver
flutter run --flavor dev \
--dart-define=IS_DEVELOPMENT=true \
--dart-define=DEV_BYPASS_OTP=123456

أوامر التطوير والتحقق

نفّذ الأوامر لكل تطبيق على حدة:

cd apps/mobile/customer # أو apps/mobile/driver
flutter pub get
dart format --output=none --set-exit-if-changed lib test integration_test
flutter analyze
flutter test

قد لا يحتوي أحد التطبيقين على كل المجلدات المذكورة في أمر التنسيق؛ احذف المجلد غير الموجود من الأمر عند التشغيل اليدوي.

Android

لأن التطبيقين يعرّفان نكهتي dev وprod، حدّد النكهة دائماً:

flutter build apk --debug --flavor prod

flutter build appbundle --release --flavor prod \
--dart-define=SUPABASE_URL=https://example.supabase.co \
--dart-define=SUPABASE_ANON_KEY=... \
--dart-define=WEB_API_URL=https://web-api.shambus.com \
--dart-define=GOOGLE_OAUTH_WEB_CLIENT_ID=...apps.googleusercontent.com \
--dart-define=GOOGLE_MAPS_API_KEY=...

يتطلب بناء Android JDK 17. إذا كان JDK النظام مختلفاً، عيّن JAVA_HOME إلى تثبيت Java 17 قبل البناء. يجب تسجيل package sy.shambus.app مع SHA-1 لمفتاح Play/release في Android OAuth، وإضافة SHA-256 حيث يطلبها تقييد Maps أو خدمات Google الأخرى؛ بصمة debug لا تكفي للإنتاج. يفشل سكربت النشر إذا غاب android/key.properties أو خرج artifact من نكهة غير prod. ويفشل Gradle نفسه لأي مهمة release عند غياب المفتاح؛ لا يعود إلى debug signing بصمت. يمكن استخدام prodDebug للتحقق المحلي من compilation فقط، لكنه ليس artifact قابلاً للنشر.

iOS

يتطلب إصدار العميل GOOGLE_OAUTH_WEB_CLIENT_ID وGOOGLE_OAUTH_IOS_CLIENT_ID، ويولد النشر ملف GoogleAuth.xcconfig المؤقت من GOOGLE_OAUTH_IOS_REVERSED_CLIENT_ID. يجب أن يكون iOS client مسجلاً للحزمة sy.shambus.app. يفشل التطبيق مبكراً في release إذا غاب Web أو iOS client ID، بدلاً من عرض زر Google لا يمكنه العمل.

Web

flutter build web --release \
--dart-define=SUPABASE_URL=https://example.supabase.co \
--dart-define=SUPABASE_ANON_KEY=... \
--dart-define=WEB_API_URL=https://web-api.shambus.com \
--dart-define=GOOGLE_OAUTH_WEB_CLIENT_ID=...apps.googleusercontent.com \
--dart-define=GOOGLE_MAPS_API_KEY=...

يعرض Flutter Web/PWA زر Google Identity Services الرسمي من حزمة google_sign_in_web. يحصل أولاً على flowId وnonce من Customer API عبر اتصال cookies محدود بأصل app.shambus.com، ثم يرسل ID token إلى POST /api/auth/customer/google/pwa. يتحقق الخادم من nonce والتوقيع والجمهور والبريد الموثق قبل إعادة جلسة التطبيق. يتطلب Web OAuth client أصلاً مصرحاً به لـhttps://app.shambus.com؛ لا يعمل authenticate() الأصلي داخل Flutter Web ولا يجوز استبدال الزر الرسمي بنافذة ويب مضمّنة.

يستخدم التطبيقان ملف flutter_bootstrap.js مخصصاً ومدعوماً من Flutter. يُضمّن bootstrap داخل index.html غير المخزن مؤقتاً، ويضيف رقم البناء المولّد إلى main.dart.js. لا يجوز إعادة تطبيق immutable على index.html أو main.dart.js أو ملفات bootstrap وmanifest؛ تبقى سياسة السنة الواحدة للأصول الثابتة الأخرى فقط. يمنع هذا مزج shell قديم مع إصدار جديد وظهور صفحة بيضاء بعد النشر. يحمّل shell خط Cairo المضمّن قبل واجهة Flutter، ويطبق Nginx في صورة الإنتاج CSP وأذونات متوافقة مع Google Identity Services بدلاً من السماح المفتوح للمصادر.

عند البناء عبر Docker لا تعدّل index.html يدوياً: يتحقق Dockerfile من محارف المفتاح ويضيف Maps JavaScript SDK فقط عندما يكون المفتاح غير فارغ. يجب أن يطابق وجود السكربت قيمة --dart-define حتى لا تحاول الواجهة إنشاء خريطة دون SDK.

يفحص Flutter أيضاً توافق Wasm أثناء البناء. ظهور تحذير عن خط مفقود يعني عادة أن الحزمة المالكة للخط حُذفت من pubspec.yaml؛ يجب إصلاحه قبل التسليم.

قائمة تحقق الترقية

عند ترقية Flutter أو حزمة أصلية:

  1. اقرأ دليل الترحيل للحزمة وحدّث API قبل تشغيل التنسيق الآلي.
  2. شغّل flutter pub outdated ثم flutter pub get لكل تطبيق.
  3. شغّل analyzer والاختبارات الكاملة، لا اختبارات الملف المتغير فقط.
  4. ابنِ APK لنكهة prod ونسخة Web release لكل تطبيق.
  5. اختبر ترقية التخزين الآمن والإشعارات والصلاحيات على تثبيت قديم عند تغيّر الحزم الأصلية.
  6. حدّث هذه الصفحة وسجل التغييرات عند أي تغيير معماري أو تغيير في متطلبات البناء.

استكشاف أخطاء شائعة

Gradle يستخدم Java غير مدعومة

أوقف daemon القديم ثم أعد البناء باستخدام Java 17:

cd android
./gradlew --stop
cd ..
flutter build apk --debug --flavor prod

الاتصال بـ localhost من المحاكي

  • Android Emulator: استخدم 10.0.2.2 بدلاً من localhost.
  • iOS Simulator: يمكن استخدام localhost أو 127.0.0.1.
  • الجهاز الحقيقي: استخدم عنوان الجهاز المضيف على الشبكة المحلية مع ضبط سياسات الشبكة المناسبة.