Files
tripz-llc/docs/23-flutter-conventions.md
Hamza-AyedandClaude Opus 5 6ea213629c fix(apps): سقوط أصلي عند تحميل الخريطة — ستايل map-saas بلا sprite
## العطل
libc++abi: terminating due to uncaught exception of type std::domain_error
بلا أي استثناء دارتي — سقوط أصلي بحت بعد «Using remote style URL» مباشرة.

## السبب
فحص الستايل البعيد مباشرةً:
  /api/maps/style.json?theme=light → 68 طبقة، منها 12 طبقة symbol تستعمل
  icon-image، و **لا sprite معرَّف في الملف إطلاقاً**.
MapLibre الأصلي يرمي std::domain_error عند طبقة أيقونات بلا sprite تحلّها.

⚠️ الستايل المرفق بالحزمة نفسها مصابٌ بالعيب ذاته (sprite غائب + 12 طبقة)،
فلا الافتراضي ولا البعيد صالحان. ستايلها الليلي وحده سليم.

## العلاج
التحوّل إلى assets/style.json و assets/style_dark.json المنقولين من سيرو
الميداني: بهما sprite صحيح، وبلاطاتهما من tiles.intaleqapp.com نفسها،
ويقلعان بلا انتظار شبكة — وهو ما يعنيه «الشكل حتمي من أول إقلاع» (docs/26 §1).
نُسخ الملفان لتطبيق السائق أيضاً.

## الإصلاح الجذري (خادم، خارج هذا الكوميت)
إضافة sprite لستايل map-saas البعيد أو إزالة طبقات الأيقونات منه.

docs/23 §13 وُسّع بالقاعدة وسببها.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 01:51:03 +03:00

272 lines
20 KiB
Markdown

# 23 — قانون تطبيقات فلاتر (الراكب والسائق)
> **هذا المستند مُلزِم.** كل كود Dart في `apps/rider` و`apps/driver` يتبعه. أي انحراف يُصحَّح لا يُبرَّر.
> اقرأه كاملاً قبل كتابة أول سطر في أي جلسة جديدة — هو العقد الذي يجعل جلستين مختلفتين تُنتجان نفس البنية.
آخر تحديث: 2026-07-19. ذو صلة: [22 §1.5](22-full-product-roadmap.md) (الطبقات) · [19](19-entitlements-licensing.md) (الاستحقاقات) · **[26](26-flutter-design-system.md) (نظام التصميم الثابت — قانون الشكل المكمّل لهذا القانون)** · [[tripz-mobile-store-identity]].
---
## 0. المبادئ الخمسة (إن تعارض شيء معها فهي تُرجَّح)
1. **التطبيقان توأمان في البنية، مختلفان في الميزات.** نفس المجلدات ونفس التسميات ونفس طبقة الشبكة. من يعرف الراكب يعرف السائق فوراً.
2. **السيرفر هو مصدر الحقيقة.** التطبيق يعرض ما يُعطى. لا منطق تسعير ولا حساب عمولة ولا قرار مطابقة في Dart — أبداً.
3. **الحالة صريحة، لا سحر.** Cubit افتراضاً، Bloc عند تعدّد الأحداث. لا GetX ولا متغيّرات عامة قابلة للتغيير.
4. **الميزة تُقفل من نقطة واحدة** (تسجيل الموديول)، لا بـ`if` مبعثرة في الشاشات.
5. **الأصيل مُعاد استعماله لا مُعاد بناؤه**، وهو **موحّد عبر كل المستأجرين** (شرط 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` كما هو.
```dart
// النمط المعتمد
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` في أي مكان آخر.
الاعتراضات المطلوبة، بهذا الترتيب:
1. `x-tenant-id` = `BuildConfig.tenantSlug`.
2. `Authorization: Bearer` من `TokenStore`.
3. `x-device-id` — بصمة الجهاز (يفرضها السيرفر عند `AUTH_REQUIRE_DEVICE_BINDING=true`، docs/17 D2).
4. **توقيع HMAC** للنقاط المالية (docs/17 I6) — يُحسب على الجسم **كما يُرسَل** بلا إعادة تسلسل، وإلا فشل توقيع سليم.
5. تجديد التوكن عند 401 **مرة واحدة** ثم تسجيل خروج — لا حلقة تجديد لانهائية.
> ⚠️ التوقيع وربط الجهاز مطفآن على السيرفر الآن ويُفعَّلان **بعد** أن يرسلهما التطبيق. رتّب: أرسل أولاً، ثم فعّل السيرفر.
**التخزين**: التوكنات في `flutter_secure_storage` حصراً — لا `SharedPreferences` للتوكن.
## 5. أعلام الميزات (القلب — راجع docs/22 §1.5)
```dart
// ✅ الصحيح — const، يُحذف الكود من الـbinary
if (Features.marketIntel) { ... }
// ❌ ممنوع — يُبقي كود الميزة في الملف فيمكن استخراجه
if (featureMap['market_intel'] == true) { ... }
```
**التسجيل المشروط هو القاعدة**، والفحص المتناثر استثناء:
```dart
// 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/` | **شهادات توقيع** — فقدانها يعني تعذّر تحديث التطبيق المنشور نهائياً |
| ~~الخطوط (mohanad · josefin · digit)~~ | **نُسخ (2026-07-19)**: الخطوط الجديدة الثابتة Inter + IBM Plex Sans Arabic — القرار وأسبابه في [26 §1](26-flutter-design-system.md). الملفات القديمة تبقى حتى يؤكد المالك حذفها |
**معرّفات المتجر** في [[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). التطبيق يستثمر الاثنتين معاً — لا يختار واحدة.
1. **من يسبق يفوز.** مدخل موحّد واحد (`TripEventMerger` داخل `TripBloc`/`OffersBloc`) يستقبل من القناتين ويطبّق الحدث الأسبق وصولاً. عرض الرحلة للسائق كذلك: `trip:offer` عبر السوكت + FCM dataOnly بالحمولة الكاملة → أيّهما وصل أولاً يعرض الـoverlay.
2. **إزالة الازدواج بآلة حالة رتيبة، لا بمعرّفات.** ترتيب الحالات معروف ثابت (`searching < assigned < driver_arriving < driver_arrived < in_progress < completed < paid`). الحدث الذي يحمل حالة ≤ الحالة المعروضة **يُهمل بصمت**. هذا يحلّ الازدواج والوصول المعكوس معاً بلا تتبّع معرّفات رسائل.
3. **صمام الأمان الثالث: المصالحة (reconciliation).** `GET /trips/:id` تُنادى عند: (أ) عودة التطبيق من الخلفية، (ب) إعادة اتصال السوكت بعد انقطاع، (ج) مؤقّت خفيف **أثناء رحلة نشطة فقط** (افتراضي كل 25 ثانية، قابل للضبط، يتوقف كلياً خارج الرحلة). سؤال المالك «هل يلزم كل فترة؟» — الجواب نعم لكن بهذا الشرط الضيّق: FCM قد يتأخر دقائق في Doze والسوكت قد يموت صامتاً؛ المؤقّت يغطي نافذة فشل القناتين معاً، وكلفته استعلام Redis واحد (A3).
4. **الفتح البارد**: `GET /trips/mine` (استئناف أي رحلة غير منتهية) + `GET /trips/rating/pending` (فرض شاشة التقييم قبل أي رحلة جديدة).
5. **العرض دائماً من حالة الـBloc الموحّدة** — لا شاشة تستمع للسوكت أو FCM مباشرة.
---
## 13. الخريطة — قاعدة مضافة 2026-08-05 (بعد سقوط التطبيق)
**ممنوع تمرير `markers`/`circles`/`polygons` إلى `IntaleqMap`.**
MapLibre يُنشئ «مديري التعليقات» عند تحميل الستايل وحده، ويهدمهم عند كل
إعادة تحميل — **وتبديل الوضع الليلي إعادةُ تحميل**. وأي إضافة تعليق في تلك
النافذة ترمي `This Annotation Manager has not been initialized`، وقد أسقطت
التطبيق فعلياً بـ`std::domain_error` من الطبقة الأصلية.
حارسٌ زمنيّ لا يكفي: `didUpdateWidget` في الحزمة يستدعي `diffCircles` بمجرّد
وجود المتحكّم — وهو موجود **قبل** تحميل الستايل و**أثناء** إعادة تحميله.
**القاعدة**: كل ما يُرسم فوق الخريطة (نقاط · أسماء · أيقونة السائق) يكون
**طبقة فلاتر** في `Stack` فوقها، بمواضع من `getScreenCoordinate`. هذا يزيل
صنف الأعطال كلياً، ويعطي الخط العربي والاتجاه والثيم مجاناً.
**الاستثناء الوحيد `Polyline`** — لا بديل عنه لرسم المسار. يُحرس بعَلَم
`_styleReady` يُصفَّر عند كل تغيير `styleUrl`.
**والأداء**: `onCameraMove` يُطلق عشرات المرات في الثانية، وكل مزامنة مواضع
ثلاثة نداءات منصّة. حارس `_syncing` إلزامي وإلا تتكدّس النداءات وتلتهم
الإطارات.
### الستايل محلّي لا بعيد
**ممنوع تمرير `IntaleqStyles.light()`/`obsidian()` (الستايل البعيد).**
ستايل map-saas البعيد `/api/maps/style.json?theme=light` فيه **12 طبقة
`symbol` تستعمل `icon-image` بلا `sprite` معرَّف**، وMapLibre الأصلي يرمي
عندها `std::domain_error` فيُسقط التطبيق قبل ظهور إطار واحد. مثبت بفحص
الستايل مباشرةً 2026-08-05.
⚠️ الستايل المرفق **بالحزمة نفسها** (`packages/intaleq_maps/assets/style.json`)
مصابٌ بالعيب ذاته — sprite غائب و12 طبقة أيقونات. الوضع الليلي المرفق بها
سليم. فلا يُعتمد على افتراضات الحزمة أيضاً.
**المعتمد**: `assets/style.json` و`assets/style_dark.json` المنقولان من سيرو
الميداني — بهما `sprite` صحيح، وبلاطاتهما من `tiles.intaleqapp.com` نفسها،
ويقلعان بلا انتظار شبكة (docs/26 §1).
**الإصلاح الجذري على الخادم** (لا يخصّ التطبيق): إضافة `sprite` لستايل
map-saas البعيد، أو إزالة طبقات الأيقونات منه.
المرجع العملي: `apps/rider_new/lib/features/trip/view/widgets/ride_map.dart`.