17 KiB
23 — قانون تطبيقات فلاتر (الراكب والسائق)
هذا المستند مُلزِم. كل كود Dart في
apps/riderوapps/driverيتبعه. أي انحراف يُصحَّح لا يُبرَّر. اقرأه كاملاً قبل كتابة أول سطر في أي جلسة جديدة — هو العقد الذي يجعل جلستين مختلفتين تُنتجان نفس البنية.
آخر تحديث: 2026-07-19. ذو صلة: 22 §1.5 (الطبقات) · 19 (الاستحقاقات) · 26 (نظام التصميم الثابت — قانون الشكل المكمّل لهذا القانون) · tripz-mobile-store-identity.
0. المبادئ الخمسة (إن تعارض شيء معها فهي تُرجَّح)
- التطبيقان توأمان في البنية، مختلفان في الميزات. نفس المجلدات ونفس التسميات ونفس طبقة الشبكة. من يعرف الراكب يعرف السائق فوراً.
- السيرفر هو مصدر الحقيقة. التطبيق يعرض ما يُعطى. لا منطق تسعير ولا حساب عمولة ولا قرار مطابقة في Dart — أبداً.
- الحالة صريحة، لا سحر. Cubit افتراضاً، Bloc عند تعدّد الأحداث. لا GetX ولا متغيّرات عامة قابلة للتغيير.
- الميزة تُقفل من نقطة واحدة (تسجيل الموديول)، لا بـ
ifمبعثرة في الشاشات. - الأصيل مُعاد استعماله لا مُعاد بناؤه، وهو موحّد عبر كل المستأجرين (شرط Shorebird).
1. بنية المجلدات — مُلزِمة حرفياً
apps/<rider|driver>/lib/
├── main.dart # نقطة الدخول: تهيئة ثم runApp — لا منطق
├── app.dart # MaterialApp + الثيم + الراوتر + المزوّدون العامّون
├── core/
│ ├── build_config.dart # ⚠️ مُولَّد — لا يُحرَّر (N3)
│ ├── config.dart # إعداد وقت التشغيل (--dart-define)
│ ├── di.dart # get_it: تسجيل التبعيات
│ ├── router.dart # go_router: كل المسارات
│ ├── design/ # نظام التصميم (docs/26): tokens · typography · tripz_colors · theme
│ ├── ui/ # عدة المكونات المشتركة (docs/26 §7): TripzScaffold · TripzButton …
│ ├── l10n/ # ملفات ARB عربي/إنجليزي — كل نص مرئي يمرّ هنا
│ ├── api/
│ │ ├── api_client.dart # Dio + الاعتراضات (توكن · مستأجر · توقيع · جهاز)
│ │ └── api_exception.dart
│ └── storage/
│ └── token_store.dart # تخزين آمن للتوكنات
└── features/<feature>/
├── cubit/ # <feature>_cubit.dart + <feature>_state.dart
├── data/ # <feature>_repository.dart + models/
└── view/ # الشاشات + widgets/
قواعد لا تُخالَف:
- الميزة لا تستورد من ميزة أخرى. التشارك يصعد إلى
core/أو إلى موديول مشترك. استيرادfeatures/trip/…من داخلfeatures/auth/…ممنوع. view/لا يستوردdata/مباشرة — يمرّ بالـCubit دائماً.- ملف واحد = صنف عام واحد.
- لا مجلد
utils/عام (مقبرة الشيفرة). الدالة تعيش قرب ما تخدمه.
2. التسمية
| العنصر | القاعدة | مثال |
|---|---|---|
| ملفات | snake_case.dart |
trip_repository.dart |
| أصناف | PascalCase |
TripCubit |
| الميزة (مجلد) | مفرد، إنجليزي | features/trip/ |
| الحالة | <Feature>State بحقل status |
TripState |
| الاستحقاق | Features.<camelCase> |
Features.marketIntel |
النصوص المرئية للمستخدم عربية؛ أسماء الرموز والملفات إنجليزية. لا نصّ عربي مكتوب داخل widget مباشرة — يمرّ بطبقة الترجمة.
3. إدارة الحالة
Cubit افتراضاً. Bloc فقط حين تتعدّد الأحداث الواردة على نفس الحالة (مثل شاشة الرحلة الحيّة: سوكت + موقع + إجراء المستخدم معاً).
قواعد:
- الحالة غير قابلة للتغيير (
copyWith)، وتحملstatusمنenum— لاbool isLoadingمتناثرة. - الـCubit لا يعرف Flutter: لا
BuildContext، لاSnackBar، لا تنقّل. يُصدر حالة، والواجهة تتصرّف. - الـCubit لا ينادي
Dioمباشرة — يمرّ بـRepositoryدائماً. - خطأ الشبكة يتحوّل إلى رسالة عربية داخل الـCubit، والواجهة تعرض
state.errorكما هو.
// النمط المعتمد
enum TripStatus { idle, loading, active, error }
class TripState {
final TripStatus status;
final Trip? trip;
final String? error;
const TripState({this.status = TripStatus.idle, this.trip, this.error});
TripState copyWith({...}) => ...;
}
4. طبقة الشبكة — نقطة واحدة إجبارياً
كل نداء يمرّ بـcore/api/api_client.dart. لا http.get في أي مكان آخر.
الاعتراضات المطلوبة، بهذا الترتيب:
x-tenant-id=BuildConfig.tenantSlug.Authorization: BearerمنTokenStore.x-device-id— بصمة الجهاز (يفرضها السيرفر عندAUTH_REQUIRE_DEVICE_BINDING=true، docs/17 D2).- توقيع HMAC للنقاط المالية (docs/17 I6) — يُحسب على الجسم كما يُرسَل بلا إعادة تسلسل، وإلا فشل توقيع سليم.
- تجديد التوكن عند 401 مرة واحدة ثم تسجيل خروج — لا حلقة تجديد لانهائية.
⚠️ التوقيع وربط الجهاز مطفآن على السيرفر الآن ويُفعَّلان بعد أن يرسلهما التطبيق. رتّب: أرسل أولاً، ثم فعّل السيرفر.
التخزين: التوكنات في flutter_secure_storage حصراً — لا SharedPreferences للتوكن.
5. أعلام الميزات (القلب — راجع docs/22 §1.5)
// ✅ الصحيح — const، يُحذف الكود من الـbinary
if (Features.marketIntel) { ... }
// ❌ ممنوع — يُبقي كود الميزة في الملف فيمكن استخراجه
if (featureMap['market_intel'] == true) { ... }
التسجيل المشروط هو القاعدة، والفحص المتناثر استثناء:
// core/di.dart — نقطة الإقفال الوحيدة
if (Features.chat) {
getIt.registerLazySingleton<ChatRepository>(() => ChatRepository(getIt()));
}
// core/router.dart
if (Features.chat) routes.add(GoRoute(path: '/chat', builder: ...));
القاعدة الحاكمة: الفعّال = BuildConfig ∩ المانيفست الريموت ∩ استحقاقات السيرفر.
التطبيق لا يمنح صلاحية — يخفي فقط. من فكّك التطبيق وفعّل كل شيء يصطدم بـ403 من FeatureGuard.
6. الخرائط — intaleq_maps حصراً
القرار (المالك، 2026-07-18، مصحَّح): تُستعمل حزمة intaleq_maps (SDK انطلق، إصدار 2.2.0، مبنية على MapLibre GL) — وهي حزمة المالك نفسه ومنشورة على pub.dev.
⚠️ تصحيح قرار سابق: كان مكتوباً «
flutter_map+ بلاطات انطلق». هذا خطأ:flutter_mapوlatlong2طبقة منافسة تُعرّفLatLngخاصاً بها، فيجتمع في المشروع نوعان بنفس الاسم.intaleq_mapsتُصدِّرLatLngوMarkerوPolylineوCameraUpdateبنفسها كبديل drop-in لـgoogle_maps_flutter. لا يُضافflutter_mapولاlatlong2إطلاقاً.
لماذا انطلق لا جوجل (تصحيح فهم شائع): خرائط جوجل تعمل في سوريا كـAPI وكرندر؛ غير العامل جزئياً هو تطبيق جوجل الرسمي — وهذا لا يعنينا لأن التوجيه داخل تطبيقنا. السبب الحقيقي أن بيانات جوجل في المنطقة قديمة (لم تُحدَّث منذ 2011)، بينما انطلق مُحدَّثة وبمستوى تجاري وأدقّ ميدانياً.
الخريطة كواجهة تأتي من الحزمة؛ ويبقى ما هو سيرفري على حاله: الجيوكودنغ والمسارات والأماكن تمرّ عبر سيرفرنا (يحمي المفتاح ويكيّش ويقيس الحصص لكل مستأجر).
مفتاح الخريطة يأتي من البيئة/إعداد المستأجر — لا يُكتب في الكود ولا في المستودع.
7. ما يُعاد استعماله من التطبيق القديم — لا يُعاد بناؤه
| المكوّن | لماذا |
|---|---|
android/ · ios/ |
تحمل هوية النشر والصلاحيات والتوقيع |
secure_string_operations · trip_overlay_plugin · bubble-master |
إضافات أصيلة مكتوبة ومختبَرة |
firebase.json · firebase_options.dart · google-services.json |
مشروع FCM القائم |
shorebird.yaml |
app_id منشور — تغييره يقطع تحديثات OTA عن المستخدمين الحاليين |
key/ |
شهادات توقيع — فقدانها يعني تعذّر تحديث التطبيق المنشور نهائياً |
| نُسخ (2026-07-19): الخطوط الجديدة الثابتة Inter + IBM Plex Sans Arabic — القرار وأسبابه في 26 §1. الملفات القديمة تبقى حتى يؤكد المالك حذفها |
معرّفات المتجر في tripz-mobile-store-identity — تُنسخ ولا تُكتب من الذاكرة.
⚠️ معرّفا السائق متضاربان بين المنصّتين (com.sefer_driver مقابل com.sefer.driver) — يؤكّدهما المالك قبل أي إصدار.
8. الطبقة الأصيلة موحّدة
لا تُفرَّع android/ أو ios/ بين المستأجرين أو الطبقات. المتغيّر الوحيد المسموح آلياً: applicationId · PRODUCT_BUNDLE_IDENTIFIER · android:label · الأيقونات — وكلها يضبطها scripts/generate-tenant-app.sh.
السبب: Shorebird يرقّع Dart فقط. أصيلٌ موحّد ⇒ ترقية الباقة patch فوري. أصيلٌ متفرّع ⇒ كل ترقية إصدار متجر ومراجعة.
9. الحزم المعتمدة
| الغرض | الحزمة |
|---|---|
| الحالة | flutter_bloc |
| الحقن | get_it |
| الشبكة | dio |
| التنقّل | go_router |
| الخرائط | intaleq_maps (لا flutter_map ولا latlong2 ولا google_maps_flutter) |
| التخزين الآمن | flutter_secure_storage |
| الزمن الحقيقي | socket_io_client |
| الإشعارات | firebase_messaging + flutter_local_notifications |
إدخال حزمة خارج هذا الجدول قرارٌ يُوثَّق هنا أولاً. تُثبَّت أحدث الإصدارات المستقرّة، ويُشغَّل flutter pub outdated قبل كل مجموعة عمل.
10. الفروق المشروعة بين التطبيقين
| الراكب | السائق | |
|---|---|---|
| الميزة المحورية | طلب رحلة وتتبّعها | استقبال العروض وتنفيذها |
| الموقع | عند الاستعمال | خلفية دائمة + متحكّم مواقع |
| الأصيل الخاص | — | overlay العرض · bubble · حيوية الوجه |
| المال | الدفع | المحفظة · الرصيد · السحب |
متحكّم موقع السائق (نمط سيرو، مثبت عملياً): تسجيل كل ٣ ثوانٍ · رفع دفعات كل دقيقتين · وعيٌ بالبطارية (تخفيف عند ٢٠٪) · سوكت للبثّ الحي ودفعات للحفظ الدائم. لا تُعِد اختراعه.
11. قواعد العمل اليومية
- لا بناء ولا اختبار على الماك — يُشغّلهما المالك (راجع no-build-or-test-on-mac). الوكيل يكتب فقط.
- التعليقات تشرح لماذا لا ماذا، وبالعربية.
- ملف
build_config.dartمُولَّد: تعديله يُمحى عند أول توليد. - كل ميزة جديدة تبدأ بسؤال: هل لها علم استحقاق؟ إن كانت مدفوعة فالعلم أولاً، ثم الكود.
12. قائمة تحقّق قبل اعتبار ميزة «منجزة»
- الحالة عبر Cubit/Bloc، غير قابلة للتغيير، بـ
statusمن enum - الـCubit بلا
BuildContextوبلاDioمباشر - كل النداءات عبر
ApiClient - الميزة المدفوعة محروسة بـ
Features.xوبتسجيل مشروط - النصوص عربية وعبر طبقة الترجمة
- لا استيراد متقاطع بين الميزات
- السيرفر يفرض ما يخفيه التطبيق (لا حماية بالواجهة وحدها)
13. الاختبار على بناء release لا debug ⚠️
حذف كود الميزة المقفلة يحدث في بناء release/AOT فقط. في debug (JIT) يبقى الكود كله موجوداً ولو كان العلم const false.
فالتحقّق من أن نسخة lite لا تحتوي فعلاً على كود النسخ الأعلى لا يصحّ إلا على بناء release — وأي فحص على debug يعطي نتيجة مضلِّلة. القياس المعتمد: بناء release لكل طبقة ومقارنة الحجم + تفتيش الرموز.
14. مؤجَّل بقرار المالك (2026-07-18)
- Android Auto (موجود في سيرو:
MyCarSession·MyCarScreen·MyCarAppService) وCarPlay — عليهما مشاكل ولا أولوية لهما الآن. يُنظر فيهما لاحقاً.
15. القناة المزدوجة للزمن الحقيقي — قرار المالك 2026-07-19 🔒
السيرفر يرسل كل انتقال حالة مرتين أصلاً: WebSocket (
trip:update) + FCM data-message (A1/A7). التطبيق يستثمر الاثنتين معاً — لا يختار واحدة.
- من يسبق يفوز. مدخل موحّد واحد (
TripEventMergerداخلTripBloc/OffersBloc) يستقبل من القناتين ويطبّق الحدث الأسبق وصولاً. عرض الرحلة للسائق كذلك:trip:offerعبر السوكت + FCM dataOnly بالحمولة الكاملة → أيّهما وصل أولاً يعرض الـoverlay. - إزالة الازدواج بآلة حالة رتيبة، لا بمعرّفات. ترتيب الحالات معروف ثابت (
searching < assigned < driver_arriving < driver_arrived < in_progress < completed < paid). الحدث الذي يحمل حالة ≤ الحالة المعروضة يُهمل بصمت. هذا يحلّ الازدواج والوصول المعكوس معاً بلا تتبّع معرّفات رسائل. - صمام الأمان الثالث: المصالحة (reconciliation).
GET /trips/:idتُنادى عند: (أ) عودة التطبيق من الخلفية، (ب) إعادة اتصال السوكت بعد انقطاع، (ج) مؤقّت خفيف أثناء رحلة نشطة فقط (افتراضي كل 25 ثانية، قابل للضبط، يتوقف كلياً خارج الرحلة). سؤال المالك «هل يلزم كل فترة؟» — الجواب نعم لكن بهذا الشرط الضيّق: FCM قد يتأخر دقائق في Doze والسوكت قد يموت صامتاً؛ المؤقّت يغطي نافذة فشل القناتين معاً، وكلفته استعلام Redis واحد (A3). - الفتح البارد:
GET /trips/mine(استئناف أي رحلة غير منتهية) +GET /trips/rating/pending(فرض شاشة التقييم قبل أي رحلة جديدة). - العرض دائماً من حالة الـBloc الموحّدة — لا شاشة تستمع للسوكت أو FCM مباشرة.