تطبيقات 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_riverpod | 3.0.x | الحالة والاعتماديات باستخدام NotifierProvider |
go_router | 17.4.x | التنقل وحواجز المصادقة |
supabase_flutter | 2.17.x | المصادقة والاشتراكات والعمليات المباشرة المسموح بها |
flutter_secure_storage | 10.3.x | الرموز والبيانات الحساسة |
connectivity_plus | 7.3.x | رصد الاتصال وتشغيل المزامنة |
geolocator | 14.0.x | الموقع والتتبع |
flutter_local_notifications | 20.1.x | الإشعارات المحلية |
mobile_scanner | 7.4.x | مسح التذاكر في تطبيق السائق |
google_sign_in | 7.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 أو حزمة أصلية:
- اقرأ دليل الترحيل للحزمة وحدّث API قبل تشغيل التنسيق الآلي.
- شغّل
flutter pub outdatedثمflutter pub getلكل تطبيق. - شغّل analyzer والاختبارات الكاملة، لا اختبارات الملف المتغير فقط.
- ابنِ APK لنكهة
prodونسخة Web release لكل تطبيق. - اختبر ترقية التخزين الآمن والإشعارات والصلاحيات على تثبيت قديم عند تغيّر الحزم الأصلية.
- حدّث هذه الصفحة وسجل التغييرات عند أي تغيير معماري أو تغيير في متطلبات البناء.
استكشاف أخطاء شائعة
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. - الجهاز الحقيقي: استخدم عنوان الجهاز المضيف على الشبكة المحلية مع ضبط سياسات الشبكة المناسبة.