From 95e2e4f35dcc7f907d798bf30f536790b3c630f5 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Wed, 5 Aug 2026 14:12:41 +0300 Subject: [PATCH] Update codebase --- apps/driver_new/lib/core/build_config.dart | 35 ++- apps/driver_new/lib/core/di.dart | 1 + apps/driver_new/lib/core/l10n/app_ar.arb | 20 +- apps/driver_new/lib/core/l10n/app_en.arb | 20 +- apps/driver_new/lib/core/store_links.dart | 37 +++ apps/driver_new/lib/core/ui/app_drawer.dart | 29 ++- .../profile/view/profile_edit_page.dart | 86 +++++++ .../features/rewards/view/rewards_page.dart | 8 +- .../features/settings/view/settings_page.dart | 151 +++++++++++- .../lib/features/trip/cubit/duty_cubit.dart | 52 +++++ .../lib/features/trip/cubit/duty_state.dart | 24 ++ .../lib/features/trip/data/models/trip.dart | 61 ++++- .../lib/features/trip/view/duty_page.dart | 70 +++++- .../lib/features/trip/view/history_page.dart | 120 ++++++++-- .../trip/view/widgets/active_duty_sheet.dart | 21 +- .../trip/view/widgets/driver_badge.dart | 68 +++++- .../trip/view/widgets/nav_eta_strip.dart | 56 +++++ .../features/trip/view/widgets/ride_map.dart | 139 +++++++---- .../trip/view/widgets/vehicle_color.dart | 67 ++++++ apps/driver_new/pubspec.lock | 10 +- apps/driver_new/pubspec.yaml | 4 + apps/rider_new/lib/core/build_config.dart | 35 ++- apps/rider_new/lib/core/di.dart | 16 +- apps/rider_new/lib/core/l10n/app_ar.arb | 25 +- apps/rider_new/lib/core/l10n/app_en.arb | 25 +- .../notifications/notification_store.dart | 104 +++++++++ .../lib/core/notifications/push_service.dart | 28 ++- apps/rider_new/lib/core/router.dart | 6 + apps/rider_new/lib/core/store_links.dart | 37 +++ apps/rider_new/lib/core/ui/app_drawer.dart | 65 +++++- .../view/notifications_page.dart | 117 ++++++++++ .../profile/view/profile_edit_page.dart | 86 +++++++ .../features/rewards/view/rewards_page.dart | 8 +- .../features/settings/view/settings_page.dart | 151 +++++++++++- .../lib/features/trip/cubit/ride_cubit.dart | 92 +++++++- .../lib/features/trip/cubit/ride_state.dart | 13 ++ .../features/trip/cubit/trip_simulator.dart | 124 ++++++++++ .../lib/features/trip/data/models/trip.dart | 61 ++++- .../trip/data/recent_places_store.dart | 74 ++++++ .../lib/features/trip/view/history_page.dart | 120 ++++++++-- .../lib/features/trip/view/ride_page.dart | 198 ++++++++++++---- .../trip/view/widgets/active_trip_sheet.dart | 34 +-- .../trip/view/widgets/driver_badge.dart | 68 +++++- .../view/widgets/place_search_dialog.dart | 219 ++++++++++++++++++ .../trip/view/widgets/planner_sheet.dart | 114 +-------- .../features/trip/view/widgets/ride_map.dart | 167 +++++++++---- .../trip/view/widgets/trip_contact_row.dart | 174 ++++++++++++++ .../trip/view/widgets/vehicle_color.dart | 67 ++++++ apps/rider_new/pubspec.lock | 10 +- apps/rider_new/pubspec.yaml | 4 + docs/40-rider-ux-gaps-plan.md | 210 +++++++++++++++++ 51 files changed, 3141 insertions(+), 390 deletions(-) create mode 100644 apps/driver_new/lib/core/store_links.dart create mode 100644 apps/driver_new/lib/features/trip/view/widgets/nav_eta_strip.dart create mode 100644 apps/driver_new/lib/features/trip/view/widgets/vehicle_color.dart create mode 100644 apps/rider_new/lib/core/notifications/notification_store.dart create mode 100644 apps/rider_new/lib/core/store_links.dart create mode 100644 apps/rider_new/lib/features/notifications/view/notifications_page.dart create mode 100644 apps/rider_new/lib/features/trip/cubit/trip_simulator.dart create mode 100644 apps/rider_new/lib/features/trip/data/recent_places_store.dart create mode 100644 apps/rider_new/lib/features/trip/view/widgets/place_search_dialog.dart create mode 100644 apps/rider_new/lib/features/trip/view/widgets/trip_contact_row.dart create mode 100644 apps/rider_new/lib/features/trip/view/widgets/vehicle_color.dart create mode 100644 docs/40-rider-ux-gaps-plan.md diff --git a/apps/driver_new/lib/core/build_config.dart b/apps/driver_new/lib/core/build_config.dart index 06dcbc6..4b22cfb 100644 --- a/apps/driver_new/lib/core/build_config.dart +++ b/apps/driver_new/lib/core/build_config.dart @@ -23,11 +23,32 @@ class BuildConfig { static const String logoAsset = 'assets/images/logo.png'; static const String splashLogoAsset = 'assets/images/splash_logo.png'; - /// روابط خارجية لكل مستأجر — تُولَّد مع بقية الملف (N3). - static const String storeUrl = String.fromEnvironment( - 'STORE_URL', - defaultValue: 'https://tripz.app', + /// روابط المتاجر الأربعة — **صفحات المتجر الحقيقية المنشورة**، أعطاها + /// المالك 2026-08-05. لا تُستعمل مباشرةً: `StoreLinks` هو من يختار الرابط + /// حسب المنصّة والدور، لأن «قيّم التطبيق» على آيفون سائق ≠ نفس الرابط على + /// أندرويد راكب. + /// + /// المعرّفات تطابق هوية النشر المقفلة: `com.mobileapp.store.ride` للراكب + /// و`com.sefer_driver` للسائق. + static const String riderIosUrl = String.fromEnvironment( + 'RIDER_IOS_URL', + defaultValue: 'https://apps.apple.com/eg/app/tripz-rider/id6458734951', ); + static const String driverIosUrl = String.fromEnvironment( + 'DRIVER_IOS_URL', + defaultValue: 'https://apps.apple.com/eg/app/tripz-driver/id6502189302', + ); + static const String riderAndroidUrl = String.fromEnvironment( + 'RIDER_ANDROID_URL', + defaultValue: + 'https://play.google.com/store/apps/details?id=com.mobileapp.store.ride', + ); + static const String driverAndroidUrl = String.fromEnvironment( + 'DRIVER_ANDROID_URL', + defaultValue: + 'https://play.google.com/store/apps/details?id=com.sefer_driver', + ); + static const String privacyUrl = String.fromEnvironment( 'PRIVACY_URL', defaultValue: 'https://tripz.app/privacy', @@ -38,12 +59,6 @@ class BuildConfig { 'SUPPORT_PHONE', defaultValue: '962790000000', ); - - /// رابط تطبيق الطرف الآخر — السائق يدعو ركّاباً والعكس. - static const String driverAppUrl = String.fromEnvironment( - 'RIDER_APP_URL', - defaultValue: 'https://tripz.app', - ); } /// أعلام الاستحقاقات (docs/23 §5). **`const` إجبارياً** كي يُحذف كود الميزة diff --git a/apps/driver_new/lib/core/di.dart b/apps/driver_new/lib/core/di.dart index 2892ae6..c331f4f 100644 --- a/apps/driver_new/lib/core/di.dart +++ b/apps/driver_new/lib/core/di.dart @@ -140,6 +140,7 @@ Future setupInjector() async { trips: sl(), location: sl(), realtime: sl(), + maps: sl(), )); } diff --git a/apps/driver_new/lib/core/l10n/app_ar.arb b/apps/driver_new/lib/core/l10n/app_ar.arb index 308af8d..71aa33c 100644 --- a/apps/driver_new/lib/core/l10n/app_ar.arb +++ b/apps/driver_new/lib/core/l10n/app_ar.arb @@ -65,6 +65,12 @@ "settingsLanguage": "اللغة", "settingsLanguageArabic": "العربية", "settingsLanguageEnglish": "English", + "settingsSectionGeneral": "عام", + "settingsSectionSupport": "الدعم والمعلومات", + "settingsSupportLead": "أسئلة شائعة وطرق التواصل", + "settingsShareLead": "شارك التطبيق مع أصدقائك", + "paymentCash": "نقداً", + "paymentWallet": "من المحفظة", "rideWhereTo": "إلى أين؟", "rideOrigin": "من", "rideDestination": "إلى", @@ -224,12 +230,24 @@ "menuNotifications": "الإشعارات", "menuPrivacy": "سياسة الخصوصية", "menuRateApp": "قيّم التطبيق", - "shareMessage": "انضم لـTripz كسائق — استعمل كودي {code}", + "shareMessage": "انضم لـTripz كسائق — استعمل كودي {code}\n{link}", + "shareMessagePlain": "انضم لـTripz كسائق\n{link}", + "menuRiderApp": "تطبيق الراكب", "menuDriverApp": "تطبيق الراكب", "@shareMessage": { "placeholders": { "code": { "type": "String" + }, + "link": { + "type": "String" + } + } + }, + "@shareMessagePlain": { + "placeholders": { + "link": { + "type": "String" } } }, diff --git a/apps/driver_new/lib/core/l10n/app_en.arb b/apps/driver_new/lib/core/l10n/app_en.arb index a2a574c..464024f 100644 --- a/apps/driver_new/lib/core/l10n/app_en.arb +++ b/apps/driver_new/lib/core/l10n/app_en.arb @@ -65,6 +65,12 @@ "settingsLanguage": "Language", "settingsLanguageArabic": "العربية", "settingsLanguageEnglish": "English", + "settingsSectionGeneral": "General", + "settingsSectionSupport": "Support & info", + "settingsSupportLead": "FAQ and ways to reach us", + "settingsShareLead": "Share the app with your friends", + "paymentCash": "Cash", + "paymentWallet": "Wallet", "rideWhereTo": "Where to?", "rideOrigin": "From", "rideDestination": "To", @@ -224,12 +230,24 @@ "menuNotifications": "Notifications", "menuPrivacy": "Privacy policy", "menuRateApp": "Rate the app", - "shareMessage": "Join Tripz as a driver — use my code {code}", + "shareMessage": "Join Tripz as a driver — use my code {code}\n{link}", + "shareMessagePlain": "Join Tripz as a driver\n{link}", + "menuRiderApp": "Rider app", "menuDriverApp": "Rider app", "@shareMessage": { "placeholders": { "code": { "type": "String" + }, + "link": { + "type": "String" + } + } + }, + "@shareMessagePlain": { + "placeholders": { + "link": { + "type": "String" } } }, diff --git a/apps/driver_new/lib/core/store_links.dart b/apps/driver_new/lib/core/store_links.dart new file mode 100644 index 0000000..6b3d8a9 --- /dev/null +++ b/apps/driver_new/lib/core/store_links.dart @@ -0,0 +1,37 @@ +import 'package:flutter/foundation.dart'; + +import 'build_config.dart'; +import 'config.dart'; + +/// اختيار رابط المتجر الصحيح — **بالمنصّة والدور معاً**. +/// +/// كان الخطأ (مثبت 2026-08-05): رابط واحد ثابت `https://tripz.app` لكل +/// شيء — فـ«قيّم التطبيق» لا يفتح صفحة تقييم، و«شارك التطبيق» يرسل رسالة +/// بلا رابط أصلاً، و«سجّل كسائق» يفتح صفحة تسويقية لا تطبيق السائق. +/// +/// أربع حالات لا واحدة: {آيفون، أندرويد} × {راكب، سائق}. والدور يأتي من +/// [AppConfig.appRole] الذي يميّز البناءين أصلاً. +class StoreLinks { + const StoreLinks._(); + + static bool get _isIos => defaultTargetPlatform == TargetPlatform.iOS; + + static bool get _isDriverBuild => AppConfig.appRole == 'driver'; + + /// رابط تطبيق الراكب على منصّة هذا الجهاز. + static String get rider => + _isIos ? BuildConfig.riderIosUrl : BuildConfig.riderAndroidUrl; + + /// رابط تطبيق السائق على منصّة هذا الجهاز. + static String get driver => + _isIos ? BuildConfig.driverIosUrl : BuildConfig.driverAndroidUrl; + + /// **هذا** التطبيق في متجر هذا الجهاز — لـ«قيّم التطبيق» ولرابط المشاركة. + /// + /// السائق يشارك رابط السائق، والراكب رابط الراكب: مشاركةُ راكبٍ لرابط + /// السائق تُنزِّل المدعوَّ تطبيقاً خاطئاً. + static String get thisApp => _isDriverBuild ? driver : rider; + + /// تطبيق الطرف الآخر — «سجّل كسائق» في تطبيق الراكب، والعكس. + static String get otherApp => _isDriverBuild ? rider : driver; +} diff --git a/apps/driver_new/lib/core/ui/app_drawer.dart b/apps/driver_new/lib/core/ui/app_drawer.dart index dafef25..d1d0cac 100644 --- a/apps/driver_new/lib/core/ui/app_drawer.dart +++ b/apps/driver_new/lib/core/ui/app_drawer.dart @@ -12,6 +12,7 @@ import '../l10n/l10n.dart'; import '../router.dart'; import '../session/session_cubit.dart'; import '../session/session_state.dart'; +import '../store_links.dart'; import '../tenant/feature_gate.dart'; /// الدراور — نقطة تطبيق **طبقتَي الميزات** معاً (docs/23 §5 + docs/38 §10): @@ -72,28 +73,38 @@ class AppDrawer extends StatelessWidget { route: Routes.support, ), // مداخل محليّة بلا نقطة خادم — تُنفَّذ في الجهاز مباشرة. + // + // الروابط من `StoreLinks` لا من ثابتٍ واحد: هذا البناء + // يشارك **رابط نفسه** (سائق يشارك رابط السائق)، ويفتح + // متجر منصّته هو. _Action( icon: Icons.share_outlined, label: l10n.menuShare, - onTap: () => SharePlus.instance - .share(ShareParams(text: l10n.shareMessage(''))), + onTap: () => SharePlus.instance.share( + ShareParams( + text: l10n.shareMessagePlain(StoreLinks.thisApp), + ), + ), ), _Action( icon: Icons.star_outline_rounded, label: l10n.menuRateApp, - onTap: () => _open(BuildConfig.storeUrl), + onTap: () => _open(StoreLinks.thisApp), ), _Action( icon: Icons.shield_outlined, label: l10n.menuPrivacy, onTap: () => _open(BuildConfig.privacyUrl), ), - if (!isDriver) - _Action( - icon: Icons.local_taxi_outlined, - label: l10n.menuDriverApp, - onTap: () => _open(BuildConfig.driverAppUrl), - ), + // كان `if (!isDriver)` — وهذا البناء سائق دائماً، فالمدخل + // لم يكن يظهر أبداً. الطرف الآخر يُحسب لا يُخفى. + _Action( + icon: isDriver + ? Icons.person_outline_rounded + : Icons.local_taxi_outlined, + label: isDriver ? l10n.menuRiderApp : l10n.menuDriverApp, + onTap: () => _open(StoreLinks.otherApp), + ), ], ), ), diff --git a/apps/driver_new/lib/features/profile/view/profile_edit_page.dart b/apps/driver_new/lib/features/profile/view/profile_edit_page.dart index c14f0a4..b9c10a5 100644 --- a/apps/driver_new/lib/features/profile/view/profile_edit_page.dart +++ b/apps/driver_new/lib/features/profile/view/profile_edit_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../core/design/tokens.dart'; import '../../../core/design/tripz_colors.dart'; +import '../../../core/design/typography.dart'; import '../../../core/di.dart'; import '../../../core/l10n/l10n.dart'; import '../../../core/session/session_cubit.dart'; @@ -54,6 +55,16 @@ class _ProfileEditPageState extends State { ), child: ListView( children: [ + // ترويسة الحساب — الاسم والرقم والتقييم في مكان واحد. + // **التقييم كان يصل ولا يُعرض أبداً**: `AppUser.rating` مقروء + // من `GET /users/me` (docs/38 §2) ولم تكن أي شاشة تستعمله، + // بينما الراكب يُقيَّم من السائق ويحقّ له أن يرى نتيجته. + _ProfileHeader( + name: user?.name ?? '', + phone: user?.phone ?? '', + rating: user?.rating, + ), + const SizedBox(height: Space.lg), TripzTextField( controller: _name, label: l10n.profileNameLabel, @@ -91,3 +102,78 @@ class _ProfileEditPageState extends State { messenger.showSnackBar(SnackBar(content: Text(l10n.profileSaved))); } } + +/// ترويسة الحساب: حرف الاسم الأول في دائرة، ثم الاسم والرقم والتقييم. +/// +/// لا صورة شخصية: الخادم لا يوفّر رفع صور للمستخدم (سيرو يفعل عبر PHP)، +/// وحرفُ الاسم أصدق من رمزٍ عام يوحي بإمكانية لا توجد. +class _ProfileHeader extends StatelessWidget { + const _ProfileHeader({ + required this.name, + required this.phone, + this.rating, + }); + + final String name; + final String phone; + final String? rating; + + @override + Widget build(BuildContext context) { + final initial = name.trim().isEmpty ? '?' : name.trim().characters.first; + return Row( + children: [ + CircleAvatar( + radius: 32, + backgroundColor: context.colors.primaryContainer, + child: Text( + initial, + style: context.texts.headlineSmall?.copyWith( + color: context.colors.onPrimaryContainer, + ), + ), + ), + const SizedBox(width: Space.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (name.trim().isNotEmpty) + Text( + name, + style: context.texts.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + phone, + // الرقم يُقرأ يساراً-يميناً حتى في واجهة عربية. + textDirection: TextDirection.ltr, + style: numericStyle(context.texts.bodySmall).copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + if (rating != null && rating!.isNotEmpty) ...[ + const SizedBox(height: Space.xxs), + Row( + children: [ + Icon( + Icons.star_rounded, + size: Sizes.iconSm, + color: context.tripzColors.warning, + ), + const SizedBox(width: Space.xxs), + Text( + rating!, + style: numericStyle(context.texts.bodySmall), + ), + ], + ), + ], + ], + ), + ), + ], + ); + } +} diff --git a/apps/driver_new/lib/features/rewards/view/rewards_page.dart b/apps/driver_new/lib/features/rewards/view/rewards_page.dart index 1daedea..f1ce8fc 100644 --- a/apps/driver_new/lib/features/rewards/view/rewards_page.dart +++ b/apps/driver_new/lib/features/rewards/view/rewards_page.dart @@ -8,6 +8,7 @@ import '../../../core/design/tripz_colors.dart'; import '../../../core/design/typography.dart'; import '../../../core/di.dart'; import '../../../core/l10n/l10n.dart'; +import '../../../core/store_links.dart'; import '../../../core/ui/tripz_button.dart'; import '../../../core/ui/tripz_card.dart'; import '../../../core/ui/tripz_scaffold.dart'; @@ -155,8 +156,11 @@ class _InviteCard extends StatelessWidget { TripzButton.primary( label: l10n.menuShare, icon: Icons.share_outlined, - onPressed: () => - SharePlus.instance.share(ShareParams(text: l10n.shareMessage(code))), + // الكود بلا رابط لا ينفع: المدعوّ لا يعرف من أين يُنزّل. ورابطُ + // **هذا** البناء لا رابطٌ ثابت — سائق يدعو سائقاً وراكب راكباً. + onPressed: () => SharePlus.instance.share( + ShareParams(text: l10n.shareMessage(code, StoreLinks.thisApp)), + ), ), const SizedBox(height: Space.md), Row( diff --git a/apps/driver_new/lib/features/settings/view/settings_page.dart b/apps/driver_new/lib/features/settings/view/settings_page.dart index 8c313d8..c654a53 100644 --- a/apps/driver_new/lib/features/settings/view/settings_page.dart +++ b/apps/driver_new/lib/features/settings/view/settings_page.dart @@ -1,15 +1,30 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../../../core/build_config.dart'; import '../../../core/design/tokens.dart'; import '../../../core/design/tripz_colors.dart'; +import '../../../core/design/typography.dart'; +import '../../../core/di.dart'; import '../../../core/l10n/l10n.dart'; +import '../../../core/router.dart'; +import '../../../core/session/session_cubit.dart'; +import '../../../core/store_links.dart'; import '../../../core/ui/tripz_scaffold.dart'; +import '../../profile/data/profile_repository.dart'; import '../cubit/settings_cubit.dart'; import '../cubit/settings_state.dart'; -/// المظهر واللغة. تبديل اللغة **يقلب الاتجاه كاملاً** بلا إعادة تشغيل — -/// `MaterialApp` يشتقّ RTL/LTR من الـlocale (docs/26 §4). +/// المظهر واللغة ومعلومات التطبيق — بأقسام كما في سيرو +/// (`setting_page.dart`: General · Preferences · Support & Info) بدل قائمة +/// مسطّحة بلا عناوين. +/// +/// تبديل اللغة **يقلب الاتجاه كاملاً** بلا إعادة تشغيل — `MaterialApp` +/// يشتقّ RTL/LTR من الـlocale (docs/26 §4). class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); @@ -23,8 +38,8 @@ class SettingsPage extends StatelessWidget { title: l10n.settingsTitle, child: ListView( children: [ - Text(l10n.settingsTheme, style: context.texts.titleMedium), - const SizedBox(height: Space.xs), + _SectionHeader(title: l10n.settingsSectionGeneral), + Text(l10n.settingsTheme, style: context.texts.titleSmall), RadioGroup( groupValue: state.themeMode, onChanged: (v) => cubit.setThemeMode(v ?? ThemeMode.system), @@ -48,12 +63,15 @@ class SettingsPage extends StatelessWidget { ], ), ), - const Divider(height: Space.xl), - Text(l10n.settingsLanguage, style: context.texts.titleMedium), - const SizedBox(height: Space.xs), + const SizedBox(height: Space.md), + Text(l10n.settingsLanguage, style: context.texts.titleSmall), RadioGroup( groupValue: state.locale.languageCode, - onChanged: (v) => cubit.setLocale(Locale(v ?? 'ar')), + // اللغة تُحفظ محلياً **وتُرسل للخادم**: الإشعارات تُصاغ على + // الخادم بلغة المستخدم المخزّنة عنده (`PATCH /users/me` + // يقبل `language` — docs/38 §3)، فلغةٌ محليّة وحدها تعني + // واجهةً عربية وإشعاراتٍ إنجليزية. + onChanged: (v) => _setLanguage(context, v ?? 'ar'), child: Column( children: [ RadioListTile( @@ -69,10 +87,127 @@ class SettingsPage extends StatelessWidget { ], ), ), + + _SectionHeader(title: l10n.settingsSectionSupport), + _Tile( + icon: Icons.help_outline_rounded, + title: l10n.menuSupport, + subtitle: l10n.settingsSupportLead, + onTap: () => context.push(Routes.support), + ), + _Tile( + icon: Icons.share_outlined, + title: l10n.menuShare, + subtitle: l10n.settingsShareLead, + onTap: () => SharePlus.instance.share( + ShareParams(text: l10n.shareMessagePlain(StoreLinks.thisApp)), + ), + ), + _Tile( + icon: Icons.shield_outlined, + title: l10n.menuPrivacy, + onTap: () async { + final uri = Uri.tryParse(BuildConfig.privacyUrl); + if (uri == null) return; + await launchUrl(uri, mode: LaunchMode.externalApplication); + }, + ), + const _VersionTile(), ], ), ); }, ); } + + /// فشل الخادم **لا يمنع** تبديل اللغة محلياً: الواجهة تستجيب فوراً، + /// والمزامنة تُعاد عند التبديل التالي. عكسه يعني زرّاً لا يستجيب بلا شبكة. + static Future _setLanguage(BuildContext context, String code) async { + await context.read().setLocale(Locale(code)); + try { + final user = await sl().update(language: code); + sl().onProfileCompleted(user); + } catch (_) { + // المزامنة أفضل-جهد — لا رسالة خطأ على تبديل نجح محلياً. + } + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: Space.lg, bottom: Space.xs), + child: Text( + title, + style: context.texts.labelMedium?.copyWith( + color: context.colors.primary, + ), + ), + ); + } +} + +class _Tile extends StatelessWidget { + const _Tile({ + required this.icon, + required this.title, + required this.onTap, + this.subtitle, + }); + + final IconData icon; + final String title; + final String? subtitle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(icon), + title: Text(title), + subtitle: subtitle == null ? null : Text(subtitle!), + trailing: Icon( + Icons.chevron_left_rounded, + color: context.colors.onSurfaceVariant, + ), + onTap: onTap, + ); + } +} + +/// رقم الإصدار — أول ما يُسأل عنه في الدعم، وكان غائباً رغم وجود مفتاح +/// ترجمته أصلاً. +class _VersionTile extends StatelessWidget { + const _VersionTile(); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (context, snapshot) { + final info = snapshot.data; + return ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.info_outline_rounded), + title: Text(l10n.aboutVersion), + trailing: Text( + // قبل وصول المعلومة يُترك المكان فارغاً لا «—»: القيمة تصل + // في إطار واحد، والشرطة تومض بلا داعٍ. + info == null ? '' : '${info.version} (${info.buildNumber})', + textDirection: TextDirection.ltr, + style: numericStyle(context.texts.bodySmall).copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ); + }, + ); + } } diff --git a/apps/driver_new/lib/features/trip/cubit/duty_cubit.dart b/apps/driver_new/lib/features/trip/cubit/duty_cubit.dart index 92faea8..b3a6dd9 100644 --- a/apps/driver_new/lib/features/trip/cubit/duty_cubit.dart +++ b/apps/driver_new/lib/features/trip/cubit/duty_cubit.dart @@ -6,6 +6,7 @@ import '../../../core/api/api_exception.dart'; import '../../../core/location/location_service.dart'; import '../../../core/realtime/realtime_service.dart'; import '../data/models/geo_point.dart'; +import '../data/maps_repository.dart'; import '../data/models/trip.dart'; import '../data/trip_repository.dart'; import 'duty_state.dart'; @@ -16,14 +17,17 @@ class DutyCubit extends Cubit { required TripRepository trips, required LocationService location, required RealtimeService realtime, + required MapsRepository maps, }) : _trips = trips, _location = location, _realtime = realtime, + _maps = maps, super(const DutyState()); final TripRepository _trips; final LocationService _location; final RealtimeService _realtime; + final MapsRepository _maps; StreamSubscription? _events; Timer? _offerTimer; @@ -100,6 +104,7 @@ class DutyCubit extends Cubit { final heading = position.heading as double?; emit(state.copyWith(myLocation: GeoPoint(lat, lng))); + _refreshNavRoute(GeoPoint(lat, lng)); // مساران عمداً: REST يغذّي المطابقة في Redis، والسوكت يغذّي خريطة // الراكب الحيّة. سقوط أحدهما لا يُعمي الآخر. @@ -202,6 +207,53 @@ class DutyCubit extends Cubit { busy: false, phase: DutyPhase.active, )); + + // الهدف يتبدّل مع الحالة (الراكب ← الوجهة)، فالمسار يُعاد فوراً عند كل + // انتقال بدل انتظار نبضة الموقع التالية. + _lastNavFrom = null; + final me = state.myLocation; + if (me != null) _refreshNavRoute(me); + } + + // ── مسار القيادة ─────────────────────────────────────────────────────── + + /// أقلّ إزاحة تستدعي إعادة الحساب (~50 متراً) — نفس منطق الراكب: يلتقط + /// الانعطاف الفعلي ويتجاهل رجفة GPS. + static const double _redrawThreshold = 0.0005; + + GeoPoint? _lastNavFrom; + bool _routing = false; + + void _refreshNavRoute(GeoPoint from) { + final target = state.navTarget; + if (target == null) { + if (state.navRoute != null) emit(state.copyWith(clearNavRoute: true)); + _lastNavFrom = null; + return; + } + if (_routing) return; + + final last = _lastNavFrom; + final moved = last == null || + (from.lat - last.lat).abs() > _redrawThreshold || + (from.lng - last.lng).abs() > _redrawThreshold; + if (!moved) return; + + _lastNavFrom = from; + unawaited(_route(from, target)); + } + + Future _route(GeoPoint from, GeoPoint to) async { + _routing = true; + try { + final route = await _maps.route(from, to); + if (!isClosed) emit(state.copyWith(navRoute: route)); + } on ApiException { + // مسارٌ متعذّر لا يوقف الرحلة — السائق يرى النقطة ولو بلا خط، + // وتُعاد المحاولة مع الحركة التالية. + } finally { + _routing = false; + } } void _onRealtime(RealtimeEvent event) { diff --git a/apps/driver_new/lib/features/trip/cubit/duty_state.dart b/apps/driver_new/lib/features/trip/cubit/duty_state.dart index 7100c65..5867c96 100644 --- a/apps/driver_new/lib/features/trip/cubit/duty_state.dart +++ b/apps/driver_new/lib/features/trip/cubit/duty_state.dart @@ -1,6 +1,7 @@ import 'package:equatable/equatable.dart'; import '../data/models/geo_point.dart'; +import '../data/models/route_info.dart'; import '../data/models/trip.dart'; /// حالة السائق. الفرق الجوهري عن الراكب: السائق **يستقبل** عملاً بدل أن @@ -31,6 +32,7 @@ class DutyState extends Equatable { this.error = DutyError.none, this.creditBalance, this.creditBlocked = false, + this.navRoute, }); final DutyPhase phase; @@ -49,6 +51,22 @@ class DutyState extends Equatable { /// محجوب لنفاد الرصيد — لا يصله عمل، ولا بد أن يعرف السبب صراحةً. final bool creditBlocked; + /// المسار الذي يقوده السائق الآن: إليه إن كان مقترباً، وإلى وجهة الراكب + /// إن كانت الرحلة جارية. **بلا هذا السائق أعمى** — كان يرى موقعه وحده. + final RouteInfo? navRoute; + + /// الهدف الحالي للقيادة — نقطة الراكب أثناء الاقتراب، ثم الوجهة. + /// + /// آلة الحالات هي من تقرّره لا الشاشة (docs/38 §4). + GeoPoint? get navTarget => switch (trip?.status) { + TripStatus.assigned || + TripStatus.driverArriving || + TripStatus.driverArrived => + trip?.origin, + TripStatus.inProgress => trip?.destination, + _ => null, + }; + bool get isOnline => phase != DutyPhase.offline; DutyState copyWith({ @@ -60,8 +78,10 @@ class DutyState extends Equatable { DutyError? error, String? creditBalance, bool? creditBlocked, + RouteInfo? navRoute, bool clearOffer = false, bool clearTrip = false, + bool clearNavRoute = false, }) { return DutyState( phase: phase ?? this.phase, @@ -72,6 +92,9 @@ class DutyState extends Equatable { error: error ?? this.error, creditBalance: creditBalance ?? this.creditBalance, creditBlocked: creditBlocked ?? this.creditBlocked, + navRoute: clearNavRoute || clearTrip + ? null + : (navRoute ?? this.navRoute), ); } @@ -85,5 +108,6 @@ class DutyState extends Equatable { error, creditBalance, creditBlocked, + navRoute, ]; } diff --git a/apps/driver_new/lib/features/trip/data/models/trip.dart b/apps/driver_new/lib/features/trip/data/models/trip.dart index 5a7ba5b..28ee48b 100644 --- a/apps/driver_new/lib/features/trip/data/models/trip.dart +++ b/apps/driver_new/lib/features/trip/data/models/trip.dart @@ -70,7 +70,14 @@ class Trip extends Equatable { this.driverPhone, this.vehiclePlate, this.vehicleModel, + this.vehicleColor, + this.vehicleColorHex, this.driverRating, + this.requestedAt, + this.distanceKm, + this.durationMin, + this.paymentMethod, + this.serviceClass, }); final String id; @@ -90,8 +97,34 @@ class Trip extends Equatable { final String? driverPhone; final String? vehiclePlate; final String? vehicleModel; + + /// لون المركبة كاسم (`White`, `أبيض`) — **ما يبحث عنه الراكب في الشارع + /// قبل اللوحة**. الخادم يرسله في `vehicle.color` + /// (`trips.service.ts:427`). + final String? vehicleColor; + + /// اللون كـhex إن وُجد (`vehicles` عندها `color_hex`). يُقدَّم على الاسم + /// عند الرسم لأنه دقيق، والاسم يبقى للعرض النصّي. + final String? vehicleColorHex; + final String? driverRating; + // ── حقول السجلّ ──────────────────────────────────────────────────────── + // كلها موجودة في `trip.entity.ts` وتصل مع `GET /trips/mine`، وكانت + // مُهمَلة في القراءة — فبدت بطاقة «رحلاتي» فارغة إلا من سعر وحالة. + + /// وقت الطلب (`requested_at`, `CreateDateColumn`). + final DateTime? requestedAt; + + final double? distanceKm; + final double? durationMin; + + /// `cash` افتراضاً في الخادم — يُعرض كي يعرف الراكب كيف دفع. + final String? paymentMethod; + + /// نوع الخدمة (`economy` افتراضاً). + final String? serviceClass; + factory Trip.fromJson(Map json) { GeoPoint? point(String key) { final v = json[key]; @@ -99,6 +132,11 @@ class Trip extends Equatable { } final driver = json['driver'] as Map?; + // المركبة تصل بشكلين: مسطّحة (`vehicle_plate`) في ردّ الرحلة، ومتداخلة + // (`vehicle: {plate, color}`) في حدث `assigned` (`trips.service.ts:423`). + // القراءة تقبل الاثنين بدل أن تُفرغ البطاقة عند أحدهما. + final vehicle = (json['vehicle'] ?? driver?['vehicle']) + as Map?; return Trip( id: json['id'] as String, status: TripStatus.parse(json['status'] as String?), @@ -112,11 +150,26 @@ class Trip extends Equatable { driverName: driver?['name'] as String? ?? json['driver_name'] as String?, driverPhone: driver?['phone'] as String? ?? json['driver_phone'] as String?, - vehiclePlate: - driver?['vehicle_plate'] as String? ?? json['vehicle_plate'] as String?, - vehicleModel: - driver?['vehicle_model'] as String? ?? json['vehicle_model'] as String?, + vehiclePlate: vehicle?['plate'] as String? ?? + driver?['vehicle_plate'] as String? ?? + json['vehicle_plate'] as String?, + vehicleModel: vehicle?['model'] as String? ?? + driver?['vehicle_model'] as String? ?? + json['vehicle_model'] as String?, + vehicleColor: vehicle?['color'] as String? ?? + driver?['vehicle_color'] as String? ?? + json['vehicle_color'] as String?, + vehicleColorHex: vehicle?['color_hex'] as String? ?? + driver?['vehicle_color_hex'] as String? ?? + json['vehicle_color_hex'] as String?, driverRating: driver?['rating']?.toString(), + requestedAt: DateTime.tryParse(json['requested_at']?.toString() ?? ''), + // المسافة الفعلية أدقّ من المقدّرة حين تتوفّر بعد انتهاء الرحلة. + distanceKm: (json['actual_distance_km'] as num?)?.toDouble() ?? + (json['distance_km'] as num?)?.toDouble(), + durationMin: (json['duration_min'] as num?)?.toDouble(), + paymentMethod: json['payment_method'] as String?, + serviceClass: json['service_class'] as String?, ); } diff --git a/apps/driver_new/lib/features/trip/view/duty_page.dart b/apps/driver_new/lib/features/trip/view/duty_page.dart index 0a20fe7..e3850de 100644 --- a/apps/driver_new/lib/features/trip/view/duty_page.dart +++ b/apps/driver_new/lib/features/trip/view/duty_page.dart @@ -10,11 +10,38 @@ import '../../../core/ui/app_drawer.dart'; import '../cubit/duty_cubit.dart'; import '../cubit/duty_state.dart'; import '../data/models/geo_point.dart'; +import '../data/models/route_info.dart'; import 'widgets/active_duty_sheet.dart'; import 'widgets/duty_toggle.dart'; import 'widgets/offer_sheet.dart'; import 'widgets/ride_map.dart'; +/// نصيب الورقة السفلية من ارتفاع الشاشة — مطابق لتطبيق الراكب. +const double _sheetFraction = 0.33; + +/// ما تحتاجه الخريطة وحدها — تغيّرُ الرصيد أو الانشغال لا يعيد بناءها. +class _MapData { + const _MapData({this.me, this.pickup, this.dropoff, this.route, this.target}); + + final GeoPoint? me; + final GeoPoint? pickup; + final GeoPoint? dropoff; + final RouteInfo? route; + final GeoPoint? target; + + @override + bool operator ==(Object other) => + other is _MapData && + other.me == me && + other.pickup == pickup && + other.dropoff == dropoff && + other.route == route && + other.target == target; + + @override + int get hashCode => Object.hash(me, pickup, dropoff, route, target); +} + /// شاشة السائق: خريطة + مفتاح الاتصال أعلى + ورقة تتبدّل بالمرحلة. class DutyPage extends StatefulWidget { const DutyPage({super.key}); @@ -42,9 +69,34 @@ class _DutyPageState extends State { children: [ // الخريطة معزولة عن بقية الحالة: نبضة موقع كل 25 متراً يجب ألّا // تعيد بناء الورقة والمفتاح معها. - BlocSelector( - selector: (s) => s.myLocation, - builder: (context, me) => RideMap(origin: me, cameraTarget: me), + // + // **مرفوعة عن القاع بالثلث** كما في تطبيق الراكب — الورقة لها + // مساحتها ولا تغطّي الخريطة. + Positioned( + top: 0, + left: 0, + right: 0, + bottom: MediaQuery.sizeOf(context).height * _sheetFraction, + child: BlocSelector( + selector: (s) => _MapData( + me: s.myLocation, + pickup: s.trip?.origin, + dropoff: s.trip?.destination, + route: s.navRoute, + target: s.navTarget, + ), + // السائق كان يرى **موقعه وحده**: بلا نقطة الراكب ولا الوجهة + // ولا خط يقوده. الخريطة الآن تعرض ما تعرضه خريطة الراكب: + // نقطتَي الرحلة والمسار الحيّ إلى الهدف الحالي. + builder: (context, d) => RideMap( + origin: d.pickup ?? d.me, + destination: d.dropoff, + driver: d.pickup == null ? null : d.me, + route: d.route, + // الكاميرا تتبع الهدف حين يوجد، وإلا فموقع السائق. + cameraTarget: d.target ?? d.me, + ), + ), ), SafeArea( @@ -83,7 +135,7 @@ class _DutyPageState extends State { selector: (s) => s.creditBlocked, builder: (context, blocked) => blocked ? StatusBanner( - message: context.l10n.errorGeneric, + message: context.l10n.creditBlocked, tone: BannerTone.danger, icon: Icons.account_balance_wallet_outlined, ) @@ -132,13 +184,21 @@ class _Sheet extends StatelessWidget { ), DutyPhase.offered => OfferSheet(offer: state.offer!), DutyPhase.active => - ActiveDutySheet(trip: state.trip!, busy: state.busy), + ActiveDutySheet( + trip: state.trip!, + busy: state.busy, + navRoute: state.navRoute, + ), }; if (state.phase == DutyPhase.offline) return const SizedBox.shrink(); return Container( width: double.infinity, + // الحدّ الأدنى = المساحة المحجوزة تحت الخريطة، وإلا ظهر فراغ بينهما. + constraints: BoxConstraints( + minHeight: MediaQuery.sizeOf(context).height * _sheetFraction, + ), decoration: BoxDecoration( color: context.colors.surface, borderRadius: Radii.sheetTop, diff --git a/apps/driver_new/lib/features/trip/view/history_page.dart b/apps/driver_new/lib/features/trip/view/history_page.dart index 856e7d3..19381a1 100644 --- a/apps/driver_new/lib/features/trip/view/history_page.dart +++ b/apps/driver_new/lib/features/trip/view/history_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; import '../../../core/design/tokens.dart'; import '../../../core/design/tripz_colors.dart'; @@ -55,6 +56,16 @@ class _HistoryPageState extends State { } } +/// بطاقة رحلة في السجلّ. +/// +/// كانت ثلاثة عناصر فقط (نقطة · حالة · سعر) — بلا تاريخ ولا مسافة ولا طريقة +/// دفع، رغم أن الخادم يرسلها كلها في `GET /trips/mine` +/// (`trip.entity.ts`: `requested_at`, `distance_km`, `duration_min`, +/// `payment_method`, `service_class`). البطاقة الآن تقرأ ما كان يصل ويُهمَل. +/// +/// **العناوين النصّية غائبة عمداً**: `trip.entity.ts` يخزّن إحداثيات +/// (`origin_lat/lng`) بلا `origin_address`، فسيرو يعرض عناوين ونحن لا نقدر +/// حتى يُضاف الحقل على الخادم. عرضُ إحداثيات خام للراكب أسوأ من لا شيء. class _TripCard extends StatelessWidget { const _TripCard({required this.trip}); @@ -77,25 +88,106 @@ class _TripCard extends StatelessWidget { _ => (l10n.historyStatusLive, colors.info), }; + // سطر التفاصيل: ما توفّر منه فقط، بفواصل — رحلة ملغاة بلا مسافة لا + // تعرض «0 كم». + final details = [ + if (trip.distanceKm != null && trip.distanceKm! > 0) + l10n.rideDistanceKm(trip.distanceKm!.toStringAsFixed(1)), + if (trip.durationMin != null && trip.durationMin! > 0) + l10n.rideEta(trip.durationMin!.round().toString()), + ].join(' · '); + return TripzCard( - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration(color: color, shape: BoxShape.circle), - ), - const SizedBox(width: Space.sm), - Expanded( - child: Text(label, style: context.texts.bodyMedium), - ), - Text( - Money.format(trip.priceForPassenger ?? trip.quotedFare, - trip.currency), - style: numericStyle(context.texts.titleSmall), + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: Space.sm), + Expanded( + child: Text( + label, + style: context.texts.bodyMedium?.copyWith(color: color), + ), + ), + Text( + Money.format( + trip.priceForPassenger ?? trip.quotedFare, + trip.currency, + ), + style: numericStyle(context.texts.titleSmall), + ), + ], ), + if (trip.requestedAt != null) ...[ + const SizedBox(height: Space.xs), + Text( + DateFormat.yMMMd().add_jm().format(trip.requestedAt!.toLocal()), + style: context.texts.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + if (details.isNotEmpty || trip.paymentMethod != null) ...[ + const SizedBox(height: Space.xs), + Row( + children: [ + if (details.isNotEmpty) + Expanded( + child: Text( + details, + style: numericStyle(context.texts.bodySmall).copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ) + else + const Spacer(), + if (trip.paymentMethod != null) + _Tag(text: _paymentLabel(context, trip.paymentMethod!)), + ], + ), + ], ], ), ); } + + /// طرق الدفع المعروفة تُترجم؛ وأي قيمة جديدة من الخادم تُعرض كما هي بدل + /// أن تختفي. + String _paymentLabel(BuildContext context, String method) { + final l10n = context.l10n; + return switch (method) { + 'cash' => l10n.paymentCash, + 'wallet' => l10n.paymentWallet, + _ => method, + }; + } +} + +/// شارة صغيرة — طريقة الدفع في السجلّ. +class _Tag extends StatelessWidget { + const _Tag({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: Space.xs, + vertical: Space.xxs / 2, + ), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: Radii.field_, + ), + child: Text(text, style: context.texts.labelSmall), + ); + } } diff --git a/apps/driver_new/lib/features/trip/view/widgets/active_duty_sheet.dart b/apps/driver_new/lib/features/trip/view/widgets/active_duty_sheet.dart index da3ff32..c1924a8 100644 --- a/apps/driver_new/lib/features/trip/view/widgets/active_duty_sheet.dart +++ b/apps/driver_new/lib/features/trip/view/widgets/active_duty_sheet.dart @@ -11,16 +11,26 @@ import '../../../../core/tenant/feature_gate.dart'; import '../../../../core/ui/tripz_button.dart'; import '../../cubit/duty_cubit.dart'; import '../../data/models/trip.dart'; +import '../../data/models/route_info.dart'; import 'fare_summary.dart'; +import 'nav_eta_strip.dart'; /// الرحلة من طرف السائق: **زر واحد** يدفع آلة الحالات خطوة واحدة. /// السائق لا يختار الحالة التالية — الكيوبت يشتقّها (docs/38 §4). class ActiveDutySheet extends StatelessWidget { - const ActiveDutySheet({super.key, required this.trip, required this.busy}); + const ActiveDutySheet({ + super.key, + required this.trip, + required this.busy, + this.navRoute, + }); final Trip trip; final bool busy; + /// مسار القيادة إلى الهدف الحالي — منه شريط الوقت والمسافة. + final RouteInfo? navRoute; + @override Widget build(BuildContext context) { final l10n = context.l10n; @@ -31,6 +41,13 @@ class ActiveDutySheet extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(_statusText(context), style: context.texts.titleMedium), + if (navRoute != null) ...[ + const SizedBox(height: Space.sm), + NavEtaStrip( + minutes: navRoute!.durationMin, + km: navRoute!.distanceKm, + ), + ], if (trip.priceForDriver != null) ...[ const SizedBox(height: Space.sm), // السائق يرى **أرباحه** لا أجرة الراكب: حقلان منفصلان والفرق عمولة. @@ -42,7 +59,7 @@ class ActiveDutySheet extends StatelessWidget { label: l10n.chatTitle, icon: Icons.forum_outlined, expanded: true, - onPressed: () => context.push('${Routes.chat}?trip=\${trip.id}'), + onPressed: () => context.push('${Routes.chat}?trip=${trip.id}'), ), ], const SizedBox(height: Space.md), diff --git a/apps/driver_new/lib/features/trip/view/widgets/driver_badge.dart b/apps/driver_new/lib/features/trip/view/widgets/driver_badge.dart index cabb7ac..1c5e890 100644 --- a/apps/driver_new/lib/features/trip/view/widgets/driver_badge.dart +++ b/apps/driver_new/lib/features/trip/view/widgets/driver_badge.dart @@ -4,6 +4,7 @@ import '../../../../core/design/tokens.dart'; import '../../../../core/design/tripz_colors.dart'; import '../../../../core/design/typography.dart'; import '../../data/models/trip.dart'; +import 'vehicle_color.dart'; /// بطاقة السائق ولوحة المركبة — أهم ما يبحث عنه الراكب في الشارع، فيأخذ /// أوضح موضع وأكبر تباين. @@ -15,25 +16,35 @@ class DriverBadge extends StatelessWidget { @override Widget build(BuildContext context) { if (trip.driverName == null) return const SizedBox.shrink(); + + // اللون **يُرسم لا يُكتب فقط**: الراكب يمسح الشارع بعينه بحثاً عن سيارة + // بيضاء، لا يقرأ كلمة «أبيض». نمط سيرو (`apply_order_widget.dart:333`) + // يلوّن رمز السيارة بلون المركبة الفعلي — وهذا مقابله. + final carColor = VehicleColor.resolve( + hex: trip.vehicleColorHex, + name: trip.vehicleColor, + ); + + // سطر المركبة: الموديل واللون معاً بفاصل — أحدهما قد يغيب. + final vehicleLine = [ + if (trip.vehicleModel != null && trip.vehicleModel!.isNotEmpty) + trip.vehicleModel!, + if (trip.vehicleColor != null && trip.vehicleColor!.isNotEmpty) + trip.vehicleColor!, + ].join(' • '); + return Row( children: [ - CircleAvatar( - radius: 24, - backgroundColor: context.colors.primaryContainer, - child: Icon( - Icons.person_rounded, - color: context.colors.onPrimaryContainer, - ), - ), + _CarAvatar(color: carColor), const SizedBox(width: Space.sm), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(trip.driverName!, style: context.texts.titleSmall), - if (trip.vehicleModel != null) + if (vehicleLine.isNotEmpty) Text( - trip.vehicleModel!, + vehicleLine, style: context.texts.bodySmall?.copyWith( color: context.colors.onSurfaceVariant, ), @@ -62,6 +73,43 @@ class DriverBadge extends StatelessWidget { } } +/// رمز سيارة ملوّن بلون المركبة الحقيقي. +/// +/// اللون الفاتح (أبيض/بيج) يختفي على سطح فاتح، فتُرسم حلقة حدّ حوله دائماً — +/// وبها تبقى السيارة البيضاء مقروءة في الوضع النهاري. +class _CarAvatar extends StatelessWidget { + const _CarAvatar({this.color}); + + final Color? color; + + @override + Widget build(BuildContext context) { + final resolved = color ?? context.colors.onSurfaceVariant; + return Container( + width: 48, + height: 48, + decoration: BoxDecoration( + // ‏10% من لون السيارة خلفيةً — يربط الرمز بلونه بلا صراخ. + color: color?.withValues(alpha: 0.12) ?? + context.colors.surfaceContainerHighest, + shape: BoxShape.circle, + border: Border.all(color: context.colors.outlineVariant), + ), + child: Center( + child: Icon( + Icons.directions_car_filled_rounded, + size: Sizes.icon, + color: resolved, + // ظلّ خفيف كي لا تذوب السيارة البيضاء في الخلفية الفاتحة. + shadows: const [ + Shadow(color: Color(0x33000000), blurRadius: 2), + ], + ), + ), + ); + } +} + /// لوحة مرسومة لا نصّ عادي — تُقرأ من بعيد ومن زاوية. class _Plate extends StatelessWidget { const _Plate({required this.plate}); diff --git a/apps/driver_new/lib/features/trip/view/widgets/nav_eta_strip.dart b/apps/driver_new/lib/features/trip/view/widgets/nav_eta_strip.dart new file mode 100644 index 0000000..c9cceee --- /dev/null +++ b/apps/driver_new/lib/features/trip/view/widgets/nav_eta_strip.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; + +import '../../../../core/design/tokens.dart'; +import '../../../../core/design/tripz_colors.dart'; +import '../../../../core/l10n/l10n.dart'; + +/// «كم بقي للهدف» — الوقت والمسافة من مسار القيادة الحيّ. +/// +/// الهدف يتبدّل مع آلة الحالات: الراكب أثناء الاقتراب، ثم وجهته بعد بدء +/// الرحلة (`DutyState.navTarget`). فالشريط نفسه يخدم المرحلتين. +class NavEtaStrip extends StatelessWidget { + const NavEtaStrip({super.key, required this.minutes, required this.km}); + + final double minutes; + final double km; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Container( + padding: const EdgeInsets.symmetric( + horizontal: Space.md, + vertical: Space.sm, + ), + decoration: BoxDecoration( + color: context.tripzColors.info.withValues(alpha: 0.10), + borderRadius: Radii.card_, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.navigation_outlined, + size: Sizes.iconSm, + color: context.tripzColors.info, + ), + const SizedBox(width: Space.xs), + Text( + // أقلّ من دقيقة تُعرض «1»: «0 دقيقة» تعني «وصلتُ» وهي كذبة. + l10n.rideEta(minutes < 1 ? '1' : minutes.round().toString()), + style: context.texts.titleSmall, + ), + const SizedBox(width: Space.sm), + Text('·', style: context.texts.titleSmall), + const SizedBox(width: Space.sm), + Text( + l10n.rideDistanceKm(km.toStringAsFixed(1)), + style: context.texts.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} diff --git a/apps/driver_new/lib/features/trip/view/widgets/ride_map.dart b/apps/driver_new/lib/features/trip/view/widgets/ride_map.dart index f4dbaf2..859cc61 100644 --- a/apps/driver_new/lib/features/trip/view/widgets/ride_map.dart +++ b/apps/driver_new/lib/features/trip/view/widgets/ride_map.dart @@ -1,3 +1,5 @@ +import 'dart:developer' as dev; + import 'package:flutter/material.dart'; import 'package:intaleq_maps/intaleq_maps.dart' as im; @@ -83,6 +85,29 @@ class _RideMapState extends State { static const _fallback = im.LatLng(31.9539, 35.9106); // عمّان + /// حارس الإحداثيات — **بلا هذا يسقط التطبيق أصلاً لا استثناءً في دارت**. + /// + /// `std::domain_error` الذي رأيناه (2026-08-05) يأتي من `mbgl::LatLng` + /// في MapLibre الأصلي، وهو يرمي حرفياً عند: `latitude must not be NaN` · + /// `longitude must not be NaN` · `latitude must be between -90 and 90` · + /// `longitude must not be infinite`. (السلاسل الأربع موجودة في ثنائي + /// `MapLibre.framework`، وهي مصدر `std::domain_error` الوحيد فيه — أمّا + /// الستايل فبريء: الملفّات الخمسة كلها اجتازت `validateStyleMin` بصفر + /// أخطاء.) + /// + /// وهو استثناء **C++ يعبر حدّ المنصّة**، فلا `try/catch` في دارت يمسكه: + /// المنع قبل التمرير هو العلاج الوحيد. + static im.LatLng? _safe(GeoPoint? p, String tag) { + if (p == null) return null; + final ok = p.lat.isFinite && p.lng.isFinite && p.lat.abs() <= 90; + if (!ok) { + dev.log('إحداثية غير صالحة رُفضت [$tag]: ${p.lat}, ${p.lng}', + name: 'RideMap'); + return null; + } + return im.LatLng(p.lat, p.lng); + } + /// الستايلان مرفقان في `assets/` — منقولان من سيرو الميداني. static const _lightStyleAsset = 'assets/style.json'; static const _darkStyleAsset = 'assets/style_dark.json'; @@ -123,9 +148,43 @@ class _RideMapState extends State { } void _moveTo(GeoPoint p) { - _controller?.animateCamera( - im.CameraUpdate.newLatLngZoom(im.LatLng(p.lat, p.lng), 15.5), - ); + final target = _safe(p, 'moveTo'); + if (target == null) return; + _controller?.animateCamera(im.CameraUpdate.newLatLngZoom(target, 15.5)); + } + + /// يفكّ ترميز المسار **مصفّىً**: مسار مشوّه من الخادم (أو سلسلة مقطوعة) + /// يعطي خطوط عرض خارج ±90، وتلك تصل `mbgl::LatLng` فتُسقط التطبيق أصلاً. + static List _decodeRoute(String encoded) { + final all = im.PolylineUtils.decode(encoded); + final good = all + .where((p) => + p.latitude.isFinite && + p.longitude.isFinite && + p.latitude.abs() <= 90) + .toList(); + if (good.length != all.length) { + dev.log('نقاط مسار غير صالحة رُفضت: ${all.length - good.length}', + name: 'RideMap'); + } + return good; + } + + /// المسار كطبقة `Polyline` — مجموعة فارغة ما لم يكن الستايل جاهزاً + /// والنقاط صالحة. + Set _routePolylines(Color color) { + final encoded = widget.route?.encodedPoints; + if (!_styleReady || encoded == null || encoded.isEmpty) return const {}; + final points = _decodeRoute(encoded); + if (points.length < 2) return const {}; + return { + im.Polyline( + polylineId: const im.PolylineId('route'), + points: points, + color: color, + width: 5, + ), + }; } /// يضبط الكاميرا على المسار كاملاً بدل نقطة واحدة. @@ -135,7 +194,7 @@ class _RideMapState extends State { void _fitRoute() { final encoded = widget.route?.encodedPoints; if (encoded == null || encoded.isEmpty) return; - final points = im.PolylineUtils.decode(encoded); + final points = _decodeRoute(encoded); if (points.length < 2) return; var minLat = points.first.latitude, maxLat = points.first.latitude; @@ -147,6 +206,10 @@ class _RideMapState extends State { if (p.longitude > maxLng) maxLng = p.longitude; } + // `mbgl::EdgeInsets` يرمي هو الآخر على NaN (`bottom must not be NaN`)، + // و`bottomInset` مشتقّ من `MediaQuery` فقد يصل صفراً أو NaN في أوّل إطار. + final bottom = widget.bottomInset.isFinite ? widget.bottomInset : 280.0; + _controller?.animateCamera( im.CameraUpdate.newLatLngBounds( im.LatLngBounds( @@ -156,7 +219,7 @@ class _RideMapState extends State { left: 48, right: 48, top: 120, - bottom: widget.bottomInset, + bottom: bottom, ), ); } @@ -167,19 +230,19 @@ class _RideMapState extends State { if (controller == null || _syncing) return; _syncing = true; - Future px(GeoPoint? p) async { - if (p == null) return null; - final point = await controller.getScreenCoordinate( - im.LatLng(p.lat, p.lng), - ); + // `getScreenCoordinate` يعبر إلى `mbgl::LatLng` هو الآخر — نفس الحارس. + Future px(GeoPoint? p, String tag) async { + final target = _safe(p, tag); + if (target == null) return null; + final point = await controller.getScreenCoordinate(target); return Offset(point.x.toDouble(), point.y.toDouble()); } try { final results = await Future.wait([ - px(widget.origin), - px(widget.destination), - px(widget.driver), + px(widget.origin, 'origin'), + px(widget.destination, 'destination'), + px(widget.driver, 'driver'), ]); if (!mounted) return; setState(() { @@ -205,22 +268,19 @@ class _RideMapState extends State { // الخريطة تبدّل ستايلها مع الثيم — خريطة نهارية في وضع ليلي تكسر // الغرض (docs/26 §2). وتبديله يهدم مديري التعليقات، فيُصفَّر العَلَم. // - // ⚠️ **ستايل محلّي لا بعيد.** ستايل map-saas البعيد - // (`/api/maps/style.json?theme=light`) فيه **12 طبقة `symbol` تستعمل - // `icon-image` بلا `sprite` معرَّف** — وMapLibre الأصلي يرمي عندها - // `std::domain_error` فيُسقط التطبيق قبل أن يظهر إطار واحد - // (مثبت 2026-08-05). الستايلان المرفقان في `assets/` سليمان: بهما - // `sprite` وبلاطاتهما من `tiles.intaleqapp.com` نفسها. - // - // وهذا حرفياً حلّ سيرو الميداني: انظر + // **ستايل محلّي لا بعيد** — كما في سيرو الميداني حرفياً: // `apps/rider/lib/views/home/map_widget.dart/google_map_passenger_widget.dart` - // — يمرّر `'assets/style.json'` / `'assets/style_dark.json'` نفسهما، وملفّاه + // يمرّر `'assets/style.json'` / `'assets/style_dark.json'` نفسهما، وملفّاه // مطابقان لهذين طبقةً بطبقة (لا يختلفان إلا في اسم العلامة التجارية). - // فلا تُعِد هذا السطر إلى `IntaleqStyles.light/obsidian` — جُرّب وأسقط - // التطبيق مرتين. - // // ومعها نكسب الإقلاع بلا انتظار شبكة — وهو ما يعنيه «الشكل حتمي من أول // إقلاع» (docs/26 §1). + // + // ملاحظة تصحيحية (2026-08-05): كان مكتوباً هنا أن ستايل map-saas البعيد + // هو سبب `std::domain_error` لأنه بلا `sprite`. **هذا خطأ.** الستايلات + // الخمسة (المحلّيان + البعيد + ستايلا الحزمة) اجتازت + // `validateStyleMin` من `@maplibre/maplibre-gl-style-spec` بصفر أخطاء، + // والسقوط تكرّر بالستايل المحلّي أيضاً. المصدر الحقيقي هو حارس + // الإحداثيات في `mbgl::LatLng`/`mbgl::EdgeInsets` — انظر `_safe` أعلاه. final styleUrl = isDark ? _darkStyleAsset : _lightStyleAsset; if (_styleUrl != null && _styleUrl != styleUrl) _styleReady = false; _styleUrl = styleUrl; @@ -234,13 +294,17 @@ class _RideMapState extends State { // فنقفله من هنا بدل الاتّكال على ذلك. autoCache: false, initialCameraPosition: im.CameraPosition( - target: widget.origin == null - ? _fallback - : im.LatLng(widget.origin!.lat, widget.origin!.lng), + target: _safe(widget.origin, 'initialCamera') ?? _fallback, zoom: 15, ), styleUrl: styleUrl, - myLocationEnabled: true, + // نقطة «موقعي» تُفعَّل **بعد** أن يصلنا موقع حقيقي لا عند الإنشاء. + // قبل ذلك يكون `MLNMapView.userLocation.coordinate` هو + // `kCLLocationCoordinate2DInvalid` أي (-180, -180) — وخط عرض -180 + // يخترق حارس `mbgl::LatLng` نفسه (`latitude must be between -90 + // and 90`). و`origin` لا يصير غير فارغ إلا بعد منح الإذن ووصول + // قراءة فعلية، فهو الشرط الصحيح تماماً. + myLocationEnabled: widget.origin != null, compassEnabled: false, zoomControlsEnabled: false, onMapCreated: _onCreated, @@ -257,20 +321,9 @@ class _RideMapState extends State { } _syncPixels(); }, - // **المسار وحده annotation** — لا بديل عنه، ومحروسٌ بعَلَم الستايل. - polylines: _styleReady && widget.route != null && - widget.route!.encodedPoints.isNotEmpty - ? { - im.Polyline( - polylineId: const im.PolylineId('route'), - points: im.PolylineUtils.decode( - widget.route!.encodedPoints, - ), - color: colors.mapRoute, - width: 5, - ), - } - : const {}, + // **المسار وحده annotation** — لا بديل عنه، ومحروسٌ بعَلَم الستايل + // وبتصفية الإحداثيات (نقاط المسار تعبر إلى `mbgl::LatLng` أيضاً). + polylines: _routePolylines(colors.mapRoute), ), // ── الطبقة الفلاترية: نقاط وأسماء وسائق ────────────────────────── diff --git a/apps/driver_new/lib/features/trip/view/widgets/vehicle_color.dart b/apps/driver_new/lib/features/trip/view/widgets/vehicle_color.dart new file mode 100644 index 0000000..cd9ddc2 --- /dev/null +++ b/apps/driver_new/lib/features/trip/view/widgets/vehicle_color.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +/// يحوّل لون المركبة — hex أو اسماً — إلى لون يُرسم به. +/// +/// الخادم غير متّسق عمداً: `vehicles` عندها `color_hex` بينما حدث `assigned` +/// يرسل `color` اسماً (`trips.service.ts:427`). فالمُحوِّل يقبل الاثنين +/// ويُقدّم الـhex لأنه دقيق. +/// +/// الأسماء تُطابَق **بالعربية والإنجليزية معاً**: السائقون يُدخلونها بلغتهم، +/// وقائمة إنجليزية وحدها تعني رمادياً افتراضياً لنصف الأسطول. +class VehicleColor { + const VehicleColor._(); + + /// اللون الذي يُرسم به رمز السيارة، أو `null` إن تعذّرت المطابقة — + /// و`null` تعني «لا تلوّن» لا «لوّن رمادياً»، فالرمادي لونُ سيارةٍ حقيقي + /// ولا يصحّ أن يعني «مجهول». + static Color? resolve({String? hex, String? name}) => + _fromHex(hex) ?? _fromName(name); + + static Color? _fromHex(String? hex) { + if (hex == null) return null; + var raw = hex.replaceFirst('#', '').trim(); + if (raw.length == 3) { + // ‏`#f00` → `#ff0000`: صيغة مختصرة شائعة في لوحات الإدارة. + raw = raw.split('').map((c) => '$c$c').join(); + } + if (raw.length == 6) raw = 'FF$raw'; + if (raw.length != 8) return null; + final value = int.tryParse(raw, radix: 16); + return value == null ? null : Color(value); + } + + static const _names = { + 'white': Colors.white, + 'أبيض': Colors.white, + 'black': Colors.black, + 'أسود': Colors.black, + 'silver': Color(0xFFC0C0C0), + 'فضي': Color(0xFFC0C0C0), + 'grey': Color(0xFF808080), + 'gray': Color(0xFF808080), + 'رمادي': Color(0xFF808080), + 'red': Color(0xFFD32F2F), + 'أحمر': Color(0xFFD32F2F), + 'blue': Color(0xFF1976D2), + 'أزرق': Color(0xFF1976D2), + 'green': Color(0xFF388E3C), + 'أخضر': Color(0xFF388E3C), + 'yellow': Color(0xFFFBC02D), + 'أصفر': Color(0xFFFBC02D), + 'orange': Color(0xFFF57C00), + 'برتقالي': Color(0xFFF57C00), + 'brown': Color(0xFF6D4C41), + 'بني': Color(0xFF6D4C41), + 'beige': Color(0xFFE8DCC4), + 'بيج': Color(0xFFE8DCC4), + 'gold': Color(0xFFC9A227), + 'ذهبي': Color(0xFFC9A227), + 'navy': Color(0xFF1A2340), + 'كحلي': Color(0xFF1A2340), + }; + + static Color? _fromName(String? name) { + if (name == null) return null; + return _names[name.trim().toLowerCase()]; + } +} diff --git a/apps/driver_new/pubspec.lock b/apps/driver_new/pubspec.lock index b33a242..63c1022 100644 --- a/apps/driver_new/pubspec.lock +++ b/apps/driver_new/pubspec.lock @@ -393,10 +393,10 @@ packages: dependency: transitive description: name: geolocator_linux - sha256: d64112a205931926f4363bb6bd48f14cb38e7326833041d170615586cd143797 + sha256: c4e966f0a7a87e70049eac7a2617f9e16fd4c585a26e4330bdfc3a71e6a721f3 url: "https://pub.dev" source: hosted - version: "0.2.4" + version: "0.2.3" geolocator_platform_interface: dependency: transitive description: @@ -702,13 +702,13 @@ packages: source: hosted version: "3.0.0" package_info_plus: - dependency: transitive + dependency: "direct main" description: name: package_info_plus - sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" url: "https://pub.dev" source: hosted - version: "9.0.1" + version: "8.3.1" package_info_plus_platform_interface: dependency: transitive description: diff --git a/apps/driver_new/pubspec.yaml b/apps/driver_new/pubspec.yaml index d3e3632..0fcddfc 100644 --- a/apps/driver_new/pubspec.yaml +++ b/apps/driver_new/pubspec.yaml @@ -51,6 +51,10 @@ dependencies: permission_handler: ^12.0.1 device_info_plus: 12.3.0 + # رقم الإصدار في شاشة الإعدادات — كانت موجودة كاعتماد غير مباشر أصلاً، + # فترقيتها لمباشر لا تجرّ كوداً أصلياً جديداً. + package_info_plus: ^8.1.1 + cupertino_icons: ^1.0.8 # باقي الحزم تُضاف في مرحلتها لا قبلها (docs/37 §1.7): diff --git a/apps/rider_new/lib/core/build_config.dart b/apps/rider_new/lib/core/build_config.dart index bfa0f0d..49292cc 100644 --- a/apps/rider_new/lib/core/build_config.dart +++ b/apps/rider_new/lib/core/build_config.dart @@ -23,11 +23,32 @@ class BuildConfig { static const String logoAsset = 'assets/images/logo.png'; static const String splashLogoAsset = 'assets/images/splash_logo.png'; - /// روابط خارجية لكل مستأجر — تُولَّد مع بقية الملف (N3). - static const String storeUrl = String.fromEnvironment( - 'STORE_URL', - defaultValue: 'https://tripz.app', + /// روابط المتاجر الأربعة — **صفحات المتجر الحقيقية المنشورة**، أعطاها + /// المالك 2026-08-05. لا تُستعمل مباشرةً: `StoreLinks` هو من يختار الرابط + /// حسب المنصّة والدور، لأن «قيّم التطبيق» على آيفون سائق ≠ نفس الرابط على + /// أندرويد راكب. + /// + /// المعرّفات تطابق هوية النشر المقفلة: `com.mobileapp.store.ride` للراكب + /// و`com.sefer_driver` للسائق. + static const String riderIosUrl = String.fromEnvironment( + 'RIDER_IOS_URL', + defaultValue: 'https://apps.apple.com/eg/app/tripz-rider/id6458734951', ); + static const String driverIosUrl = String.fromEnvironment( + 'DRIVER_IOS_URL', + defaultValue: 'https://apps.apple.com/eg/app/tripz-driver/id6502189302', + ); + static const String riderAndroidUrl = String.fromEnvironment( + 'RIDER_ANDROID_URL', + defaultValue: + 'https://play.google.com/store/apps/details?id=com.mobileapp.store.ride', + ); + static const String driverAndroidUrl = String.fromEnvironment( + 'DRIVER_ANDROID_URL', + defaultValue: + 'https://play.google.com/store/apps/details?id=com.sefer_driver', + ); + static const String privacyUrl = String.fromEnvironment( 'PRIVACY_URL', defaultValue: 'https://tripz.app/privacy', @@ -38,12 +59,6 @@ class BuildConfig { 'SUPPORT_PHONE', defaultValue: '962790000000', ); - - /// رابط تطبيق الطرف الآخر — الراكب يدعو نفسه ليصير سائقاً والعكس. - static const String driverAppUrl = String.fromEnvironment( - 'DRIVER_APP_URL', - defaultValue: 'https://tripz.app/driver', - ); } /// أعلام الاستحقاقات (docs/23 §5). **`const` إجبارياً** كي يُحذف كود الميزة diff --git a/apps/rider_new/lib/core/di.dart b/apps/rider_new/lib/core/di.dart index 76dc0ee..b6c885a 100644 --- a/apps/rider_new/lib/core/di.dart +++ b/apps/rider_new/lib/core/di.dart @@ -25,6 +25,8 @@ import 'api/antlaq_api.dart'; import 'api/api_client.dart'; import 'build_config.dart'; import 'location/location_service.dart'; +import '../features/trip/data/recent_places_store.dart'; +import 'notifications/notification_store.dart'; import 'notifications/push_service.dart'; import 'realtime/realtime_service.dart'; import 'session/session_cubit.dart'; @@ -79,7 +81,12 @@ Future setupInjector() async { sl.registerSingleton(RealtimeService(tokens)); sl.registerSingleton(TenantCubit(sl(), prefs)); - sl.registerSingleton(PushService(sl())); + + // الصندوق يُحمَّل قبل `PushService` كي لا يضيع إشعارٌ يصل أثناء الإقلاع. + final notifications = NotificationStore(); + await notifications.load(); + sl.registerSingleton(notifications); + sl.registerSingleton(PushService(sl(), notifications)); sl.registerSingleton(HistoryRepository(sl())); sl.registerFactory(() => HistoryCubit(sl())); @@ -105,11 +112,18 @@ Future setupInjector() async { sl.registerFactory(() => WalletCubit(sl())); } + // الأماكن الأخيرة تُحمَّل مرة واحدة عند الإقلاع: الشاشة الرئيسية تقرأها + // في أول إطار، وقراءةُ القرص وقتها تعني صفّاً يقفز بعد الرسم. + final recents = RecentPlacesStore(); + await recents.load(); + sl.registerSingleton(recents); + sl.registerFactory(() => RideCubit( trips: sl(), maps: sl(), location: sl(), realtime: sl(), + recents: sl(), )); } diff --git a/apps/rider_new/lib/core/l10n/app_ar.arb b/apps/rider_new/lib/core/l10n/app_ar.arb index f7cc989..ed6e96a 100644 --- a/apps/rider_new/lib/core/l10n/app_ar.arb +++ b/apps/rider_new/lib/core/l10n/app_ar.arb @@ -65,6 +65,10 @@ "settingsLanguage": "اللغة", "settingsLanguageArabic": "العربية", "settingsLanguageEnglish": "English", + "settingsSectionGeneral": "عام", + "settingsSectionSupport": "الدعم والمعلومات", + "settingsSupportLead": "أسئلة شائعة وطرق التواصل", + "settingsShareLead": "شارك التطبيق مع أصدقائك", "rideWhereTo": "إلى أين؟", "rideOrigin": "من", "rideDestination": "إلى", @@ -131,6 +135,8 @@ "rateSubmit": "إرسال التقييم", "rateSkip": "لاحقاً", "rateAlready": "سبق أن قيّمت هذه الرحلة", + "tripCall": "اتصال", + "tripMessage": "رسالة", "tripFareTitle": "تفاصيل الأجرة", "tripFare": "الأجرة", "tripEarnings": "أرباحك", @@ -169,9 +175,14 @@ "menuEarnings": "أرباحي", "menuSettings": "الإعدادات", "menuSupport": "المساعدة", + "menuNotifications": "الإشعارات", + "notificationsEmpty": "لا إشعارات بعد — سنُعلمك هنا بكل ما يخصّ رحلاتك", + "notificationsClear": "مسح الكل", "menuAbout": "عن التطبيق", "menuLogout": "تسجيل الخروج", "menuLogoutConfirm": "هل تريد الخروج من حسابك؟", + "paymentCash": "نقداً", + "paymentWallet": "من المحفظة", "historyTitle": "رحلاتي", "historyEmpty": "لا رحلات بعد — اطلب أول رحلة لك", "historyStatusCompleted": "مكتملة", @@ -224,12 +235,24 @@ "menuNotifications": "الإشعارات", "menuPrivacy": "سياسة الخصوصية", "menuRateApp": "قيّم التطبيق", - "shareMessage": "جرّب تطبيق Tripz — استعمل كودي {code} واحصل على خصم أول رحلة", + "shareMessage": "جرّب تطبيق Tripz — استعمل كودي {code} واحصل على خصم أول رحلة\n{link}", + "shareMessagePlain": "جرّب تطبيق Tripz\n{link}", + "menuRiderApp": "حمّل تطبيق الراكب", "menuDriverApp": "سجّل كسائق", "@shareMessage": { "placeholders": { "code": { "type": "String" + }, + "link": { + "type": "String" + } + } + }, + "@shareMessagePlain": { + "placeholders": { + "link": { + "type": "String" } } }, diff --git a/apps/rider_new/lib/core/l10n/app_en.arb b/apps/rider_new/lib/core/l10n/app_en.arb index 5d168ba..322a200 100644 --- a/apps/rider_new/lib/core/l10n/app_en.arb +++ b/apps/rider_new/lib/core/l10n/app_en.arb @@ -65,6 +65,10 @@ "settingsLanguage": "Language", "settingsLanguageArabic": "العربية", "settingsLanguageEnglish": "English", + "settingsSectionGeneral": "General", + "settingsSectionSupport": "Support & info", + "settingsSupportLead": "FAQ and ways to reach us", + "settingsShareLead": "Share the app with your friends", "rideWhereTo": "Where to?", "rideOrigin": "From", "rideDestination": "To", @@ -131,6 +135,8 @@ "rateSubmit": "Submit rating", "rateSkip": "Later", "rateAlready": "You already rated this trip", + "tripCall": "Call", + "tripMessage": "Message", "tripFareTitle": "Fare breakdown", "tripFare": "Fare", "tripEarnings": "Your earnings", @@ -169,9 +175,14 @@ "menuEarnings": "Earnings", "menuSettings": "Settings", "menuSupport": "Help", + "menuNotifications": "Notifications", + "notificationsEmpty": "No notifications yet — we'll keep you posted about your trips here", + "notificationsClear": "Clear all", "menuAbout": "About", "menuLogout": "Sign out", "menuLogoutConfirm": "Sign out of your account?", + "paymentCash": "Cash", + "paymentWallet": "Wallet", "historyTitle": "My trips", "historyEmpty": "No trips yet — request your first ride", "historyStatusCompleted": "Completed", @@ -224,12 +235,24 @@ "menuNotifications": "Notifications", "menuPrivacy": "Privacy policy", "menuRateApp": "Rate the app", - "shareMessage": "Try Tripz — use my code {code} for a discount on your first ride", + "shareMessage": "Try Tripz — use my code {code} for a discount on your first ride\n{link}", + "shareMessagePlain": "Try Tripz\n{link}", + "menuRiderApp": "Get the rider app", "menuDriverApp": "Become a driver", "@shareMessage": { "placeholders": { "code": { "type": "String" + }, + "link": { + "type": "String" + } + } + }, + "@shareMessagePlain": { + "placeholders": { + "link": { + "type": "String" } } }, diff --git a/apps/rider_new/lib/core/notifications/notification_store.dart b/apps/rider_new/lib/core/notifications/notification_store.dart new file mode 100644 index 0000000..129a1e9 --- /dev/null +++ b/apps/rider_new/lib/core/notifications/notification_store.dart @@ -0,0 +1,104 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +/// إشعار وصل إلى الجهاز — يُحفظ محلياً لأن الخادم لا يسرد شيئاً. +class AppNotification { + const AppNotification({ + required this.title, + required this.body, + required this.receivedAt, + this.read = false, + }); + + final String title; + final String body; + final DateTime receivedAt; + final bool read; + + AppNotification copyWith({bool? read}) => AppNotification( + title: title, + body: body, + receivedAt: receivedAt, + read: read ?? this.read, + ); + + Map toJson() => { + 't': title, + 'b': body, + 'at': receivedAt.toIso8601String(), + 'r': read, + }; + + factory AppNotification.fromJson(Map json) => + AppNotification( + title: json['t'] as String? ?? '', + body: json['b'] as String? ?? '', + receivedAt: + DateTime.tryParse(json['at']?.toString() ?? '') ?? DateTime.now(), + read: json['r'] as bool? ?? false, + ); +} + +/// صندوق الإشعارات — **محلّي بالكامل وعن قصد**. +/// +/// الخادم يوفّر `POST /notifications/token` فقط ولا نقطة سرد +/// (`backend-archive/src/modules/notifications`, docs/38 §11). فبدل شاشة +/// فارغة تعتمد على نقطة غير موجودة، نحفظ ما يصل فعلاً عبر FCM بينما التطبيق +/// يعمل. سيرو يعرض `NotificationPage` في الدراور +/// (`map_menu_widget.dart:354`) — هذا مقابلها الصادق. +/// +/// **حدّ معروف:** ما يصل والتطبيق مغلق تماماً لا يُلتقط — لا `background +/// handler` مسجَّلاً. حين تُضاف نقطة سرد على الخادم، يُستبدل هذا المصدر +/// بها ويبقى نفس عرض الشاشة. +class NotificationStore { + static const _key = 'notifications_inbox'; + + /// سقف الاحتفاظ — صندوق بلا حدّ يتضخّم بلا فائدة. + static const _max = 50; + + List _items = const []; + List get items => _items; + + int get unreadCount => _items.where((n) => !n.read).length; + + Future load() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_key); + if (raw == null || raw.isEmpty) return; + try { + final list = jsonDecode(raw) as List; + _items = list + .whereType>() + .map(AppNotification.fromJson) + .toList(growable: false); + } catch (_) { + // تخزين تالف لا يمنع فتح الشاشة — يُهمل ويُكتب فوقه عند أول إشعار. + _items = const []; + } + } + + Future add(AppNotification notification) async { + _items = [notification, ..._items].take(_max).toList(growable: false); + await _persist(); + } + + Future markAllRead() async { + if (_items.every((n) => n.read)) return; + _items = _items.map((n) => n.copyWith(read: true)).toList(growable: false); + await _persist(); + } + + Future clear() async { + _items = const []; + await _persist(); + } + + Future _persist() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _key, + jsonEncode(_items.map((n) => n.toJson()).toList(growable: false)), + ); + } +} diff --git a/apps/rider_new/lib/core/notifications/push_service.dart b/apps/rider_new/lib/core/notifications/push_service.dart index 8001472..03922fb 100644 --- a/apps/rider_new/lib/core/notifications/push_service.dart +++ b/apps/rider_new/lib/core/notifications/push_service.dart @@ -4,20 +4,23 @@ import 'package:flutter/foundation.dart'; import '../api/api_client.dart'; import '../api/api_exception.dart'; +import 'notification_store.dart'; /// إشعارات FCM — **الطبقة الوحيدة التي تعرف Firebase**. /// /// عقد الخادم نقطة واحدة: `POST /notifications/token { token, platform }` -/// (docs/38 §11). لا نقطة سرد إشعارات، فلا شاشة «صندوق إشعارات» تُبنى على -/// وهم — ما يصل يصل من النظام. +/// (docs/38 §11). لا نقطة **سرد** إشعارات، فالصندوق محلّي: ما يصل أثناء عمل +/// التطبيق يُحفظ في [NotificationStore] ويُعرض في `NotificationsPage` — +/// انظر تعليق ذلك الصنف لحدود هذا الاختيار. /// /// الإعداد الأصلي جاهز مسبقاً: `google-services.json` و /// `GoogleService-Info.plist` منقولان مع الطبقة الأصلية، و`build.gradle` /// يطبّق `com.google.gms.google-services` أصلاً. class PushService { - PushService(this._api); + PushService(this._api, this._store); final ApiClient _api; + final NotificationStore _store; /// يُنادى **بعد** الدخول: التسجيل يربط الجهاز بالمستخدم، فقبل الجلسة /// لا معنى له. @@ -35,11 +38,30 @@ class PushService { // الرمز يُدوَّر من Google دورياً — بلا هذا الاشتراك تتوقّف الإشعارات // بصمت بعد أسابيع. messaging.onTokenRefresh.listen(_send); + + // ما يصل والتطبيق مفتوح، وما فُتح التطبيق بالضغط عليه. + FirebaseMessaging.onMessage.listen(_capture); + FirebaseMessaging.onMessageOpenedApp.listen(_capture); + final initial = await messaging.getInitialMessage(); + if (initial != null) await _capture(initial); } catch (_) { // غياب الإشعارات لا يمنع استعمال التطبيق — لا يُسقط الإقلاع. } } + /// إشعار بلا عنوان ولا نصّ (رسالة بيانات صرفة) لا يُعرض — سطر فارغ في + /// الصندوق أسوأ من لا شيء. + Future _capture(RemoteMessage message) async { + final title = message.notification?.title ?? ''; + final body = message.notification?.body ?? ''; + if (title.isEmpty && body.isEmpty) return; + await _store.add(AppNotification( + title: title, + body: body, + receivedAt: message.sentTime ?? DateTime.now(), + )); + } + Future _send(String token) async { try { await _api.post>( diff --git a/apps/rider_new/lib/core/router.dart b/apps/rider_new/lib/core/router.dart index 309675c..5f2d928 100644 --- a/apps/rider_new/lib/core/router.dart +++ b/apps/rider_new/lib/core/router.dart @@ -8,6 +8,7 @@ import '../features/chat/view/chat_page.dart'; import '../features/profile/view/profile_edit_page.dart'; import '../features/profile/view/profile_setup_page.dart'; import '../features/rating/data/rating_repository.dart'; +import '../features/notifications/view/notifications_page.dart'; import '../features/rewards/view/rewards_page.dart'; import '../features/rating/view/rating_page.dart'; import '../features/settings/view/settings_page.dart'; @@ -34,6 +35,7 @@ class Routes { static const support = '/support'; static const rewards = '/rewards'; static const chat = '/chat'; + static const notifications = '/notifications'; } /// التوجيه يُشتقّ من `SessionCubit` وحده — لا `Navigator.push` بعد الدخول @@ -120,6 +122,10 @@ final appRouter = GoRouter( path: Routes.support, builder: (context, state) => const SupportPage(), ), + GoRoute( + path: Routes.notifications, + builder: (context, state) => const NotificationsPage(), + ), GoRoute( path: Routes.home, builder: (context, state) => const RidePage(), diff --git a/apps/rider_new/lib/core/store_links.dart b/apps/rider_new/lib/core/store_links.dart new file mode 100644 index 0000000..6b3d8a9 --- /dev/null +++ b/apps/rider_new/lib/core/store_links.dart @@ -0,0 +1,37 @@ +import 'package:flutter/foundation.dart'; + +import 'build_config.dart'; +import 'config.dart'; + +/// اختيار رابط المتجر الصحيح — **بالمنصّة والدور معاً**. +/// +/// كان الخطأ (مثبت 2026-08-05): رابط واحد ثابت `https://tripz.app` لكل +/// شيء — فـ«قيّم التطبيق» لا يفتح صفحة تقييم، و«شارك التطبيق» يرسل رسالة +/// بلا رابط أصلاً، و«سجّل كسائق» يفتح صفحة تسويقية لا تطبيق السائق. +/// +/// أربع حالات لا واحدة: {آيفون، أندرويد} × {راكب، سائق}. والدور يأتي من +/// [AppConfig.appRole] الذي يميّز البناءين أصلاً. +class StoreLinks { + const StoreLinks._(); + + static bool get _isIos => defaultTargetPlatform == TargetPlatform.iOS; + + static bool get _isDriverBuild => AppConfig.appRole == 'driver'; + + /// رابط تطبيق الراكب على منصّة هذا الجهاز. + static String get rider => + _isIos ? BuildConfig.riderIosUrl : BuildConfig.riderAndroidUrl; + + /// رابط تطبيق السائق على منصّة هذا الجهاز. + static String get driver => + _isIos ? BuildConfig.driverIosUrl : BuildConfig.driverAndroidUrl; + + /// **هذا** التطبيق في متجر هذا الجهاز — لـ«قيّم التطبيق» ولرابط المشاركة. + /// + /// السائق يشارك رابط السائق، والراكب رابط الراكب: مشاركةُ راكبٍ لرابط + /// السائق تُنزِّل المدعوَّ تطبيقاً خاطئاً. + static String get thisApp => _isDriverBuild ? driver : rider; + + /// تطبيق الطرف الآخر — «سجّل كسائق» في تطبيق الراكب، والعكس. + static String get otherApp => _isDriverBuild ? rider : driver; +} diff --git a/apps/rider_new/lib/core/ui/app_drawer.dart b/apps/rider_new/lib/core/ui/app_drawer.dart index dafef25..55e0b71 100644 --- a/apps/rider_new/lib/core/ui/app_drawer.dart +++ b/apps/rider_new/lib/core/ui/app_drawer.dart @@ -8,10 +8,13 @@ import '../build_config.dart'; import '../config.dart'; import '../design/tokens.dart'; import '../design/tripz_colors.dart'; +import '../di.dart'; import '../l10n/l10n.dart'; +import '../notifications/notification_store.dart'; import '../router.dart'; import '../session/session_cubit.dart'; import '../session/session_state.dart'; +import '../store_links.dart'; import '../tenant/feature_gate.dart'; /// الدراور — نقطة تطبيق **طبقتَي الميزات** معاً (docs/23 §5 + docs/38 §10): @@ -43,6 +46,14 @@ class AppDrawer extends StatelessWidget { label: l10n.menuProfile, route: Routes.profileEdit, ), + _Item( + icon: Icons.notifications_none_rounded, + label: l10n.menuNotifications, + route: Routes.notifications, + // العدّاد يُقرأ عند بناء الدراور — والدراور يُبنى عند كل + // فتح، فلا حاجة لتيّار يراقب مخزناً في الذاكرة. + badge: sl().unreadCount, + ), _Item( icon: Icons.route_outlined, label: l10n.menuTrips, @@ -72,28 +83,36 @@ class AppDrawer extends StatelessWidget { route: Routes.support, ), // مداخل محليّة بلا نقطة خادم — تُنفَّذ في الجهاز مباشرة. + // + // الروابط من `StoreLinks` لا من ثابتٍ واحد: هذا البناء + // يشارك **رابط نفسه** (سائق يشارك رابط السائق)، ويفتح + // متجر منصّته هو. _Action( icon: Icons.share_outlined, label: l10n.menuShare, - onTap: () => SharePlus.instance - .share(ShareParams(text: l10n.shareMessage(''))), + onTap: () => SharePlus.instance.share( + ShareParams( + text: l10n.shareMessagePlain(StoreLinks.thisApp), + ), + ), ), _Action( icon: Icons.star_outline_rounded, label: l10n.menuRateApp, - onTap: () => _open(BuildConfig.storeUrl), + onTap: () => _open(StoreLinks.thisApp), ), _Action( icon: Icons.shield_outlined, label: l10n.menuPrivacy, onTap: () => _open(BuildConfig.privacyUrl), ), - if (!isDriver) - _Action( - icon: Icons.local_taxi_outlined, - label: l10n.menuDriverApp, - onTap: () => _open(BuildConfig.driverAppUrl), - ), + _Action( + icon: isDriver + ? Icons.person_outline_rounded + : Icons.local_taxi_outlined, + label: isDriver ? l10n.menuRiderApp : l10n.menuDriverApp, + onTap: () => _open(StoreLinks.otherApp), + ), ], ), ), @@ -220,17 +239,43 @@ class _Action extends StatelessWidget { } class _Item extends StatelessWidget { - const _Item({required this.icon, required this.label, required this.route}); + const _Item({ + required this.icon, + required this.label, + required this.route, + this.badge = 0, + }); final IconData icon; final String label; final String route; + /// عدّاد غير المقروء — يُخفى عند الصفر. + final int badge; + @override Widget build(BuildContext context) { return ListTile( leading: Icon(icon), title: Text(label), + trailing: badge == 0 + ? null + : Container( + padding: const EdgeInsets.symmetric( + horizontal: Space.xs, + vertical: Space.xxs / 2, + ), + decoration: BoxDecoration( + color: context.tripzColors.danger, + borderRadius: Radii.field_, + ), + child: Text( + '$badge', + style: context.texts.labelSmall?.copyWith( + color: context.colors.onPrimary, + ), + ), + ), onTap: () { Navigator.of(context).pop(); context.push(route); diff --git a/apps/rider_new/lib/features/notifications/view/notifications_page.dart b/apps/rider_new/lib/features/notifications/view/notifications_page.dart new file mode 100644 index 0000000..3bc1a53 --- /dev/null +++ b/apps/rider_new/lib/features/notifications/view/notifications_page.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../../core/design/tokens.dart'; +import '../../../core/design/tripz_colors.dart'; +import '../../../core/di.dart'; +import '../../../core/l10n/l10n.dart'; +import '../../../core/notifications/notification_store.dart'; +import '../../../core/ui/empty_view.dart'; +import '../../../core/ui/tripz_card.dart'; +import '../../../core/ui/tripz_scaffold.dart'; +import '../../../core/ui/view_status.dart'; + +/// صندوق الإشعارات — يقابل `NotificationPage` في دراور سيرو +/// (`map_menu_widget.dart:354`). +/// +/// المصدر [NotificationStore] محلّي لا شبكي (السبب في تعليق ذلك الصنف). +/// فلا كيوبت ولا مستودع: قراءة واحدة عند الفتح ثم `setState` — إضافة طبقة +/// حالة لمصدرٍ في الذاكرة تعقيد بلا مقابل. +class NotificationsPage extends StatefulWidget { + const NotificationsPage({super.key}); + + @override + State createState() => _NotificationsPageState(); +} + +class _NotificationsPageState extends State { + final NotificationStore _store = sl(); + + @override + void initState() { + super.initState(); + // فتح الشاشة هو القراءة — لا زرّ «تعليم كمقروء» منفصل. + _store.markAllRead(); + } + + Future _clear() async { + await _store.clear(); + if (mounted) setState(() {}); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final items = _store.items; + + return TripzScaffold( + title: l10n.menuNotifications, + status: items.isEmpty ? ViewStatus.empty : ViewStatus.success, + empty: EmptyView( + icon: Icons.notifications_none_rounded, + message: l10n.notificationsEmpty, + ), + actions: [ + if (items.isNotEmpty) + IconButton( + icon: const Icon(Icons.delete_outline_rounded), + tooltip: l10n.notificationsClear, + onPressed: _clear, + ), + ], + child: ListView.separated( + itemCount: items.length, + separatorBuilder: (context, i) => const SizedBox(height: Space.xs), + itemBuilder: (context, i) => _NotificationCard(item: items[i]), + ), + ); + } +} + +class _NotificationCard extends StatelessWidget { + const _NotificationCard({required this.item}); + + final AppNotification item; + + @override + Widget build(BuildContext context) { + return TripzCard( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.notifications_active_outlined, + size: Sizes.iconSm, + color: context.colors.primary, + ), + const SizedBox(width: Space.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (item.title.isNotEmpty) + Text(item.title, style: context.texts.titleSmall), + if (item.body.isNotEmpty) ...[ + const SizedBox(height: Space.xxs), + Text( + item.body, + style: context.texts.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + const SizedBox(height: Space.xxs), + Text( + DateFormat.yMd().add_jm().format(item.receivedAt.toLocal()), + style: context.texts.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/rider_new/lib/features/profile/view/profile_edit_page.dart b/apps/rider_new/lib/features/profile/view/profile_edit_page.dart index c14f0a4..b9c10a5 100644 --- a/apps/rider_new/lib/features/profile/view/profile_edit_page.dart +++ b/apps/rider_new/lib/features/profile/view/profile_edit_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../core/design/tokens.dart'; import '../../../core/design/tripz_colors.dart'; +import '../../../core/design/typography.dart'; import '../../../core/di.dart'; import '../../../core/l10n/l10n.dart'; import '../../../core/session/session_cubit.dart'; @@ -54,6 +55,16 @@ class _ProfileEditPageState extends State { ), child: ListView( children: [ + // ترويسة الحساب — الاسم والرقم والتقييم في مكان واحد. + // **التقييم كان يصل ولا يُعرض أبداً**: `AppUser.rating` مقروء + // من `GET /users/me` (docs/38 §2) ولم تكن أي شاشة تستعمله، + // بينما الراكب يُقيَّم من السائق ويحقّ له أن يرى نتيجته. + _ProfileHeader( + name: user?.name ?? '', + phone: user?.phone ?? '', + rating: user?.rating, + ), + const SizedBox(height: Space.lg), TripzTextField( controller: _name, label: l10n.profileNameLabel, @@ -91,3 +102,78 @@ class _ProfileEditPageState extends State { messenger.showSnackBar(SnackBar(content: Text(l10n.profileSaved))); } } + +/// ترويسة الحساب: حرف الاسم الأول في دائرة، ثم الاسم والرقم والتقييم. +/// +/// لا صورة شخصية: الخادم لا يوفّر رفع صور للمستخدم (سيرو يفعل عبر PHP)، +/// وحرفُ الاسم أصدق من رمزٍ عام يوحي بإمكانية لا توجد. +class _ProfileHeader extends StatelessWidget { + const _ProfileHeader({ + required this.name, + required this.phone, + this.rating, + }); + + final String name; + final String phone; + final String? rating; + + @override + Widget build(BuildContext context) { + final initial = name.trim().isEmpty ? '?' : name.trim().characters.first; + return Row( + children: [ + CircleAvatar( + radius: 32, + backgroundColor: context.colors.primaryContainer, + child: Text( + initial, + style: context.texts.headlineSmall?.copyWith( + color: context.colors.onPrimaryContainer, + ), + ), + ), + const SizedBox(width: Space.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (name.trim().isNotEmpty) + Text( + name, + style: context.texts.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + phone, + // الرقم يُقرأ يساراً-يميناً حتى في واجهة عربية. + textDirection: TextDirection.ltr, + style: numericStyle(context.texts.bodySmall).copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + if (rating != null && rating!.isNotEmpty) ...[ + const SizedBox(height: Space.xxs), + Row( + children: [ + Icon( + Icons.star_rounded, + size: Sizes.iconSm, + color: context.tripzColors.warning, + ), + const SizedBox(width: Space.xxs), + Text( + rating!, + style: numericStyle(context.texts.bodySmall), + ), + ], + ), + ], + ], + ), + ), + ], + ); + } +} diff --git a/apps/rider_new/lib/features/rewards/view/rewards_page.dart b/apps/rider_new/lib/features/rewards/view/rewards_page.dart index 1daedea..f1ce8fc 100644 --- a/apps/rider_new/lib/features/rewards/view/rewards_page.dart +++ b/apps/rider_new/lib/features/rewards/view/rewards_page.dart @@ -8,6 +8,7 @@ import '../../../core/design/tripz_colors.dart'; import '../../../core/design/typography.dart'; import '../../../core/di.dart'; import '../../../core/l10n/l10n.dart'; +import '../../../core/store_links.dart'; import '../../../core/ui/tripz_button.dart'; import '../../../core/ui/tripz_card.dart'; import '../../../core/ui/tripz_scaffold.dart'; @@ -155,8 +156,11 @@ class _InviteCard extends StatelessWidget { TripzButton.primary( label: l10n.menuShare, icon: Icons.share_outlined, - onPressed: () => - SharePlus.instance.share(ShareParams(text: l10n.shareMessage(code))), + // الكود بلا رابط لا ينفع: المدعوّ لا يعرف من أين يُنزّل. ورابطُ + // **هذا** البناء لا رابطٌ ثابت — سائق يدعو سائقاً وراكب راكباً. + onPressed: () => SharePlus.instance.share( + ShareParams(text: l10n.shareMessage(code, StoreLinks.thisApp)), + ), ), const SizedBox(height: Space.md), Row( diff --git a/apps/rider_new/lib/features/settings/view/settings_page.dart b/apps/rider_new/lib/features/settings/view/settings_page.dart index 8c313d8..c654a53 100644 --- a/apps/rider_new/lib/features/settings/view/settings_page.dart +++ b/apps/rider_new/lib/features/settings/view/settings_page.dart @@ -1,15 +1,30 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../../../core/build_config.dart'; import '../../../core/design/tokens.dart'; import '../../../core/design/tripz_colors.dart'; +import '../../../core/design/typography.dart'; +import '../../../core/di.dart'; import '../../../core/l10n/l10n.dart'; +import '../../../core/router.dart'; +import '../../../core/session/session_cubit.dart'; +import '../../../core/store_links.dart'; import '../../../core/ui/tripz_scaffold.dart'; +import '../../profile/data/profile_repository.dart'; import '../cubit/settings_cubit.dart'; import '../cubit/settings_state.dart'; -/// المظهر واللغة. تبديل اللغة **يقلب الاتجاه كاملاً** بلا إعادة تشغيل — -/// `MaterialApp` يشتقّ RTL/LTR من الـlocale (docs/26 §4). +/// المظهر واللغة ومعلومات التطبيق — بأقسام كما في سيرو +/// (`setting_page.dart`: General · Preferences · Support & Info) بدل قائمة +/// مسطّحة بلا عناوين. +/// +/// تبديل اللغة **يقلب الاتجاه كاملاً** بلا إعادة تشغيل — `MaterialApp` +/// يشتقّ RTL/LTR من الـlocale (docs/26 §4). class SettingsPage extends StatelessWidget { const SettingsPage({super.key}); @@ -23,8 +38,8 @@ class SettingsPage extends StatelessWidget { title: l10n.settingsTitle, child: ListView( children: [ - Text(l10n.settingsTheme, style: context.texts.titleMedium), - const SizedBox(height: Space.xs), + _SectionHeader(title: l10n.settingsSectionGeneral), + Text(l10n.settingsTheme, style: context.texts.titleSmall), RadioGroup( groupValue: state.themeMode, onChanged: (v) => cubit.setThemeMode(v ?? ThemeMode.system), @@ -48,12 +63,15 @@ class SettingsPage extends StatelessWidget { ], ), ), - const Divider(height: Space.xl), - Text(l10n.settingsLanguage, style: context.texts.titleMedium), - const SizedBox(height: Space.xs), + const SizedBox(height: Space.md), + Text(l10n.settingsLanguage, style: context.texts.titleSmall), RadioGroup( groupValue: state.locale.languageCode, - onChanged: (v) => cubit.setLocale(Locale(v ?? 'ar')), + // اللغة تُحفظ محلياً **وتُرسل للخادم**: الإشعارات تُصاغ على + // الخادم بلغة المستخدم المخزّنة عنده (`PATCH /users/me` + // يقبل `language` — docs/38 §3)، فلغةٌ محليّة وحدها تعني + // واجهةً عربية وإشعاراتٍ إنجليزية. + onChanged: (v) => _setLanguage(context, v ?? 'ar'), child: Column( children: [ RadioListTile( @@ -69,10 +87,127 @@ class SettingsPage extends StatelessWidget { ], ), ), + + _SectionHeader(title: l10n.settingsSectionSupport), + _Tile( + icon: Icons.help_outline_rounded, + title: l10n.menuSupport, + subtitle: l10n.settingsSupportLead, + onTap: () => context.push(Routes.support), + ), + _Tile( + icon: Icons.share_outlined, + title: l10n.menuShare, + subtitle: l10n.settingsShareLead, + onTap: () => SharePlus.instance.share( + ShareParams(text: l10n.shareMessagePlain(StoreLinks.thisApp)), + ), + ), + _Tile( + icon: Icons.shield_outlined, + title: l10n.menuPrivacy, + onTap: () async { + final uri = Uri.tryParse(BuildConfig.privacyUrl); + if (uri == null) return; + await launchUrl(uri, mode: LaunchMode.externalApplication); + }, + ), + const _VersionTile(), ], ), ); }, ); } + + /// فشل الخادم **لا يمنع** تبديل اللغة محلياً: الواجهة تستجيب فوراً، + /// والمزامنة تُعاد عند التبديل التالي. عكسه يعني زرّاً لا يستجيب بلا شبكة. + static Future _setLanguage(BuildContext context, String code) async { + await context.read().setLocale(Locale(code)); + try { + final user = await sl().update(language: code); + sl().onProfileCompleted(user); + } catch (_) { + // المزامنة أفضل-جهد — لا رسالة خطأ على تبديل نجح محلياً. + } + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: Space.lg, bottom: Space.xs), + child: Text( + title, + style: context.texts.labelMedium?.copyWith( + color: context.colors.primary, + ), + ), + ); + } +} + +class _Tile extends StatelessWidget { + const _Tile({ + required this.icon, + required this.title, + required this.onTap, + this.subtitle, + }); + + final IconData icon; + final String title; + final String? subtitle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(icon), + title: Text(title), + subtitle: subtitle == null ? null : Text(subtitle!), + trailing: Icon( + Icons.chevron_left_rounded, + color: context.colors.onSurfaceVariant, + ), + onTap: onTap, + ); + } +} + +/// رقم الإصدار — أول ما يُسأل عنه في الدعم، وكان غائباً رغم وجود مفتاح +/// ترجمته أصلاً. +class _VersionTile extends StatelessWidget { + const _VersionTile(); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (context, snapshot) { + final info = snapshot.data; + return ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.info_outline_rounded), + title: Text(l10n.aboutVersion), + trailing: Text( + // قبل وصول المعلومة يُترك المكان فارغاً لا «—»: القيمة تصل + // في إطار واحد، والشرطة تومض بلا داعٍ. + info == null ? '' : '${info.version} (${info.buildNumber})', + textDirection: TextDirection.ltr, + style: numericStyle(context.texts.bodySmall).copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ); + }, + ); + } } diff --git a/apps/rider_new/lib/features/trip/cubit/ride_cubit.dart b/apps/rider_new/lib/features/trip/cubit/ride_cubit.dart index c6a4181..d4ea8f1 100644 --- a/apps/rider_new/lib/features/trip/cubit/ride_cubit.dart +++ b/apps/rider_new/lib/features/trip/cubit/ride_cubit.dart @@ -12,6 +12,7 @@ import '../data/models/geo_point.dart'; import '../data/models/place.dart'; import '../data/models/ride_type.dart'; import '../data/models/trip.dart'; +import '../data/recent_places_store.dart'; import '../data/trip_repository.dart'; import 'ride_state.dart'; @@ -25,16 +26,19 @@ class RideCubit extends Cubit { required MapsRepository maps, required LocationService location, required RealtimeService realtime, + required RecentPlacesStore recents, }) : _trips = trips, _maps = maps, _location = location, _realtime = realtime, + _recents = recents, super(const RideState()); final TripRepository _trips; final MapsRepository _maps; final LocationService _location; final RealtimeService _realtime; + final RecentPlacesStore _recents; StreamSubscription? _events; Timer? _searchTimeout; @@ -116,8 +120,6 @@ class RideCubit extends Cubit { void couponChanged(String code) => emit(state.copyWith(couponCode: code)); - void openPlanner() => emit(state.copyWith(phase: RidePhase.planning)); - void collapsePlanner() => emit(state.copyWith(phase: RidePhase.idle)); /// تبديل النوع **لا يُطلق نداءً**: كل الأنواع مسعَّرة مسبقاً في [_priceAll]. @@ -150,7 +152,12 @@ class RideCubit extends Cubit { }); } + /// الأماكن الأخيرة — تُقرأ في الشاشة، وتُحدَّث عند كل اختيار وجهة. + List get recentPlaces => _recents.items; + Future pickPlace(Place place, PickTarget target) async { + // الوجهة وحدها تُحفظ: «الأخيرة» تعني أين ذهبتُ لا من أين انطلقت. + if (target == PickTarget.destination) unawaited(_recents.add(place)); emit(state.copyWith( origin: target == PickTarget.origin ? place.point : null, originLabel: target == PickTarget.origin ? place.label : null, @@ -416,6 +423,29 @@ class RideCubit extends Cubit { )); } + // ── المحاكاة ─────────────────────────────────────────────────────────── + + /// عَلَم بناء: `--dart-define=SIMULATE_TRIP=true`. + /// + /// **`const` عمداً** — في بناء الإنتاج يصير الشرط ثابتاً زمن الترجمة + /// فيُحذف كل نداء محاكاة من الـbinary (نفس منطق `Features` في docs/23 §5). + static const bool simulationEnabled = + bool.fromEnvironment('SIMULATE_TRIP'); + + /// مدخل المحاكي — يمرّ بـ[_attachTrip] نفسه الذي يمرّ به `trip:update` + /// الحقيقي، فالشاشات لا تعرف الفرق ولا يُختبر مسارٌ ثانٍ. + void injectSimulatedTrip(Trip trip) { + if (!simulationEnabled || isClosed) return; + _stopSearchGuards(); + _attachTrip(trip); + } + + void injectSimulatedDriverLocation(GeoPoint point) { + if (!simulationEnabled || isClosed) return; + // نفس مسار الحدث الحقيقي — فمسار الاقتراب يُحسب في المحاكاة أيضاً. + _onDriverMoved(point); + } + void _onRealtime(RealtimeEvent event) { switch (event.name) { case RealtimeEvent.tripUpdate: @@ -427,10 +457,62 @@ class RideCubit extends Cubit { final lat = event.data['lat']; final lng = event.data['lng']; if (lat is num && lng is num) { - emit(state.copyWith( - driverLocation: GeoPoint(lat.toDouble(), lng.toDouble()), - )); + _onDriverMoved(GeoPoint(lat.toDouble(), lng.toDouble())); } } } + + // ── مسار الاقتراب ────────────────────────────────────────────────────── + + /// أقلّ إزاحة تستدعي إعادة رسم المسار، بالدرجات (~50 متراً). + /// + /// السائق يبثّ موقعه كل بضع ثوانٍ؛ نداءُ مسارٍ عند كل بثّة يعني عشرات + /// النداءات في الدقيقة بلا فرق مرئي. وهذا الحدّ يلتقط **تغيّر الاتجاه** + /// (انعطاف فعلي) ويتجاهل رجفة GPS في المكان. + static const double _redrawThreshold = 0.0005; + + GeoPoint? _lastPickupFrom; + bool _pickupRouting = false; + + void _onDriverMoved(GeoPoint point) { + emit(state.copyWith(driverLocation: point)); + + // المسار يُرسم أثناء الاقتراب فقط — بعد بدء الرحلة صار مسار الرحلة هو + // المعروض، ورسم خطٍّ للسائق إلى راكبٍ في سيارته بلا معنى. + final approaching = const { + TripStatus.assigned, + TripStatus.driverArriving, + }.contains(state.trip?.status); + if (!approaching) { + if (state.pickupRoute != null) { + emit(state.copyWith(clearPickupRoute: true)); + } + return; + } + + final rider = state.origin; + if (rider == null || _pickupRouting) return; + + final last = _lastPickupFrom; + final moved = last == null || + (point.lat - last.lat).abs() > _redrawThreshold || + (point.lng - last.lng).abs() > _redrawThreshold; + if (!moved) return; + + _lastPickupFrom = point; + unawaited(_routePickup(point, rider)); + } + + Future _routePickup(GeoPoint from, GeoPoint to) async { + _pickupRouting = true; + try { + final route = await _maps.route(from, to); + if (!isClosed) emit(state.copyWith(pickupRoute: route)); + } on ApiException { + // تعذّر المسار لا يُخفي السائق ولا يُعرض خطأً — البطاقة تبقى بلا + // وقتٍ ومسافة، والمحاولة تتكرّر مع الحركة التالية. + } finally { + _pickupRouting = false; + } + } } diff --git a/apps/rider_new/lib/features/trip/cubit/ride_state.dart b/apps/rider_new/lib/features/trip/cubit/ride_state.dart index d121263..eaf41d1 100644 --- a/apps/rider_new/lib/features/trip/cubit/ride_state.dart +++ b/apps/rider_new/lib/features/trip/cubit/ride_state.dart @@ -58,6 +58,7 @@ class RideState extends Equatable { this.quotes = const {}, this.trip, this.driverLocation, + this.pickupRoute, this.searchResults = const [], this.searching = false, this.busy = false, @@ -95,6 +96,12 @@ class RideState extends Equatable { /// موقع السائق الحيّ من `driver:location`. final GeoPoint? driverLocation; + /// مسار **السائق ← الراكب** أثناء الاقتراب، ومنه الوقت والمسافة المعروضان. + /// + /// منفصل عن [route] (مسار الرحلة نفسها) لأنهما يُرسمان معاً بلونين + /// مختلفين، ولأن هذا يُعاد حسابه مع حركة السائق بينما ذاك ثابت. + final RouteInfo? pickupRoute; + final List searchResults; /// بحث نصّي جارٍ — منفصل عن [busy] كي لا يقفل مؤشّرُ البحثِ الشاشةَ كلها. @@ -133,6 +140,7 @@ class RideState extends Equatable { Map? quotes, Trip? trip, GeoPoint? driverLocation, + RouteInfo? pickupRoute, List? searchResults, bool? searching, bool? busy, @@ -144,6 +152,7 @@ class RideState extends Equatable { bool clearRoute = false, bool clearTrip = false, bool clearCamera = false, + bool clearPickupRoute = false, }) { return RideState( phase: phase ?? this.phase, @@ -161,6 +170,9 @@ class RideState extends Equatable { quotes: clearRoute ? const {} : (quotes ?? this.quotes), trip: clearTrip ? null : (trip ?? this.trip), driverLocation: driverLocation ?? this.driverLocation, + pickupRoute: clearPickupRoute || clearRoute + ? null + : (pickupRoute ?? this.pickupRoute), searchResults: searchResults ?? this.searchResults, searching: searching ?? this.searching, busy: busy ?? this.busy, @@ -186,6 +198,7 @@ class RideState extends Equatable { quotes, trip, driverLocation, + pickupRoute, searchResults, searching, busy, diff --git a/apps/rider_new/lib/features/trip/cubit/trip_simulator.dart b/apps/rider_new/lib/features/trip/cubit/trip_simulator.dart new file mode 100644 index 0000000..c4bfda3 --- /dev/null +++ b/apps/rider_new/lib/features/trip/cubit/trip_simulator.dart @@ -0,0 +1,124 @@ +import 'dart:async'; + +import '../data/models/geo_point.dart'; +import '../data/models/trip.dart'; +import 'ride_cubit.dart'; + +/// محاكي رحلة — يمشّي الرحلة عبر حالاتها **بلا تطبيق سائق**. +/// +/// ## لماذا يوجد +/// +/// شاشات ما بعد الطلب (`assigned` ← `driver_arriving` ← `driver_arrived` ← +/// `in_progress` ← `completed`) لا تظهر إلا حين يقبل سائقٌ حقيقي ويتقدّم في +/// الرحلة. ومراجعتها بصرياً كانت تعني تشغيل تطبيقين وحسابين وسائقاً متاحاً — +/// فبقيت غير مُراجَعة. +/// +/// ## ما يفعله وما لا يفعله +/// +/// **يحقن `Trip` في `RideCubit` عبر مسار الأحداث نفسه** الذي يسلكه +/// `trip:update` القادم من الخادم — أي أن الشاشات لا تعرف أنها محاكاة، ونفس +/// منطق `_attachTrip` ونفس الانتقالات تُختبر. لا شاشة وهمية ولا فرع `if +/// (simulated)` في أي ودجت. +/// +/// **لا يلمس الخادم**: لا رحلة تُنشأ ولا سائق يُحجز ولا مال يُحرَّك. ولذلك +/// هو أداة *مراجعة شكل* لا اختبار تكامل — ما يثبت هنا لا يثبت أن الخادم +/// يرسل نفس التتابع. +/// +/// ## الأمان +/// +/// محروس بـ[RideCubit.simulationEnabled] المشتقّ من `--dart-define`، وهو +/// `false` افتراضياً. في بناء الإنتاج الشرط ثابتٌ زمن الترجمة فيُحذف الكود. +class TripSimulator { + TripSimulator(this._cubit); + + final RideCubit _cubit; + Timer? _timer; + + /// الخطوات بترتيبها الواقعي ومدّة كل واحدة — مضغوطة كي تُراجَع الشاشات + /// في نصف دقيقة لا في رحلة كاملة. + static const _steps = <(TripStatus, Duration)>[ + (TripStatus.assigned, Duration(seconds: 4)), + (TripStatus.driverArriving, Duration(seconds: 5)), + (TripStatus.driverArrived, Duration(seconds: 4)), + (TripStatus.inProgress, Duration(seconds: 6)), + (TripStatus.completed, Duration(seconds: 3)), + ]; + + static const _driverName = 'سائق تجريبي'; + static const _driverPhone = '0790000000'; + + bool get isRunning => _timer != null; + + /// يبدأ من الحالة الحالية للكيوبت: المصدر والوجهة والأجرة الحقيقية إن + /// وُجدت، وإلا قيم معقولة كي تُملأ الشاشة. + void start() { + if (!RideCubit.simulationEnabled || isRunning) return; + + final state = _cubit.state; + final origin = state.origin ?? const GeoPoint(31.9539, 35.9106); + final destination = state.destination ?? + GeoPoint(origin.lat + 0.012, origin.lng + 0.012); + // المبالغ نصوص في `Trip` (docs/38 §12.2) بينما `FareQuote.total` رقم — + // التحويل هنا لا في الودجت. + final fare = state.quote?.total?.toStringAsFixed(2) ?? '2.50'; + final currency = state.quote?.currency.isNotEmpty == true + ? state.quote!.currency + : 'JOD'; + + var index = 0; + // موقع السائق يتحرّك نحو الراكب ثم نحو الوجهة — بلا حركة تبدو الخريطة + // معطّلة وتضيع نصف الفائدة من المراجعة. + var driver = GeoPoint(origin.lat - 0.006, origin.lng - 0.006); + + void emitStep() { + final (status, _) = _steps[index]; + _cubit.injectSimulatedTrip( + Trip( + id: 'sim-trip', + status: status, + origin: origin, + destination: destination, + quotedFare: fare, + priceForPassenger: fare, + currency: currency, + driverName: _driverName, + driverPhone: _driverPhone, + vehiclePlate: '12-3456', + vehicleModel: 'Toyota Corolla', + vehicleColor: 'أبيض', + vehicleColorHex: '#FFFFFF', + driverRating: '4.8', + ), + ); + + // بعد بدء الرحلة يتّجه السائق للوجهة لا للراكب. + final target = status == TripStatus.inProgress ? destination : origin; + driver = GeoPoint( + driver.lat + (target.lat - driver.lat) * 0.5, + driver.lng + (target.lng - driver.lng) * 0.5, + ); + _cubit.injectSimulatedDriverLocation(driver); + } + + void schedule() { + final (_, delay) = _steps[index]; + _timer = Timer(delay, () { + index++; + if (index >= _steps.length) { + _timer = null; + return; + } + emitStep(); + schedule(); + }); + } + + emitStep(); + schedule(); + } + + void stop() { + _timer?.cancel(); + _timer = null; + } +} diff --git a/apps/rider_new/lib/features/trip/data/models/trip.dart b/apps/rider_new/lib/features/trip/data/models/trip.dart index 5a7ba5b..28ee48b 100644 --- a/apps/rider_new/lib/features/trip/data/models/trip.dart +++ b/apps/rider_new/lib/features/trip/data/models/trip.dart @@ -70,7 +70,14 @@ class Trip extends Equatable { this.driverPhone, this.vehiclePlate, this.vehicleModel, + this.vehicleColor, + this.vehicleColorHex, this.driverRating, + this.requestedAt, + this.distanceKm, + this.durationMin, + this.paymentMethod, + this.serviceClass, }); final String id; @@ -90,8 +97,34 @@ class Trip extends Equatable { final String? driverPhone; final String? vehiclePlate; final String? vehicleModel; + + /// لون المركبة كاسم (`White`, `أبيض`) — **ما يبحث عنه الراكب في الشارع + /// قبل اللوحة**. الخادم يرسله في `vehicle.color` + /// (`trips.service.ts:427`). + final String? vehicleColor; + + /// اللون كـhex إن وُجد (`vehicles` عندها `color_hex`). يُقدَّم على الاسم + /// عند الرسم لأنه دقيق، والاسم يبقى للعرض النصّي. + final String? vehicleColorHex; + final String? driverRating; + // ── حقول السجلّ ──────────────────────────────────────────────────────── + // كلها موجودة في `trip.entity.ts` وتصل مع `GET /trips/mine`، وكانت + // مُهمَلة في القراءة — فبدت بطاقة «رحلاتي» فارغة إلا من سعر وحالة. + + /// وقت الطلب (`requested_at`, `CreateDateColumn`). + final DateTime? requestedAt; + + final double? distanceKm; + final double? durationMin; + + /// `cash` افتراضاً في الخادم — يُعرض كي يعرف الراكب كيف دفع. + final String? paymentMethod; + + /// نوع الخدمة (`economy` افتراضاً). + final String? serviceClass; + factory Trip.fromJson(Map json) { GeoPoint? point(String key) { final v = json[key]; @@ -99,6 +132,11 @@ class Trip extends Equatable { } final driver = json['driver'] as Map?; + // المركبة تصل بشكلين: مسطّحة (`vehicle_plate`) في ردّ الرحلة، ومتداخلة + // (`vehicle: {plate, color}`) في حدث `assigned` (`trips.service.ts:423`). + // القراءة تقبل الاثنين بدل أن تُفرغ البطاقة عند أحدهما. + final vehicle = (json['vehicle'] ?? driver?['vehicle']) + as Map?; return Trip( id: json['id'] as String, status: TripStatus.parse(json['status'] as String?), @@ -112,11 +150,26 @@ class Trip extends Equatable { driverName: driver?['name'] as String? ?? json['driver_name'] as String?, driverPhone: driver?['phone'] as String? ?? json['driver_phone'] as String?, - vehiclePlate: - driver?['vehicle_plate'] as String? ?? json['vehicle_plate'] as String?, - vehicleModel: - driver?['vehicle_model'] as String? ?? json['vehicle_model'] as String?, + vehiclePlate: vehicle?['plate'] as String? ?? + driver?['vehicle_plate'] as String? ?? + json['vehicle_plate'] as String?, + vehicleModel: vehicle?['model'] as String? ?? + driver?['vehicle_model'] as String? ?? + json['vehicle_model'] as String?, + vehicleColor: vehicle?['color'] as String? ?? + driver?['vehicle_color'] as String? ?? + json['vehicle_color'] as String?, + vehicleColorHex: vehicle?['color_hex'] as String? ?? + driver?['vehicle_color_hex'] as String? ?? + json['vehicle_color_hex'] as String?, driverRating: driver?['rating']?.toString(), + requestedAt: DateTime.tryParse(json['requested_at']?.toString() ?? ''), + // المسافة الفعلية أدقّ من المقدّرة حين تتوفّر بعد انتهاء الرحلة. + distanceKm: (json['actual_distance_km'] as num?)?.toDouble() ?? + (json['distance_km'] as num?)?.toDouble(), + durationMin: (json['duration_min'] as num?)?.toDouble(), + paymentMethod: json['payment_method'] as String?, + serviceClass: json['service_class'] as String?, ); } diff --git a/apps/rider_new/lib/features/trip/data/recent_places_store.dart b/apps/rider_new/lib/features/trip/data/recent_places_store.dart new file mode 100644 index 0000000..b002e58 --- /dev/null +++ b/apps/rider_new/lib/features/trip/data/recent_places_store.dart @@ -0,0 +1,74 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import 'models/geo_point.dart'; +import 'models/place.dart'; + +/// الوجهات الأخيرة — **محلّية**، كما في سيرو +/// (`rp_favorites.dart: RpRecentsRow` + جدول `recentLocations`). +/// +/// أكثر رحلات الراكب تتكرّر: البيت، العمل، بيت الأهل. وبلا هذا يعيد كتابة +/// الاسم نفسه كل مرة — وهي أكبر خطوة ضائعة في الشاشة الرئيسية. +/// +/// محلّي لا خادمي عن قصد: `GET /trips/mine` يرجّع رحلات لا **أماكن** +/// (بلا اسم الوجهة)، فبناء «الأخيرة» عليه يعطي أسطراً بلا عناوين. +class RecentPlacesStore { + static const _key = 'recent_places'; + + /// خمسة تكفي صفّاً أفقياً واحداً؛ أكثر منها يصير قائمةً تُتصفَّح لا تُلمح. + static const _max = 5; + + List _items = const []; + List get items => _items; + + Future load() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_key); + if (raw == null || raw.isEmpty) return; + try { + final list = jsonDecode(raw) as List; + _items = list + .whereType>() + .map(_decode) + .toList(growable: false); + } catch (_) { + _items = const []; + } + } + + /// يضيف المكان في الصدارة، ويزيح تكراره إن سبق — لا يتكدّس نفس المكان. + Future add(Place place) async { + if (place.label.isEmpty) return; + _items = [ + place, + ..._items.where((p) => p.id != place.id || p.label != place.label), + ].take(_max).toList(growable: false); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _key, + jsonEncode(_items.map(_encode).toList(growable: false)), + ); + } + + static Map _encode(Place p) => { + 'id': p.id, + 'name': p.name, + 'address': p.address, + 'category': p.category, + 'lat': p.point.lat, + 'lng': p.point.lng, + }; + + static Place _decode(Map json) => Place( + id: json['id']?.toString() ?? '', + name: json['name'] as String? ?? '', + address: json['address'] as String? ?? '', + category: json['category'] as String? ?? '', + point: GeoPoint( + (json['lat'] as num?)?.toDouble() ?? 0, + (json['lng'] as num?)?.toDouble() ?? 0, + ), + ); +} diff --git a/apps/rider_new/lib/features/trip/view/history_page.dart b/apps/rider_new/lib/features/trip/view/history_page.dart index 856e7d3..19381a1 100644 --- a/apps/rider_new/lib/features/trip/view/history_page.dart +++ b/apps/rider_new/lib/features/trip/view/history_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; import '../../../core/design/tokens.dart'; import '../../../core/design/tripz_colors.dart'; @@ -55,6 +56,16 @@ class _HistoryPageState extends State { } } +/// بطاقة رحلة في السجلّ. +/// +/// كانت ثلاثة عناصر فقط (نقطة · حالة · سعر) — بلا تاريخ ولا مسافة ولا طريقة +/// دفع، رغم أن الخادم يرسلها كلها في `GET /trips/mine` +/// (`trip.entity.ts`: `requested_at`, `distance_km`, `duration_min`, +/// `payment_method`, `service_class`). البطاقة الآن تقرأ ما كان يصل ويُهمَل. +/// +/// **العناوين النصّية غائبة عمداً**: `trip.entity.ts` يخزّن إحداثيات +/// (`origin_lat/lng`) بلا `origin_address`، فسيرو يعرض عناوين ونحن لا نقدر +/// حتى يُضاف الحقل على الخادم. عرضُ إحداثيات خام للراكب أسوأ من لا شيء. class _TripCard extends StatelessWidget { const _TripCard({required this.trip}); @@ -77,25 +88,106 @@ class _TripCard extends StatelessWidget { _ => (l10n.historyStatusLive, colors.info), }; + // سطر التفاصيل: ما توفّر منه فقط، بفواصل — رحلة ملغاة بلا مسافة لا + // تعرض «0 كم». + final details = [ + if (trip.distanceKm != null && trip.distanceKm! > 0) + l10n.rideDistanceKm(trip.distanceKm!.toStringAsFixed(1)), + if (trip.durationMin != null && trip.durationMin! > 0) + l10n.rideEta(trip.durationMin!.round().toString()), + ].join(' · '); + return TripzCard( - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration(color: color, shape: BoxShape.circle), - ), - const SizedBox(width: Space.sm), - Expanded( - child: Text(label, style: context.texts.bodyMedium), - ), - Text( - Money.format(trip.priceForPassenger ?? trip.quotedFare, - trip.currency), - style: numericStyle(context.texts.titleSmall), + Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: Space.sm), + Expanded( + child: Text( + label, + style: context.texts.bodyMedium?.copyWith(color: color), + ), + ), + Text( + Money.format( + trip.priceForPassenger ?? trip.quotedFare, + trip.currency, + ), + style: numericStyle(context.texts.titleSmall), + ), + ], ), + if (trip.requestedAt != null) ...[ + const SizedBox(height: Space.xs), + Text( + DateFormat.yMMMd().add_jm().format(trip.requestedAt!.toLocal()), + style: context.texts.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + if (details.isNotEmpty || trip.paymentMethod != null) ...[ + const SizedBox(height: Space.xs), + Row( + children: [ + if (details.isNotEmpty) + Expanded( + child: Text( + details, + style: numericStyle(context.texts.bodySmall).copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ) + else + const Spacer(), + if (trip.paymentMethod != null) + _Tag(text: _paymentLabel(context, trip.paymentMethod!)), + ], + ), + ], ], ), ); } + + /// طرق الدفع المعروفة تُترجم؛ وأي قيمة جديدة من الخادم تُعرض كما هي بدل + /// أن تختفي. + String _paymentLabel(BuildContext context, String method) { + final l10n = context.l10n; + return switch (method) { + 'cash' => l10n.paymentCash, + 'wallet' => l10n.paymentWallet, + _ => method, + }; + } +} + +/// شارة صغيرة — طريقة الدفع في السجلّ. +class _Tag extends StatelessWidget { + const _Tag({required this.text}); + + final String text; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: Space.xs, + vertical: Space.xxs / 2, + ), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: Radii.field_, + ), + child: Text(text, style: context.texts.labelSmall), + ); + } } diff --git a/apps/rider_new/lib/features/trip/view/ride_page.dart b/apps/rider_new/lib/features/trip/view/ride_page.dart index 1b6436e..3061a4e 100644 --- a/apps/rider_new/lib/features/trip/view/ride_page.dart +++ b/apps/rider_new/lib/features/trip/view/ride_page.dart @@ -10,10 +10,13 @@ import '../../../core/ui/status_banner.dart'; import '../../../core/ui/tripz_button.dart'; import '../cubit/ride_cubit.dart'; import '../cubit/ride_state.dart'; +import '../cubit/trip_simulator.dart'; import '../data/models/geo_point.dart'; +import '../data/models/route_info.dart'; import 'widgets/active_trip_sheet.dart'; import 'widgets/center_pin.dart'; import 'widgets/confirm_sheet.dart'; +import 'widgets/place_search_dialog.dart'; import 'widgets/planner_sheet.dart'; import 'widgets/ride_map.dart'; import 'widgets/searching_sheet.dart'; @@ -30,8 +33,16 @@ class RidePage extends StatefulWidget { State createState() => _RidePageState(); } +/// نصيب الورقة السفلية من ارتفاع الشاشة — الخريطة تأخذ ما تبقّى (~67%). +/// +/// قرار المالك 2026-08-05: الخريطة تُرفع عن القاع بالثلث كي تبقى مساحة +/// الورقة خالصة لها. سيرو يرفعها بـ20%، وتريبز بـ33% لأن ورقته تعرض +/// خطوطاً أكثر (الخط الزمني + الأنواع + السعر). +const double _sheetFraction = 0.33; + class _RidePageState extends State with WidgetsBindingObserver { late final RideCubit _cubit = sl()..init(); + late final TripSimulator _simulator = TripSimulator(_cubit); GeoPoint? _mapCenter; @override @@ -43,6 +54,7 @@ class _RidePageState extends State with WidgetsBindingObserver { @override void dispose() { WidgetsBinding.instance.removeObserver(this); + _simulator.stop(); _cubit.close(); super.dispose(); } @@ -65,47 +77,82 @@ class _RidePageState extends State with WidgetsBindingObserver { body: Stack( children: [ // ── الخريطة ──────────────────────────────────────────────── - BlocSelector( - selector: (s) => _MapData( - origin: s.origin, - destination: s.destination, - driver: s.driverLocation, - route: s.route, - camera: s.cameraTarget, - originLabel: s.originLabel, - destinationLabel: s.destinationLabel, - ), - builder: (context, data) => RideMap( - origin: data.origin, - destination: data.destination, - driver: data.driver, - route: data.route, - cameraTarget: data.camera, - onCameraIdle: (p) => _mapCenter = p, - // الورقة تغطّي نحو ثلث الشاشة — يُحسب منه إطار المسار كي لا - // يختفي نصفه تحتها. - bottomInset: MediaQuery.sizeOf(context).height * 0.34, - originLabel: data.originLabel, - destinationLabel: data.destinationLabel, - ), - ), + // + // **مرفوعة عن القاع بمقدار [_sheetFraction]** لا مالئةً الشاشة: + // الثلث السفلي مساحة الورقة وحدها، فلا تغطّي الورقةُ الخريطةَ + // (نمط سيرو — `Positioned(bottom: Get.height * .2, top: 0)` في + // `google_map_passenger_widget.dart`). + // + // والدبّوس **داخل نفس المنطقة** لا في الـ`Stack` الخارجي: مركز + // الكاميرا (`onCameraIdle`) هو مركز ودجت الخريطة، فلو بقي + // الدبّوس متمركزاً على الشاشة لأشار إلى نقطة غير التي تُؤكَّد. + Positioned( + top: 0, + left: 0, + right: 0, + bottom: MediaQuery.sizeOf(context).height * _sheetFraction, + child: Stack( + children: [ + BlocSelector( + selector: (s) => _MapData( + origin: s.origin, + destination: s.destination, + driver: s.driverLocation, + route: s.route, + pickupRoute: s.pickupRoute, + camera: s.cameraTarget, + originLabel: s.originLabel, + destinationLabel: s.destinationLabel, + ), + builder: (context, data) => RideMap( + origin: data.origin, + destination: data.destination, + driver: data.driver, + route: data.route, + pickupRoute: data.pickupRoute, + cameraTarget: data.camera, + onCameraIdle: (p) => _mapCenter = p, + // الورقة لم تعد تغطّي الخريطة — تبقى حشوة تنفّس فقط + // كي لا يلتصق المسار بحافّة الخريطة السفلية. + bottomInset: Space.xxl, + originLabel: data.originLabel, + destinationLabel: data.destinationLabel, + ), + ), - // ── دبّوس الاختيار ───────────────────────────────────────── - BlocSelector( - selector: (s) => s.phase == RidePhase.pickingOnMap, - builder: (context, picking) => - picking ? const CenterPin() : const SizedBox.shrink(), + // ── دبّوس الاختيار ───────────────────────────────────── + BlocSelector( + selector: (s) => s.phase == RidePhase.pickingOnMap, + builder: (context, picking) => + picking ? const CenterPin() : const SizedBox.shrink(), + ), + ], + ), ), // زر القائمة — يطفو فوق الخريطة، ويحترم المنطقة الآمنة. SafeArea( child: Padding( padding: Space.page, - child: Builder( - builder: (context) => _MapFab( - icon: Icons.menu_rounded, - onTap: Scaffold.of(context).openDrawer, - ), + child: Row( + children: [ + Builder( + builder: (context) => _MapFab( + icon: Icons.menu_rounded, + onTap: Scaffold.of(context).openDrawer, + ), + ), + // زرّ المحاكاة — يظهر فقط مع + // `--dart-define=SIMULATE_TRIP=true`. الشرط `const` فيُحذف + // هو وما تحته من بناء الإنتاج. + if (RideCubit.simulationEnabled) ...[ + const SizedBox(width: Space.xs), + _MapFab( + icon: Icons.play_arrow_rounded, + onTap: () => _simulator.start(), + ), + ], + ], ), ), ), @@ -137,6 +184,7 @@ class _MapData { this.destination, this.driver, this.route, + this.pickupRoute, this.camera, this.originLabel = '', this.destinationLabel = '', @@ -145,7 +193,8 @@ class _MapData { final GeoPoint? origin; final GeoPoint? destination; final GeoPoint? driver; - final dynamic route; + final RouteInfo? route; + final RouteInfo? pickupRoute; final GeoPoint? camera; final String originLabel; final String destinationLabel; @@ -157,6 +206,7 @@ class _MapData { other.destination == destination && other.driver == driver && other.route == route && + other.pickupRoute == pickupRoute && other.camera == camera && other.originLabel == originLabel && other.destinationLabel == destinationLabel; @@ -167,6 +217,7 @@ class _MapData { destination, driver, route, + pickupRoute, camera, originLabel, destinationLabel, @@ -190,11 +241,20 @@ class _Sheet extends StatelessWidget { RidePhase.pickingOnMap => _PickConfirm(onConfirm: onConfirmPick), RidePhase.confirming => ConfirmSheet(state: state), RidePhase.searching => const SearchingSheet(), - RidePhase.active => ActiveTripSheet(trip: state.trip!), + RidePhase.active => ActiveTripSheet( + trip: state.trip!, + pickupRoute: state.pickupRoute, + ), }; return Container( width: double.infinity, + // الحدّ الأدنى = المساحة المحجوزة للورقة تحت الخريطة، وإلا ظهر فراغُ + // الـ`Scaffold` بينهما في المراحل قصيرة المحتوى (`idle` مثلاً). + // وهو حدّ أدنى لا ثابت: `active` و`confirming` يكبران فوقه بحرّية. + constraints: BoxConstraints( + minHeight: MediaQuery.sizeOf(context).height * _sheetFraction, + ), decoration: BoxDecoration( color: context.colors.surface, borderRadius: Radii.sheetTop, @@ -299,21 +359,32 @@ class _Collapsed extends StatelessWidget { ), const SizedBox(height: Space.sm), ], - // حقل بحث لا زرّ: الضغط عليه يفتح المخطّط والكيبورد معاً. + // حقل بحث لا زرّ: الضغط عليه يفتح بحث الأماكن بملء الشاشة مباشرةً + // (docs/40 §2) — لا ورقةً وسيطة ثم بحثاً داخلها. + // + // الحشوة سخيّة عمداً: الثلث السفلي صار مساحة الورقة وحدها بعد رفع + // الخريطة، وحقلٌ نحيف وسط فراغ يبدو مقصوصاً لا أنيقاً. Material( color: context.colors.surfaceContainerHighest, borderRadius: Radii.card_, child: InkWell( - onTap: cubit.openPlanner, + onTap: () => PlaceSearchDialog.show( + context, + target: PickTarget.destination, + ), borderRadius: Radii.card_, child: Padding( padding: const EdgeInsets.symmetric( horizontal: Space.md, - vertical: Space.sm, + vertical: Space.md, ), child: Row( children: [ - Icon(Icons.search_rounded, color: context.colors.primary), + Icon( + Icons.search_rounded, + size: Sizes.icon, + color: context.colors.primary, + ), const SizedBox(width: Space.sm), Expanded( child: Text( @@ -323,16 +394,63 @@ class _Collapsed extends StatelessWidget { ), ), ), + Icon( + Icons.chevron_left_rounded, + color: context.colors.onSurfaceVariant, + ), ], ), ), ), ), + // الوجهات الأخيرة — نمط سيرو (`rp_favorites.dart: RpRecentsRow`). + // أكثر الرحلات تتكرّر، وبلا هذا يعيد الراكب كتابة الاسم نفسه كل مرة. + const _RecentsRow(), ], ); } } +/// صفّ أفقي للوجهات الأخيرة — يختفي كلياً حين لا توجد، فلا يترك فراغاً. +class _RecentsRow extends StatelessWidget { + const _RecentsRow(); + + @override + Widget build(BuildContext context) { + final cubit = context.read(); + final places = cubit.recentPlaces; + if (places.isEmpty) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(top: Space.sm), + child: SizedBox( + height: Sizes.touchTarget, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: places.length, + separatorBuilder: (context, i) => const SizedBox(width: Space.xs), + itemBuilder: (context, i) { + final place = places[i]; + return ActionChip( + avatar: Icon( + Icons.history_rounded, + size: Sizes.iconSm, + color: context.colors.onSurfaceVariant, + ), + label: Text( + place.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onPressed: () => cubit.pickPlace(place, PickTarget.destination), + ); + }, + ), + ), + ); + } +} + class _PickConfirm extends StatelessWidget { const _PickConfirm({required this.onConfirm}); diff --git a/apps/rider_new/lib/features/trip/view/widgets/active_trip_sheet.dart b/apps/rider_new/lib/features/trip/view/widgets/active_trip_sheet.dart index 9c0ecae..528b3d8 100644 --- a/apps/rider_new/lib/features/trip/view/widgets/active_trip_sheet.dart +++ b/apps/rider_new/lib/features/trip/view/widgets/active_trip_sheet.dart @@ -1,26 +1,27 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:go_router/go_router.dart'; -import '../../../../core/build_config.dart'; import '../../../../core/design/tokens.dart'; import '../../../../core/design/tripz_colors.dart'; import '../../../../core/l10n/l10n.dart'; -import '../../../../core/router.dart'; -import '../../../../core/tenant/feature_gate.dart'; import '../../../../core/ui/tripz_button.dart'; import '../../cubit/ride_cubit.dart'; +import '../../data/models/route_info.dart'; import '../../data/models/trip.dart'; import 'cancel_sheet.dart'; import 'driver_badge.dart'; import 'fare_summary.dart'; +import 'trip_contact_row.dart'; /// بطاقة الرحلة الجارية: حالة واضحة ثم بيانات السائق ثم فعل واحد. class ActiveTripSheet extends StatelessWidget { - const ActiveTripSheet({super.key, required this.trip}); + const ActiveTripSheet({super.key, required this.trip, this.pickupRoute}); final Trip trip; + /// مسار اقتراب السائق — منه شريط الوقت والمسافة. + final RouteInfo? pickupRoute; + @override Widget build(BuildContext context) { final l10n = context.l10n; @@ -29,22 +30,27 @@ class ActiveTripSheet extends StatelessWidget { TripStatus.driverArriving, }.contains(trip.status); + // الشريط أثناء الاقتراب وحده: بعد ركوب الراكب لم يعد «كم بقي ليصل» سؤالاً. + final eta = const { + TripStatus.assigned, + TripStatus.driverArriving, + }.contains(trip.status) + ? pickupRoute + : null; + return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text(_statusText(context), style: context.texts.titleMedium), + if (eta != null) ...[ + const SizedBox(height: Space.sm), + TripEtaStrip(minutes: eta.durationMin, km: eta.distanceKm), + ], const SizedBox(height: Space.md), DriverBadge(trip: trip), - if (Features.chat && context.tenantHas('chat')) ...[ - const SizedBox(height: Space.sm), - TripzButton.secondary( - label: l10n.chatTitle, - icon: Icons.forum_outlined, - expanded: true, - onPressed: () => context.push('${Routes.chat}?trip=\${trip.id}'), - ), - ], + const SizedBox(height: Space.sm), + TripContactRow(trip: trip), if (trip.priceForPassenger != null) ...[ const SizedBox(height: Space.md), FareSummary(trip: trip), diff --git a/apps/rider_new/lib/features/trip/view/widgets/driver_badge.dart b/apps/rider_new/lib/features/trip/view/widgets/driver_badge.dart index cabb7ac..1c5e890 100644 --- a/apps/rider_new/lib/features/trip/view/widgets/driver_badge.dart +++ b/apps/rider_new/lib/features/trip/view/widgets/driver_badge.dart @@ -4,6 +4,7 @@ import '../../../../core/design/tokens.dart'; import '../../../../core/design/tripz_colors.dart'; import '../../../../core/design/typography.dart'; import '../../data/models/trip.dart'; +import 'vehicle_color.dart'; /// بطاقة السائق ولوحة المركبة — أهم ما يبحث عنه الراكب في الشارع، فيأخذ /// أوضح موضع وأكبر تباين. @@ -15,25 +16,35 @@ class DriverBadge extends StatelessWidget { @override Widget build(BuildContext context) { if (trip.driverName == null) return const SizedBox.shrink(); + + // اللون **يُرسم لا يُكتب فقط**: الراكب يمسح الشارع بعينه بحثاً عن سيارة + // بيضاء، لا يقرأ كلمة «أبيض». نمط سيرو (`apply_order_widget.dart:333`) + // يلوّن رمز السيارة بلون المركبة الفعلي — وهذا مقابله. + final carColor = VehicleColor.resolve( + hex: trip.vehicleColorHex, + name: trip.vehicleColor, + ); + + // سطر المركبة: الموديل واللون معاً بفاصل — أحدهما قد يغيب. + final vehicleLine = [ + if (trip.vehicleModel != null && trip.vehicleModel!.isNotEmpty) + trip.vehicleModel!, + if (trip.vehicleColor != null && trip.vehicleColor!.isNotEmpty) + trip.vehicleColor!, + ].join(' • '); + return Row( children: [ - CircleAvatar( - radius: 24, - backgroundColor: context.colors.primaryContainer, - child: Icon( - Icons.person_rounded, - color: context.colors.onPrimaryContainer, - ), - ), + _CarAvatar(color: carColor), const SizedBox(width: Space.sm), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(trip.driverName!, style: context.texts.titleSmall), - if (trip.vehicleModel != null) + if (vehicleLine.isNotEmpty) Text( - trip.vehicleModel!, + vehicleLine, style: context.texts.bodySmall?.copyWith( color: context.colors.onSurfaceVariant, ), @@ -62,6 +73,43 @@ class DriverBadge extends StatelessWidget { } } +/// رمز سيارة ملوّن بلون المركبة الحقيقي. +/// +/// اللون الفاتح (أبيض/بيج) يختفي على سطح فاتح، فتُرسم حلقة حدّ حوله دائماً — +/// وبها تبقى السيارة البيضاء مقروءة في الوضع النهاري. +class _CarAvatar extends StatelessWidget { + const _CarAvatar({this.color}); + + final Color? color; + + @override + Widget build(BuildContext context) { + final resolved = color ?? context.colors.onSurfaceVariant; + return Container( + width: 48, + height: 48, + decoration: BoxDecoration( + // ‏10% من لون السيارة خلفيةً — يربط الرمز بلونه بلا صراخ. + color: color?.withValues(alpha: 0.12) ?? + context.colors.surfaceContainerHighest, + shape: BoxShape.circle, + border: Border.all(color: context.colors.outlineVariant), + ), + child: Center( + child: Icon( + Icons.directions_car_filled_rounded, + size: Sizes.icon, + color: resolved, + // ظلّ خفيف كي لا تذوب السيارة البيضاء في الخلفية الفاتحة. + shadows: const [ + Shadow(color: Color(0x33000000), blurRadius: 2), + ], + ), + ), + ); + } +} + /// لوحة مرسومة لا نصّ عادي — تُقرأ من بعيد ومن زاوية. class _Plate extends StatelessWidget { const _Plate({required this.plate}); diff --git a/apps/rider_new/lib/features/trip/view/widgets/place_search_dialog.dart b/apps/rider_new/lib/features/trip/view/widgets/place_search_dialog.dart new file mode 100644 index 0000000..aa82cbc --- /dev/null +++ b/apps/rider_new/lib/features/trip/view/widgets/place_search_dialog.dart @@ -0,0 +1,219 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../core/design/tokens.dart'; +import '../../../../core/design/tripz_colors.dart'; +import '../../../../core/l10n/l10n.dart'; +import '../../../../core/ui/tripz_text_field.dart'; +import '../../cubit/ride_cubit.dart'; +import '../../cubit/ride_state.dart'; +import '../../data/models/place.dart'; +import 'route_timeline.dart'; + +/// بحث الأماكن بملء الشاشة — نمط سيرو +/// (`apps/rider/lib/views/home/route_planner/rp_search_dialog.dart`): +/// ديالوج منفصل عن الورقة السفلية بدل حقل مضغوط داخلها (docs/40 §2). +/// +/// [target] يحدّد الحقل الذي يُملأ عند الاختيار — البحث نفسه (`searchPlaces`) +/// مشترك، فلا حاجة لنسخة ثانية من الكيوبت لكل اتجاه. +class PlaceSearchDialog extends StatefulWidget { + const PlaceSearchDialog({super.key, required this.target}); + + final PickTarget target; + + static Future show(BuildContext context, {required PickTarget target}) { + final cubit = context.read(); + return showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + barrierColor: Colors.black54, + transitionDuration: Motion.page, + pageBuilder: (context, animation, secondaryAnimation) => + BlocProvider.value( + value: cubit, + child: PlaceSearchDialog(target: target), + ), + transitionBuilder: (context, animation, secondaryAnimation, child) { + return SlideTransition( + position: Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)), + child: child, + ); + }, + ); + } + + @override + State createState() => _PlaceSearchDialogState(); +} + +class _PlaceSearchDialogState extends State { + final _query = TextEditingController(); + + @override + void dispose() { + // البحث ينتهي مع الديالوج — لا يبقى مصدر نتائج قديمة معلَّقاً في الحالة + // حين يُفتح البحث ثانيةً بحقل فارغ. + context.read().searchPlaces(''); + _query.dispose(); + super.dispose(); + } + + IconData _iconFor(String category) => switch (category) { + 'street' || 'road' => Icons.route_outlined, + 'restaurant' || 'cafe' || 'food' => Icons.restaurant_outlined, + 'hospital' || 'clinic' || 'pharmacy' => Icons.local_hospital_outlined, + 'school' || 'university' => Icons.school_outlined, + 'mall' || 'shop' || 'store' => Icons.storefront_outlined, + 'mosque' => Icons.mosque_outlined, + 'hotel' => Icons.hotel_outlined, + _ => Icons.place_outlined, + }; + + void _select(BuildContext context, Place place) { + Navigator.of(context).pop(); + context.read().pickPlace(place, widget.target); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final hint = widget.target == PickTarget.origin + ? l10n.rideOrigin + : l10n.rideSearchHint; + + return Scaffold( + backgroundColor: context.colors.surface, + body: SafeArea( + child: Padding( + padding: Space.page, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_rounded), + onPressed: () => Navigator.of(context).pop(), + ), + Expanded( + child: Text(l10n.rideWhereTo, style: context.texts.titleMedium), + ), + ], + ), + const SizedBox(height: Space.sm), + BlocSelector( + selector: (s) => (o: s.originLabel, d: s.destinationLabel, stops: s.stops.length), + builder: (context, r) => RouteTimeline( + originLabel: r.o, + destinationLabel: r.d, + stopCount: r.stops, + onTapOrigin: () { + if (widget.target != PickTarget.origin) { + Navigator.of(context).pop(); + PlaceSearchDialog.show(context, target: PickTarget.origin); + } + }, + onTapDestination: () { + if (widget.target != PickTarget.destination) { + Navigator.of(context).pop(); + PlaceSearchDialog.show(context, target: PickTarget.destination); + } + }, + ), + ), + const SizedBox(height: Space.md), + TripzTextField( + controller: _query, + hint: hint, + prefixIcon: Icons.search_rounded, + autofocus: true, + onChanged: context.read().searchPlaces, + suffix: _query.text.isEmpty + ? null + : IconButton( + icon: const Icon(Icons.close_rounded), + onPressed: () { + _query.clear(); + context.read().searchPlaces(''); + setState(() {}); + }, + ), + ), + const SizedBox(height: Space.sm), + Expanded( + child: BlocBuilder( + builder: (context, state) { + if (state.searching) { + return const Padding( + padding: EdgeInsets.only(top: Space.md), + child: LinearProgressIndicator(minHeight: 2), + ); + } + if (_query.text.trim().length >= 2 && + state.searchResults.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.only(top: Space.lg), + child: Text( + l10n.rideSearchNoResults, + textAlign: TextAlign.center, + style: context.texts.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + ); + } + // حقل فارغ ⇒ الأخيرة بدل شاشة بيضاء — الراكب غالباً + // ذاهبٌ لمكان سبق أن ذهب إليه. + final results = state.searchResults.isEmpty && + _query.text.trim().isEmpty + ? context.read().recentPlaces + : state.searchResults; + return ListView.separated( + padding: const EdgeInsets.only(top: Space.xs), + itemCount: results.length, + separatorBuilder: (context, i) => const Divider(height: 1), + itemBuilder: (context, i) { + final place = results[i]; + return ListTile( + contentPadding: EdgeInsets.zero, + leading: CircleAvatar( + radius: 18, + backgroundColor: context.colors.surfaceContainerHighest, + child: Icon( + _iconFor(place.category), + size: Sizes.iconSm, + color: context.colors.onSurfaceVariant, + ), + ), + title: Text(place.label, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: place.address.isEmpty + ? null + : Text(place.address, maxLines: 1, overflow: TextOverflow.ellipsis), + onTap: () => _select(context, place), + ); + }, + ); + }, + ), + ), + TextButton.icon( + icon: const Icon(Icons.map_outlined), + label: Text(l10n.ridePickOnMap), + onPressed: () { + Navigator.of(context).pop(); + context.read().startMapPick(widget.target); + }, + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/rider_new/lib/features/trip/view/widgets/planner_sheet.dart b/apps/rider_new/lib/features/trip/view/widgets/planner_sheet.dart index eef1b22..c5194d1 100644 --- a/apps/rider_new/lib/features/trip/view/widgets/planner_sheet.dart +++ b/apps/rider_new/lib/features/trip/view/widgets/planner_sheet.dart @@ -2,52 +2,25 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../../core/design/tokens.dart'; -import '../../../../core/design/tripz_colors.dart'; import '../../../../core/l10n/l10n.dart'; import '../../../../core/ui/tripz_button.dart'; -import '../../../../core/ui/tripz_text_field.dart'; import '../../cubit/ride_cubit.dart'; import '../../cubit/ride_state.dart'; +import 'place_search_dialog.dart'; import 'route_timeline.dart'; -/// المخطّط الموسّع بترتيبه المثبت ميدانياً (docs/39 §3): -/// الخط الزمني للمسار ← البحث ← تحديد من الخريطة. -class PlannerSheet extends StatefulWidget { +/// المخطّط الموسّع: الخط الزمني للمسار فقط — البحث بحدّ ذاته انتقل إلى +/// [PlaceSearchDialog] بملء الشاشة (docs/40 §2). الضغط على أي حقل في الخط +/// الزمني يفتح ذلك الديالوج بدل بحث مضغوط داخل هذه الورقة. +class PlannerSheet extends StatelessWidget { const PlannerSheet({super.key, required this.state}); final RideState state; - @override - State createState() => _PlannerSheetState(); -} - -class _PlannerSheetState extends State { - final _query = TextEditingController(); - - @override - void dispose() { - _query.dispose(); - super.dispose(); - } - - /// أيقونة تعبّر عن نوع المكان — مطعم غير شارع غير مستشفى. يقرأها المستخدم - /// أسرع من النصّ. - IconData _iconFor(String category) => switch (category) { - 'street' || 'road' => Icons.route_outlined, - 'restaurant' || 'cafe' || 'food' => Icons.restaurant_outlined, - 'hospital' || 'clinic' || 'pharmacy' => Icons.local_hospital_outlined, - 'school' || 'university' => Icons.school_outlined, - 'mall' || 'shop' || 'store' => Icons.storefront_outlined, - 'mosque' => Icons.mosque_outlined, - 'hotel' => Icons.hotel_outlined, - _ => Icons.place_outlined, - }; - @override Widget build(BuildContext context) { final l10n = context.l10n; final cubit = context.read(); - final state = widget.state; return Column( mainAxisSize: MainAxisSize.min, @@ -57,81 +30,12 @@ class _PlannerSheetState extends State { originLabel: state.originLabel, destinationLabel: state.destinationLabel, stopCount: state.stops.length, - onTapOrigin: () => cubit.startMapPick(PickTarget.origin), - onTapDestination: () => cubit.startMapPick(PickTarget.destination), + onTapOrigin: () => + PlaceSearchDialog.show(context, target: PickTarget.origin), + onTapDestination: () => + PlaceSearchDialog.show(context, target: PickTarget.destination), ), const SizedBox(height: Space.md), - TripzTextField( - controller: _query, - hint: l10n.rideSearchHint, - prefixIcon: Icons.search_rounded, - // الكيبورد يفتح فوراً: المستخدم فتح المخطّط ليكتب، لا لينظر. - autofocus: true, - onChanged: cubit.searchPlaces, - suffix: _query.text.isEmpty - ? null - : IconButton( - icon: const Icon(Icons.close_rounded), - onPressed: () { - _query.clear(); - cubit.searchPlaces(''); - setState(() {}); - }, - ), - ), - if (state.searching) - const Padding( - padding: EdgeInsets.only(top: Space.md), - child: LinearProgressIndicator(minHeight: 2), - ), - if (!state.searching && - _query.text.trim().length >= 2 && - state.searchResults.isEmpty) - Padding( - padding: const EdgeInsets.only(top: Space.md), - child: Text( - l10n.rideSearchNoResults, - textAlign: TextAlign.center, - style: context.texts.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ), - if (state.searchResults.isNotEmpty) - Flexible( - child: ListView.separated( - shrinkWrap: true, - padding: const EdgeInsets.only(top: Space.xs), - itemCount: state.searchResults.length, - separatorBuilder: (context, i) => const Divider(height: 1), - itemBuilder: (context, i) { - final place = state.searchResults[i]; - return ListTile( - contentPadding: EdgeInsets.zero, - leading: CircleAvatar( - radius: 18, - backgroundColor: context.colors.surfaceContainerHighest, - child: Icon( - _iconFor(place.category), - size: Sizes.iconSm, - color: context.colors.onSurfaceVariant, - ), - ), - title: Text(place.label, maxLines: 1, - overflow: TextOverflow.ellipsis), - subtitle: place.address.isEmpty - ? null - : Text(place.address, - maxLines: 1, overflow: TextOverflow.ellipsis), - onTap: () { - _query.clear(); - cubit.pickPlace(place, PickTarget.destination); - }, - ); - }, - ), - ), - const SizedBox(height: Space.md), TripzButton.secondary( label: l10n.ridePickOnMap, icon: Icons.map_outlined, diff --git a/apps/rider_new/lib/features/trip/view/widgets/ride_map.dart b/apps/rider_new/lib/features/trip/view/widgets/ride_map.dart index f4dbaf2..929f308 100644 --- a/apps/rider_new/lib/features/trip/view/widgets/ride_map.dart +++ b/apps/rider_new/lib/features/trip/view/widgets/ride_map.dart @@ -1,3 +1,5 @@ +import 'dart:developer' as dev; + import 'package:flutter/material.dart'; import 'package:intaleq_maps/intaleq_maps.dart' as im; @@ -35,6 +37,7 @@ class RideMap extends StatefulWidget { this.destination, this.driver, this.route, + this.pickupRoute, this.cameraTarget, this.onCameraIdle, this.bottomInset = 280, @@ -46,6 +49,10 @@ class RideMap extends StatefulWidget { final GeoPoint? destination; final GeoPoint? driver; final RouteInfo? route; + + /// مسار اقتراب السائق — يُرسم بلون وعرض مختلفين عن [route]. + final RouteInfo? pickupRoute; + final GeoPoint? cameraTarget; /// مركز الخريطة عند استقرارها — يغذّي اختيار النقطة بالدبّوس الثابت. @@ -83,6 +90,29 @@ class _RideMapState extends State { static const _fallback = im.LatLng(31.9539, 35.9106); // عمّان + /// حارس الإحداثيات — **بلا هذا يسقط التطبيق أصلاً لا استثناءً في دارت**. + /// + /// `std::domain_error` الذي رأيناه (2026-08-05) يأتي من `mbgl::LatLng` + /// في MapLibre الأصلي، وهو يرمي حرفياً عند: `latitude must not be NaN` · + /// `longitude must not be NaN` · `latitude must be between -90 and 90` · + /// `longitude must not be infinite`. (السلاسل الأربع موجودة في ثنائي + /// `MapLibre.framework`، وهي مصدر `std::domain_error` الوحيد فيه — أمّا + /// الستايل فبريء: الملفّات الخمسة كلها اجتازت `validateStyleMin` بصفر + /// أخطاء.) + /// + /// وهو استثناء **C++ يعبر حدّ المنصّة**، فلا `try/catch` في دارت يمسكه: + /// المنع قبل التمرير هو العلاج الوحيد. + static im.LatLng? _safe(GeoPoint? p, String tag) { + if (p == null) return null; + final ok = p.lat.isFinite && p.lng.isFinite && p.lat.abs() <= 90; + if (!ok) { + dev.log('إحداثية غير صالحة رُفضت [$tag]: ${p.lat}, ${p.lng}', + name: 'RideMap'); + return null; + } + return im.LatLng(p.lat, p.lng); + } + /// الستايلان مرفقان في `assets/` — منقولان من سيرو الميداني. static const _lightStyleAsset = 'assets/style.json'; static const _darkStyleAsset = 'assets/style_dark.json'; @@ -123,9 +153,66 @@ class _RideMapState extends State { } void _moveTo(GeoPoint p) { - _controller?.animateCamera( - im.CameraUpdate.newLatLngZoom(im.LatLng(p.lat, p.lng), 15.5), - ); + final target = _safe(p, 'moveTo'); + if (target == null) return; + _controller?.animateCamera(im.CameraUpdate.newLatLngZoom(target, 15.5)); + } + + /// يفكّ ترميز المسار **مصفّىً**: مسار مشوّه من الخادم (أو سلسلة مقطوعة) + /// يعطي خطوط عرض خارج ±90، وتلك تصل `mbgl::LatLng` فتُسقط التطبيق أصلاً. + static List _decodeRoute(String encoded) { + final all = im.PolylineUtils.decode(encoded); + final good = all + .where((p) => + p.latitude.isFinite && + p.longitude.isFinite && + p.latitude.abs() <= 90) + .toList(); + if (good.length != all.length) { + dev.log('نقاط مسار غير صالحة رُفضت: ${all.length - good.length}', + name: 'RideMap'); + } + return good; + } + + /// المسارات كطبقات `Polyline` — مجموعة فارغة ما لم يكن الستايل جاهزاً + /// والنقاط صالحة. + /// + /// **خطّان لا خطّ**: مسار الرحلة (`route`) بلون المسار وعرض 5، ومسار + /// اقتراب السائق (`pickupRoute`) أرفع وبلون المعلومات — كي يفرّق الراكب + /// بنظرة بين «طريقي» و«طريق السائق إليّ». + Set _routePolylines(Color routeColor, Color pickupColor) { + if (!_styleReady) return const {}; + + final lines = {}; + + final encoded = widget.route?.encodedPoints; + if (encoded != null && encoded.isNotEmpty) { + final points = _decodeRoute(encoded); + if (points.length >= 2) { + lines.add(im.Polyline( + polylineId: const im.PolylineId('route'), + points: points, + color: routeColor, + width: 5, + )); + } + } + + final pickup = widget.pickupRoute?.encodedPoints; + if (pickup != null && pickup.isNotEmpty) { + final points = _decodeRoute(pickup); + if (points.length >= 2) { + lines.add(im.Polyline( + polylineId: const im.PolylineId('pickup'), + points: points, + color: pickupColor, + width: 4, + )); + } + } + + return lines; } /// يضبط الكاميرا على المسار كاملاً بدل نقطة واحدة. @@ -135,7 +222,7 @@ class _RideMapState extends State { void _fitRoute() { final encoded = widget.route?.encodedPoints; if (encoded == null || encoded.isEmpty) return; - final points = im.PolylineUtils.decode(encoded); + final points = _decodeRoute(encoded); if (points.length < 2) return; var minLat = points.first.latitude, maxLat = points.first.latitude; @@ -147,6 +234,10 @@ class _RideMapState extends State { if (p.longitude > maxLng) maxLng = p.longitude; } + // `mbgl::EdgeInsets` يرمي هو الآخر على NaN (`bottom must not be NaN`)، + // و`bottomInset` مشتقّ من `MediaQuery` فقد يصل صفراً أو NaN في أوّل إطار. + final bottom = widget.bottomInset.isFinite ? widget.bottomInset : 280.0; + _controller?.animateCamera( im.CameraUpdate.newLatLngBounds( im.LatLngBounds( @@ -156,7 +247,7 @@ class _RideMapState extends State { left: 48, right: 48, top: 120, - bottom: widget.bottomInset, + bottom: bottom, ), ); } @@ -167,19 +258,19 @@ class _RideMapState extends State { if (controller == null || _syncing) return; _syncing = true; - Future px(GeoPoint? p) async { - if (p == null) return null; - final point = await controller.getScreenCoordinate( - im.LatLng(p.lat, p.lng), - ); + // `getScreenCoordinate` يعبر إلى `mbgl::LatLng` هو الآخر — نفس الحارس. + Future px(GeoPoint? p, String tag) async { + final target = _safe(p, tag); + if (target == null) return null; + final point = await controller.getScreenCoordinate(target); return Offset(point.x.toDouble(), point.y.toDouble()); } try { final results = await Future.wait([ - px(widget.origin), - px(widget.destination), - px(widget.driver), + px(widget.origin, 'origin'), + px(widget.destination, 'destination'), + px(widget.driver, 'driver'), ]); if (!mounted) return; setState(() { @@ -205,22 +296,19 @@ class _RideMapState extends State { // الخريطة تبدّل ستايلها مع الثيم — خريطة نهارية في وضع ليلي تكسر // الغرض (docs/26 §2). وتبديله يهدم مديري التعليقات، فيُصفَّر العَلَم. // - // ⚠️ **ستايل محلّي لا بعيد.** ستايل map-saas البعيد - // (`/api/maps/style.json?theme=light`) فيه **12 طبقة `symbol` تستعمل - // `icon-image` بلا `sprite` معرَّف** — وMapLibre الأصلي يرمي عندها - // `std::domain_error` فيُسقط التطبيق قبل أن يظهر إطار واحد - // (مثبت 2026-08-05). الستايلان المرفقان في `assets/` سليمان: بهما - // `sprite` وبلاطاتهما من `tiles.intaleqapp.com` نفسها. - // - // وهذا حرفياً حلّ سيرو الميداني: انظر + // **ستايل محلّي لا بعيد** — كما في سيرو الميداني حرفياً: // `apps/rider/lib/views/home/map_widget.dart/google_map_passenger_widget.dart` - // — يمرّر `'assets/style.json'` / `'assets/style_dark.json'` نفسهما، وملفّاه + // يمرّر `'assets/style.json'` / `'assets/style_dark.json'` نفسهما، وملفّاه // مطابقان لهذين طبقةً بطبقة (لا يختلفان إلا في اسم العلامة التجارية). - // فلا تُعِد هذا السطر إلى `IntaleqStyles.light/obsidian` — جُرّب وأسقط - // التطبيق مرتين. - // // ومعها نكسب الإقلاع بلا انتظار شبكة — وهو ما يعنيه «الشكل حتمي من أول // إقلاع» (docs/26 §1). + // + // ملاحظة تصحيحية (2026-08-05): كان مكتوباً هنا أن ستايل map-saas البعيد + // هو سبب `std::domain_error` لأنه بلا `sprite`. **هذا خطأ.** الستايلات + // الخمسة (المحلّيان + البعيد + ستايلا الحزمة) اجتازت + // `validateStyleMin` من `@maplibre/maplibre-gl-style-spec` بصفر أخطاء، + // والسقوط تكرّر بالستايل المحلّي أيضاً. المصدر الحقيقي هو حارس + // الإحداثيات في `mbgl::LatLng`/`mbgl::EdgeInsets` — انظر `_safe` أعلاه. final styleUrl = isDark ? _darkStyleAsset : _lightStyleAsset; if (_styleUrl != null && _styleUrl != styleUrl) _styleReady = false; _styleUrl = styleUrl; @@ -234,13 +322,17 @@ class _RideMapState extends State { // فنقفله من هنا بدل الاتّكال على ذلك. autoCache: false, initialCameraPosition: im.CameraPosition( - target: widget.origin == null - ? _fallback - : im.LatLng(widget.origin!.lat, widget.origin!.lng), + target: _safe(widget.origin, 'initialCamera') ?? _fallback, zoom: 15, ), styleUrl: styleUrl, - myLocationEnabled: true, + // نقطة «موقعي» تُفعَّل **بعد** أن يصلنا موقع حقيقي لا عند الإنشاء. + // قبل ذلك يكون `MLNMapView.userLocation.coordinate` هو + // `kCLLocationCoordinate2DInvalid` أي (-180, -180) — وخط عرض -180 + // يخترق حارس `mbgl::LatLng` نفسه (`latitude must be between -90 + // and 90`). و`origin` لا يصير غير فارغ إلا بعد منح الإذن ووصول + // قراءة فعلية، فهو الشرط الصحيح تماماً. + myLocationEnabled: widget.origin != null, compassEnabled: false, zoomControlsEnabled: false, onMapCreated: _onCreated, @@ -257,20 +349,9 @@ class _RideMapState extends State { } _syncPixels(); }, - // **المسار وحده annotation** — لا بديل عنه، ومحروسٌ بعَلَم الستايل. - polylines: _styleReady && widget.route != null && - widget.route!.encodedPoints.isNotEmpty - ? { - im.Polyline( - polylineId: const im.PolylineId('route'), - points: im.PolylineUtils.decode( - widget.route!.encodedPoints, - ), - color: colors.mapRoute, - width: 5, - ), - } - : const {}, + // **المسار وحده annotation** — لا بديل عنه، ومحروسٌ بعَلَم الستايل + // وبتصفية الإحداثيات (نقاط المسار تعبر إلى `mbgl::LatLng` أيضاً). + polylines: _routePolylines(colors.mapRoute, colors.info), ), // ── الطبقة الفلاترية: نقاط وأسماء وسائق ────────────────────────── diff --git a/apps/rider_new/lib/features/trip/view/widgets/trip_contact_row.dart b/apps/rider_new/lib/features/trip/view/widgets/trip_contact_row.dart new file mode 100644 index 0000000..b36997a --- /dev/null +++ b/apps/rider_new/lib/features/trip/view/widgets/trip_contact_row.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../../core/build_config.dart'; +import '../../../../core/design/tokens.dart'; +import '../../../../core/design/tripz_colors.dart'; +import '../../../../core/l10n/l10n.dart'; +import '../../../../core/router.dart'; +import '../../../../core/tenant/feature_gate.dart'; +import '../../data/models/trip.dart'; + +/// طرق التواصل مع السائق في صفّ واحد — نمط سيرو +/// (`controller/functions/launch.dart`: هاتف · رسالة · واتساب). +/// +/// **ثلاثة أفعال لا زرّ واحد**: الشبكة تسقط فتفشل المحادثة داخل التطبيق، +/// وحينها يبقى الاتصال الهاتفي العادي — وهو الوحيد الذي يعمل بلا إنترنت. +/// لذلك لا يُختزل شيء منها في قائمة مخفيّة. +/// +/// **المكالمة المجانية (VoIP) غير موجودة هنا** عن قصد: `Features.calls` +/// مطفأ لأن WebRTC لم يُبنَ في التطبيق بعد (الخادم يدعمه — `call:*` في +/// docs/38 §9). حين يُبنى يُضاف زرّ رابع خلف نفس العَلَم. +class TripContactRow extends StatelessWidget { + const TripContactRow({super.key, required this.trip}); + + final Trip trip; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final phone = trip.driverPhone; + final hasPhone = phone != null && phone.trim().isNotEmpty; + + return Row( + children: [ + if (Features.chat && context.tenantHas('chat')) + Expanded( + child: _ContactAction( + icon: Icons.forum_outlined, + label: l10n.chatTitle, + onTap: () => context.push('${Routes.chat}?trip=${trip.id}'), + ), + ), + if (hasPhone) ...[ + const SizedBox(width: Space.xs), + Expanded( + child: _ContactAction( + icon: Icons.call_outlined, + label: l10n.tripCall, + onTap: () => _launch('tel:${_clean(phone)}'), + ), + ), + const SizedBox(width: Space.xs), + Expanded( + child: _ContactAction( + icon: Icons.sms_outlined, + label: l10n.tripMessage, + onTap: () => _launch('sms:${_clean(phone)}'), + ), + ), + ], + ], + ); + } + + /// الأرقام تصل بصيغ مختلفة (`0790…`, `+962790…`, بمسافات) — يُنظَّف الرقم + /// من كل ما ليس رقماً أو `+`، وإلا رفضه `tel:` صامتاً. + static String _clean(String raw) { + final kept = raw.replaceAll(RegExp(r'[^0-9+]'), ''); + return kept.isEmpty ? raw : kept; + } + + static Future _launch(String url) async { + final uri = Uri.tryParse(url); + if (uri == null) return; + // جهازٌ بلا تطبيق هاتف (جهاز لوحي) يفشل هنا — لا يُسقط الشاشة. + await launchUrl(uri, mode: LaunchMode.externalApplication); + } +} + +class _ContactAction extends StatelessWidget { + const _ContactAction({ + required this.icon, + required this.label, + required this.onTap, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Material( + color: context.colors.surfaceContainerHighest, + borderRadius: Radii.card_, + child: InkWell( + onTap: onTap, + borderRadius: Radii.card_, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Space.sm), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: Sizes.iconSm, color: context.colors.primary), + const SizedBox(height: Space.xxs), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.texts.labelSmall, + ), + ], + ), + ), + ), + ); + } +} + +/// شريط «كم بقي» — الوقت والمسافة من مسار الاقتراب الحيّ. +/// +/// هذا **أكثر ما يسأل عنه الراكب** بعد وصول السائق للشاشة، وكان غائباً +/// تماماً: البطاقة كانت تقول «السائق في الطريق» بلا رقم واحد. +class TripEtaStrip extends StatelessWidget { + const TripEtaStrip({ + super.key, + required this.minutes, + required this.km, + }); + + final double minutes; + final double km; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Container( + padding: const EdgeInsets.symmetric( + horizontal: Space.md, + vertical: Space.sm, + ), + decoration: BoxDecoration( + color: context.tripzColors.info.withValues(alpha: 0.10), + borderRadius: Radii.card_, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.access_time_rounded, + size: Sizes.iconSm, + color: context.tripzColors.info, + ), + const SizedBox(width: Space.xs), + Text( + // أقلّ من دقيقة يُعرض «1» لا «0» — «0 دقيقة» تعني «وصل» وهي كذبة. + l10n.rideEta(minutes < 1 ? '1' : minutes.round().toString()), + style: context.texts.titleSmall, + ), + const SizedBox(width: Space.sm), + Text('·', style: context.texts.titleSmall), + const SizedBox(width: Space.sm), + Text( + l10n.rideDistanceKm(km.toStringAsFixed(1)), + style: context.texts.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} diff --git a/apps/rider_new/lib/features/trip/view/widgets/vehicle_color.dart b/apps/rider_new/lib/features/trip/view/widgets/vehicle_color.dart new file mode 100644 index 0000000..cd9ddc2 --- /dev/null +++ b/apps/rider_new/lib/features/trip/view/widgets/vehicle_color.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +/// يحوّل لون المركبة — hex أو اسماً — إلى لون يُرسم به. +/// +/// الخادم غير متّسق عمداً: `vehicles` عندها `color_hex` بينما حدث `assigned` +/// يرسل `color` اسماً (`trips.service.ts:427`). فالمُحوِّل يقبل الاثنين +/// ويُقدّم الـhex لأنه دقيق. +/// +/// الأسماء تُطابَق **بالعربية والإنجليزية معاً**: السائقون يُدخلونها بلغتهم، +/// وقائمة إنجليزية وحدها تعني رمادياً افتراضياً لنصف الأسطول. +class VehicleColor { + const VehicleColor._(); + + /// اللون الذي يُرسم به رمز السيارة، أو `null` إن تعذّرت المطابقة — + /// و`null` تعني «لا تلوّن» لا «لوّن رمادياً»، فالرمادي لونُ سيارةٍ حقيقي + /// ولا يصحّ أن يعني «مجهول». + static Color? resolve({String? hex, String? name}) => + _fromHex(hex) ?? _fromName(name); + + static Color? _fromHex(String? hex) { + if (hex == null) return null; + var raw = hex.replaceFirst('#', '').trim(); + if (raw.length == 3) { + // ‏`#f00` → `#ff0000`: صيغة مختصرة شائعة في لوحات الإدارة. + raw = raw.split('').map((c) => '$c$c').join(); + } + if (raw.length == 6) raw = 'FF$raw'; + if (raw.length != 8) return null; + final value = int.tryParse(raw, radix: 16); + return value == null ? null : Color(value); + } + + static const _names = { + 'white': Colors.white, + 'أبيض': Colors.white, + 'black': Colors.black, + 'أسود': Colors.black, + 'silver': Color(0xFFC0C0C0), + 'فضي': Color(0xFFC0C0C0), + 'grey': Color(0xFF808080), + 'gray': Color(0xFF808080), + 'رمادي': Color(0xFF808080), + 'red': Color(0xFFD32F2F), + 'أحمر': Color(0xFFD32F2F), + 'blue': Color(0xFF1976D2), + 'أزرق': Color(0xFF1976D2), + 'green': Color(0xFF388E3C), + 'أخضر': Color(0xFF388E3C), + 'yellow': Color(0xFFFBC02D), + 'أصفر': Color(0xFFFBC02D), + 'orange': Color(0xFFF57C00), + 'برتقالي': Color(0xFFF57C00), + 'brown': Color(0xFF6D4C41), + 'بني': Color(0xFF6D4C41), + 'beige': Color(0xFFE8DCC4), + 'بيج': Color(0xFFE8DCC4), + 'gold': Color(0xFFC9A227), + 'ذهبي': Color(0xFFC9A227), + 'navy': Color(0xFF1A2340), + 'كحلي': Color(0xFF1A2340), + }; + + static Color? _fromName(String? name) { + if (name == null) return null; + return _names[name.trim().toLowerCase()]; + } +} diff --git a/apps/rider_new/pubspec.lock b/apps/rider_new/pubspec.lock index 2ce52af..2759eca 100644 --- a/apps/rider_new/pubspec.lock +++ b/apps/rider_new/pubspec.lock @@ -353,10 +353,10 @@ packages: dependency: transitive description: name: geolocator_linux - sha256: d64112a205931926f4363bb6bd48f14cb38e7326833041d170615586cd143797 + sha256: c4e966f0a7a87e70049eac7a2617f9e16fd4c585a26e4330bdfc3a71e6a721f3 url: "https://pub.dev" source: hosted - version: "0.2.4" + version: "0.2.3" geolocator_platform_interface: dependency: transitive description: @@ -598,13 +598,13 @@ packages: source: hosted version: "3.0.0" package_info_plus: - dependency: transitive + dependency: "direct main" description: name: package_info_plus - sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" url: "https://pub.dev" source: hosted - version: "9.0.1" + version: "8.3.1" package_info_plus_platform_interface: dependency: transitive description: diff --git a/apps/rider_new/pubspec.yaml b/apps/rider_new/pubspec.yaml index c79c50d..62fa82a 100644 --- a/apps/rider_new/pubspec.yaml +++ b/apps/rider_new/pubspec.yaml @@ -48,6 +48,10 @@ dependencies: permission_handler: ^12.0.1 device_info_plus: 12.3.0 + # رقم الإصدار في شاشة الإعدادات — كانت موجودة كاعتماد غير مباشر أصلاً، + # فترقيتها لمباشر لا تجرّ كوداً أصلياً جديداً. + package_info_plus: ^8.1.1 + cupertino_icons: ^1.0.8 # باقي الحزم تُضاف في مرحلتها لا قبلها (docs/37 §1.7): diff --git a/docs/40-rider-ux-gaps-plan.md b/docs/40-rider-ux-gaps-plan.md new file mode 100644 index 0000000..61e064c --- /dev/null +++ b/docs/40-rider-ux-gaps-plan.md @@ -0,0 +1,210 @@ +# 40 — فجوات واجهة `rider_new` مقابل سيرو، وخطة الإغلاق + +تاريخ: 2026-08-05 · الحالة: خطة معتمدة للتنفيذ · يمكن تسليمها لنموذج آخر بلا سياق إضافي + +## 0. لماذا هذه الوثيقة + +المالك راجع `rider_new` مقابل تطبيقَي سيرو المنشورين (App Store: +`id6458734951` راكب، `id6502189302` سائق؛ Play: `com.mobileapp.store.ride`) +وطلب تشخيصاً دقيقاً — لا وعوداً — لثلاث فجوات محدّدة، مع خطة قابلة للتنفيذ +من أي طرف. هذا فحص كودٍ فعلي على الشجرتين، لا افتراض. + +## 1. النتيجة أوّلاً: الفجوة الحقيقية أضيق ممّا بدت + +فحصت `apps/rider_new` مقابل `apps/rider` (سيرو) سطراً بسطر على النقاط +الثلاث التي ذكرها المالك: + +| البند | الحالة في `rider_new` | الحكم | +|---|---|---| +| ارتفاع الخريطة (70-80%) | [ride_page.dart:87](../apps/rider_new/lib/features/trip/view/ride_page.dart#L87): `bottomInset = height * 0.34` → الخريطة تأخذ فعلياً **~66%** | **قريب من المطلوب أصلاً**، ليس فجوة صفرية | +| بحث الأماكن كديالوج كامل الشاشة | [planner_sheet.dart](../apps/rider_new/lib/features/trip/view/widgets/planner_sheet.dart) — حقل بحث **داخل** الورقة السفلية المطويّة (`AnimatedSize`) | **فجوة حقيقية** — راجع §2 | +| الدعوات/الكوبونات/المكافآت | `features/rewards/` كامل: `RewardsCubit`، `ReferralSummary`، `Coupon`، `share_plus` مُستخدَم في [rewards_page.dart](../apps/rider_new/lib/features/rewards/view/rewards_page.dart) و[app_drawer.dart](../apps/rider_new/lib/core/ui/app_drawer.dart) | **موجود ومبني**، ليس فراغاً — قد يحتاج ضبط تفاصيل بصرية فقط | +| المحفظة | `features/wallet/` كامل: `WalletCubit`، `WalletRepository`، `wallet_page.dart` | **موجود ومبني** | + +**الخلاصة:** الادّعاء بأن «لا شيء جديد يعمل» غير دقيق مقارنةً بحالة الكود +الفعلية — رحلاتي/المحفظة/المكافآت مبنية وتستدعي مستودعات حقيقية لا بيانات +عشوائية. الفجوة القابلة للإشارة إليها بدقة هي بند واحد: **شكل بحث الأماكن**. + +## 2. الفجوة الوحيدة المؤكَّدة: بحث الأماكن + +### سيرو (المرجع، `apps/rider/lib/views/home/route_planner/rp_search_dialog.dart`) + +ديالوج **كامل الشاشة** منفصل تماماً عن الورقة السفلية: + +```dart +showGeneralDialog( + context: context, + barrierDismissible: true, + barrierColor: RP.sheet, + transitionDuration: const Duration(milliseconds: 300), + pageBuilder: (context, animation, secondaryAnimation) => + RpSearchDialog(accent: accent, hintText: hintText, isOrigin: isOrigin), + transitionBuilder: (context, animation, secondaryAnimation, child) => + SlideTransition( + position: Tween(begin: const Offset(0, 1), end: Offset.zero) + .animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)), + child: child, + ), +); +``` + +يُستدعى بنسختين — `isOrigin: true/false` — بدل حقل واحد يبحث عن الوجهة فقط. + +### `rider_new` الحالي + +`planner_sheet.dart` يعرض حقل البحث والنتائج **داخل** `_Sheet` المطوية على +الخريطة (`AnimatedSize` في [ride_page.dart:206](../apps/rider_new/lib/features/trip/view/ride_page.dart#L206)): +مساحة أضيق، النتائج تُقصّ عند حدّ الورقة، ولا يوجد تمييز بين البحث عن نقطة +البداية والوجهة (البحث الحالي يذهب دائماً لـ`PickTarget.destination` — +انظر `planner_sheet.dart:128`). + +### خطة الإغلاق (قابلة للتنفيذ مباشرة) + +1. **`PlaceSearchDialog` جديد** في `features/trip/view/widgets/` — `showGeneralDialog` + بنفس نمط السحب من الأسفل (300ms، `Curves.easeOutCubic`)، بارامتر + `PickTarget target` بدل `isOrigin` (النوع الموجود أصلاً في `RideCubit`). +2. يحوي: `RouteTimeline` (موجود) في الأعلى ثابتاً، ثم `TripzTextField` + بالبحث المباشر (`cubit.searchPlaces` موجود)، ثم قائمة نتائج بملء الشاشة + المتبقية — لا `Flexible` داخل `AnimatedSize`. +3. `_Collapsed._MapFab`/حقل «إلى أين؟» في `ride_page.dart:307` يستدعي + `PlaceSearchDialog.show(context, target: PickTarget.destination)` بدل + `cubit.openPlanner` (الذي يبقى للحالات الأخرى — تحديد من الخريطة، إلخ — + إن وُجدت استدعاءات أخرى له، تُفحص قبل الحذف). +4. `RouteTimeline.onTapOrigin` (الموجود في `planner_sheet.dart:60`) يفتح نفس + الديالوج بـ`PickTarget.origin`. +5. **لا تُنسخ** ملفات `rp_search_dialog.dart` وأخواتها حرفياً (docs/37 §2: + سيرو مرجع لا مصدر نسخ) — يُعاد بناؤها بـCubit/`TripzTextField`/نظام + التصميم الحالي، بنفس السلوك المرصود فقط. + +## 3. ما لم يُثبَت بعد — يحتاج فحصاً بصرياً لا كودياً + +الفحص أعلاه كودي (قراءة ملفات). البنود التالية **مبنية في الكود** لكن +مطابقتها البصرية لسيرو لم تُتحقّق على جهاز حقيقي: + +- شكل بطاقة المكافآت/الكوبون في `rewards_page.dart` — الحقول موجودة + (`referrals`, `coupons`)، لكن التخطيط والألوان لم تُقارَن سطراً بسطر مع + سيرو. +- نص/زر مشاركة الدعوة تحديداً (رابط + كود) — `share_plus` مستورد، لكن نص + الرسالة المُشارَكة نفسه يحتاج تحقّقاً من محتواه مقابل ما يشاركه سيرو. + +**لتنفيذ هذين البندين بدقة:** التقط لقطات شاشة من تطبيقَي سيرو المنشورين +(الروابط أعلاه) وضعها بجانب `rewards_page.dart`/`app_drawer.dart` الحاليين +— هذا الجزء يحتاج عيناً بشرية أو أداة مقارنة بصرية، لا قراءة كود وحدها. + +## 3.ب — جرد الدراور مقابل سيرو (فحص كودي، 2026-08-05) + +مرجع سيرو الحقيقي **ليس** `drawer_menu_page.dart` (ذاك ملف ميّت 25 سطراً +بلا مدخل واحد)، بل +`apps/rider/lib/views/home/map_widget.dart/map_menu_widget.dart`. + +| مدخل سيرو | صفحته في سيرو | الحالة في `rider_new` | +|---|---|---| +| Mawasalati | `TransitHomePage` | ❌ منتج منفصل (`backend-transit/`) — خارج نطاق تطبيق الراكب | +| My Balance | `PassengerWallet` | ✅ `Routes.wallet` → `/wallet`, `/wallet/transactions` موجودتان | +| Order History | `OrderHistory` | ✅ `Routes.history` → `GET /trips/mine` | +| Promos | `PromosPassengerPage` | ✅ `Routes.rewards` → `/rewards/referrals`, `/rewards/coupons` | +| Contact Us | `ContactUsPage` | ✅ `Routes.support` | +| Complaint | `ComplaintPage` | ❌ **محجوب على الخادم** — لا وحدة `complaints` في `backend-archive/src/modules` | +| Driver | رابط خارجي | ✅ `BuildConfig.driverAppUrl` | +| Share App | `ShareAppPage` | ⚠️ عندنا مشاركة مباشرة لا صفحة — فرق شكل لا وظيفة | +| Privacy Policy | رابط خارجي | ✅ | +| (صفّ الأيقونات) Profile | `PassengerProfilePage` | ✅ `Routes.profileEdit` | +| (صفّ الأيقونات) Notification | `NotificationPage` | ✅ **أُنجز 2026-08-05** — انظر أدناه | +| (صفّ الأيقونات) Setting | `SettingPage` | ✅ `Routes.settings` | + +**تصحيح لتشخيص أسبق:** لا شيء في الدراور «بيانات ديمو». كل صفحة تستدعي +نقطة حقيقية موجودة في `backend-archive` (تحقّقت من `wallet.controller.ts`, +`rewards.controller.ts`, `trips.controller.ts`, `payments/*`). ما يبدو +فارغاً هو خادمٌ لم تُبذر بياناته، لا واجهةٌ وهمية. + +### صندوق الإشعارات — أُنجز، بحدٍّ معروف + +`features/notifications/` + `core/notifications/notification_store.dart`. + +الخادم يوفّر `POST /notifications/token` فقط — **لا نقطة سرد** +(`backend-archive/src/modules/notifications/notifications.controller.ts` +فيه `@Post('token')` وحده). فالصندوق **محلّي**: `PushService` يلتقط +`onMessage` و`onMessageOpenedApp` و`getInitialMessage` ويحفظها في +`SharedPreferences` (سقف 50)، والدراور يعرض عدّاد غير المقروء. + +**الحدّ:** ما يصل والتطبيق مغلق تماماً لا يُلتقط (لا `background handler` +مسجَّل). حين تُضاف نقطة سرد على الخادم يُستبدل المصدر وحده ويبقى شكل +الشاشة. + +### شاشات الدراور الثلاث — أُنجزت 2026-08-05 + +**رحلاتي.** البطاقة كانت ثلاثة عناصر (نقطة · حالة · سعر). والسبب لم يكن +نقصاً في الخادم بل **قراءةً ناقصة**: `trip.entity.ts` فيه `requested_at` +(`CreateDateColumn`) و`distance_km` و`duration_min` و`payment_method` +و`service_class`، وكلها تصل مع `GET /trips/mine` وكان `Trip.fromJson` +يتجاهلها. صارت تُقرأ وتُعرض: التاريخ والوقت، المسافة والمدة، وشارة طريقة +الدفع. +- **العناوين النصّية ما زالت مستحيلة**: الكيان يخزّن `origin_lat/lng` بلا + `origin_address`. سيرو يعرض عناوين لأن PHP يخزّنها. عرضُ إحداثيات خام + أسوأ من لا شيء — يحتاج حقلاً على الخادم. + +**الإعدادات.** كانت قائمة مسطّحة (مظهر + لغة). صارت بأقسام كسيرو +(`setting_page.dart`: General · Support & Info)، وأُضيف: مدخل المساعدة، +مشاركة التطبيق، سياسة الخصوصية، **ورقم الإصدار** (مفتاح `aboutVersion` كان +موجوداً بلا شاشة تستعمله). +- **باگ مُصلَح:** تبديل اللغة كان **محلياً فقط**. الخادم يصوغ الإشعارات + بلغة المستخدم المخزّنة عنده و`PATCH /users/me` يقبل `language` + (docs/38 §3) — فكانت الواجهة عربية والإشعارات إنجليزية. صار التبديل + يُزامَن، وفشلُ الشبكة لا يمنع التبديل المحلي. + +**حسابي.** أُضيفت ترويسة (حرف الاسم · الاسم · الرقم · **التقييم**). +`AppUser.rating` كان يصل من `GET /users/me` ولا تعرضه أي شاشة، بينما الراكب +يُقيَّم من السائق ويحقّ له رؤية نتيجته. +- **لا صورة شخصية**: الخادم لا يوفّر رفع صور (سيرو يفعل عبر PHP). وحقول + سيرو الأخرى (الجنس، التعليم، الحالة الاجتماعية، هاتف الطوارئ) غير ممكنة: + `PATCH /users/me` يقبل `{name, language}` حصراً. + +الثلاث مُطبَّقة على `driver_new` أيضاً (كانت متطابقة بايتاً ببايت مع +`rider_new` قبل التعديل، فالنسخ آمن). + +## 3.ج — تطبيق السائق: ما أُصلح قبل التجربة الحقيقية (2026-08-05) + +**السائق كان أعمى أثناء الرحلة.** `duty_page.dart` كان يمرّر +`RideMap(origin: me, cameraTarget: me)` فقط — بلا نقطة الراكب، بلا الوجهة، +بلا خطّ يقوده. تجربةٌ حقيقية بهذه الشاشة مستحيلة. +- `DutyState` أخذ `navRoute` و`navTarget`؛ والهدف تشتقّه **آلة الحالات**: + نقطة الراكب في `assigned/driver_arriving/driver_arrived`، ووجهته في + `in_progress`. +- `DutyCubit` صار يحسب المسار من موقع السائق إلى الهدف، بنفس حدّ الـ50 متراً + المستعمل في تطبيق الراكب (يلتقط الانعطاف، يتجاهل رجفة GPS)، ويعيد الحساب + فوراً عند كل انتقال حالة بدل انتظار نبضة الموقع. +- الخريطة صارت تعرض النقطتين والمسار، ومرفوعة بالثلث كخريطة الراكب. +- شريط الوقت والمسافة (`NavEtaStrip`) في ورقة الرحلة. + +**باگان مُصلَحان:** +1. لافتة نفاد الرصيد كانت تعرض `l10n.errorGeneric` («حدث خطأ غير متوقّع») + بدل `l10n.creditBlocked` («رصيدك التشغيلي نفد — اشحنه لتستقبل الطلبات»). + السائق المحجوب كان يرى خطأً عاماً لا سبباً. +2. زرّ المحادثة كان `'\${Routes.chat}?trip=\\\${trip.id}'` — الهروب الزائد + يجعل الناتج نصّاً حرفياً `?trip=\${trip.id}`، فالمحادثة تُفتح بمعرّف رحلة + غير موجود ولا تحمّل رسالة واحدة أبداً. (نفس الخطأ كان في تطبيق الراكب + وزال حين استُبدل الزرّ بـ`TripContactRow`.) + +**محجوب على الخادم:** لا اسم الراكب ولا رقمه يصلان السائق — لا +`rider_name`/`rider_phone` في أي وحدة من `backend-archive/src/modules`. +فزرّ «اتصل بالراكب» غير ممكن؛ المحادثة داخل التطبيق هي القناة الوحيدة. + +### Complaint — محجوب، ويحتاج قراراً + +بناؤه يتطلّب وحدة `complaints` في NestJS (كيان + `POST /complaints` + +`GET /complaints/mine`). هذا شغل باك إند يُبنى ويُختبر على الخادم +(`docs/15`), لا على الماك. لم أبنِ واجهةً لنقطة غير موجودة. + +## 4. الأولوية والترتيب + +1. ~~`PlaceSearchDialog` (§2)~~ — **أُنجز 2026-08-05**. +2. ~~رفع الخريطة عن القاع بالثلث~~ — **أُنجز**: `_sheetFraction = 0.33` في + `ride_page.dart`. الخريطة والدبّوس في `Positioned` واحدة (وإلا أشار + الدبّوس إلى غير النقطة المؤكَّدة)، والورقة لها `minHeight` بنفس النسبة. +3. ~~صندوق الإشعارات~~ — **أُنجز** (§3.ب). +4. `ComplaintPage` — **محجوب**: يحتاج وحدة `complaints` في NestJS أولاً. +5. مطابقة بصرية للمكافآت/المشاركة (§3) — تحتاج لقطات سيرو أولاً. +6. تكرار ما ينطبق على `driver_new` — فُحص: لا `PlannerSheet` ولا بحث أماكن + فيه، فلا شيء يُكرَّر من §2. + +Related: [[docs/37]] (قاعدة سيرو مرجع لا مصدر نسخ) · [[docs/39]] (شاشات سيرو المرصودة)