Update codebase
This commit is contained in:
@@ -23,11 +23,32 @@ class BuildConfig {
|
|||||||
static const String logoAsset = 'assets/images/logo.png';
|
static const String logoAsset = 'assets/images/logo.png';
|
||||||
static const String splashLogoAsset = 'assets/images/splash_logo.png';
|
static const String splashLogoAsset = 'assets/images/splash_logo.png';
|
||||||
|
|
||||||
/// روابط خارجية لكل مستأجر — تُولَّد مع بقية الملف (N3).
|
/// روابط المتاجر الأربعة — **صفحات المتجر الحقيقية المنشورة**، أعطاها
|
||||||
static const String storeUrl = String.fromEnvironment(
|
/// المالك 2026-08-05. لا تُستعمل مباشرةً: `StoreLinks` هو من يختار الرابط
|
||||||
'STORE_URL',
|
/// حسب المنصّة والدور، لأن «قيّم التطبيق» على آيفون سائق ≠ نفس الرابط على
|
||||||
defaultValue: 'https://tripz.app',
|
/// أندرويد راكب.
|
||||||
|
///
|
||||||
|
/// المعرّفات تطابق هوية النشر المقفلة: `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(
|
static const String privacyUrl = String.fromEnvironment(
|
||||||
'PRIVACY_URL',
|
'PRIVACY_URL',
|
||||||
defaultValue: 'https://tripz.app/privacy',
|
defaultValue: 'https://tripz.app/privacy',
|
||||||
@@ -38,12 +59,6 @@ class BuildConfig {
|
|||||||
'SUPPORT_PHONE',
|
'SUPPORT_PHONE',
|
||||||
defaultValue: '962790000000',
|
defaultValue: '962790000000',
|
||||||
);
|
);
|
||||||
|
|
||||||
/// رابط تطبيق الطرف الآخر — السائق يدعو ركّاباً والعكس.
|
|
||||||
static const String driverAppUrl = String.fromEnvironment(
|
|
||||||
'RIDER_APP_URL',
|
|
||||||
defaultValue: 'https://tripz.app',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// أعلام الاستحقاقات (docs/23 §5). **`const` إجبارياً** كي يُحذف كود الميزة
|
/// أعلام الاستحقاقات (docs/23 §5). **`const` إجبارياً** كي يُحذف كود الميزة
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ Future<void> setupInjector() async {
|
|||||||
trips: sl(),
|
trips: sl(),
|
||||||
location: sl(),
|
location: sl(),
|
||||||
realtime: sl(),
|
realtime: sl(),
|
||||||
|
maps: sl(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,12 @@
|
|||||||
"settingsLanguage": "اللغة",
|
"settingsLanguage": "اللغة",
|
||||||
"settingsLanguageArabic": "العربية",
|
"settingsLanguageArabic": "العربية",
|
||||||
"settingsLanguageEnglish": "English",
|
"settingsLanguageEnglish": "English",
|
||||||
|
"settingsSectionGeneral": "عام",
|
||||||
|
"settingsSectionSupport": "الدعم والمعلومات",
|
||||||
|
"settingsSupportLead": "أسئلة شائعة وطرق التواصل",
|
||||||
|
"settingsShareLead": "شارك التطبيق مع أصدقائك",
|
||||||
|
"paymentCash": "نقداً",
|
||||||
|
"paymentWallet": "من المحفظة",
|
||||||
"rideWhereTo": "إلى أين؟",
|
"rideWhereTo": "إلى أين؟",
|
||||||
"rideOrigin": "من",
|
"rideOrigin": "من",
|
||||||
"rideDestination": "إلى",
|
"rideDestination": "إلى",
|
||||||
@@ -224,12 +230,24 @@
|
|||||||
"menuNotifications": "الإشعارات",
|
"menuNotifications": "الإشعارات",
|
||||||
"menuPrivacy": "سياسة الخصوصية",
|
"menuPrivacy": "سياسة الخصوصية",
|
||||||
"menuRateApp": "قيّم التطبيق",
|
"menuRateApp": "قيّم التطبيق",
|
||||||
"shareMessage": "انضم لـTripz كسائق — استعمل كودي {code}",
|
"shareMessage": "انضم لـTripz كسائق — استعمل كودي {code}\n{link}",
|
||||||
|
"shareMessagePlain": "انضم لـTripz كسائق\n{link}",
|
||||||
|
"menuRiderApp": "تطبيق الراكب",
|
||||||
"menuDriverApp": "تطبيق الراكب",
|
"menuDriverApp": "تطبيق الراكب",
|
||||||
"@shareMessage": {
|
"@shareMessage": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"code": {
|
"code": {
|
||||||
"type": "String"
|
"type": "String"
|
||||||
|
},
|
||||||
|
"link": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@shareMessagePlain": {
|
||||||
|
"placeholders": {
|
||||||
|
"link": {
|
||||||
|
"type": "String"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -65,6 +65,12 @@
|
|||||||
"settingsLanguage": "Language",
|
"settingsLanguage": "Language",
|
||||||
"settingsLanguageArabic": "العربية",
|
"settingsLanguageArabic": "العربية",
|
||||||
"settingsLanguageEnglish": "English",
|
"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?",
|
"rideWhereTo": "Where to?",
|
||||||
"rideOrigin": "From",
|
"rideOrigin": "From",
|
||||||
"rideDestination": "To",
|
"rideDestination": "To",
|
||||||
@@ -224,12 +230,24 @@
|
|||||||
"menuNotifications": "Notifications",
|
"menuNotifications": "Notifications",
|
||||||
"menuPrivacy": "Privacy policy",
|
"menuPrivacy": "Privacy policy",
|
||||||
"menuRateApp": "Rate the app",
|
"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",
|
"menuDriverApp": "Rider app",
|
||||||
"@shareMessage": {
|
"@shareMessage": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"code": {
|
"code": {
|
||||||
"type": "String"
|
"type": "String"
|
||||||
|
},
|
||||||
|
"link": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@shareMessagePlain": {
|
||||||
|
"placeholders": {
|
||||||
|
"link": {
|
||||||
|
"type": "String"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import '../l10n/l10n.dart';
|
|||||||
import '../router.dart';
|
import '../router.dart';
|
||||||
import '../session/session_cubit.dart';
|
import '../session/session_cubit.dart';
|
||||||
import '../session/session_state.dart';
|
import '../session/session_state.dart';
|
||||||
|
import '../store_links.dart';
|
||||||
import '../tenant/feature_gate.dart';
|
import '../tenant/feature_gate.dart';
|
||||||
|
|
||||||
/// الدراور — نقطة تطبيق **طبقتَي الميزات** معاً (docs/23 §5 + docs/38 §10):
|
/// الدراور — نقطة تطبيق **طبقتَي الميزات** معاً (docs/23 §5 + docs/38 §10):
|
||||||
@@ -72,28 +73,38 @@ class AppDrawer extends StatelessWidget {
|
|||||||
route: Routes.support,
|
route: Routes.support,
|
||||||
),
|
),
|
||||||
// مداخل محليّة بلا نقطة خادم — تُنفَّذ في الجهاز مباشرة.
|
// مداخل محليّة بلا نقطة خادم — تُنفَّذ في الجهاز مباشرة.
|
||||||
|
//
|
||||||
|
// الروابط من `StoreLinks` لا من ثابتٍ واحد: هذا البناء
|
||||||
|
// يشارك **رابط نفسه** (سائق يشارك رابط السائق)، ويفتح
|
||||||
|
// متجر منصّته هو.
|
||||||
_Action(
|
_Action(
|
||||||
icon: Icons.share_outlined,
|
icon: Icons.share_outlined,
|
||||||
label: l10n.menuShare,
|
label: l10n.menuShare,
|
||||||
onTap: () => SharePlus.instance
|
onTap: () => SharePlus.instance.share(
|
||||||
.share(ShareParams(text: l10n.shareMessage(''))),
|
ShareParams(
|
||||||
|
text: l10n.shareMessagePlain(StoreLinks.thisApp),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_Action(
|
_Action(
|
||||||
icon: Icons.star_outline_rounded,
|
icon: Icons.star_outline_rounded,
|
||||||
label: l10n.menuRateApp,
|
label: l10n.menuRateApp,
|
||||||
onTap: () => _open(BuildConfig.storeUrl),
|
onTap: () => _open(StoreLinks.thisApp),
|
||||||
),
|
),
|
||||||
_Action(
|
_Action(
|
||||||
icon: Icons.shield_outlined,
|
icon: Icons.shield_outlined,
|
||||||
label: l10n.menuPrivacy,
|
label: l10n.menuPrivacy,
|
||||||
onTap: () => _open(BuildConfig.privacyUrl),
|
onTap: () => _open(BuildConfig.privacyUrl),
|
||||||
),
|
),
|
||||||
if (!isDriver)
|
// كان `if (!isDriver)` — وهذا البناء سائق دائماً، فالمدخل
|
||||||
_Action(
|
// لم يكن يظهر أبداً. الطرف الآخر يُحسب لا يُخفى.
|
||||||
icon: Icons.local_taxi_outlined,
|
_Action(
|
||||||
label: l10n.menuDriverApp,
|
icon: isDriver
|
||||||
onTap: () => _open(BuildConfig.driverAppUrl),
|
? Icons.person_outline_rounded
|
||||||
),
|
: Icons.local_taxi_outlined,
|
||||||
|
label: isDriver ? l10n.menuRiderApp : l10n.menuDriverApp,
|
||||||
|
onTap: () => _open(StoreLinks.otherApp),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
|
|
||||||
import '../../../core/design/tokens.dart';
|
import '../../../core/design/tokens.dart';
|
||||||
import '../../../core/design/tripz_colors.dart';
|
import '../../../core/design/tripz_colors.dart';
|
||||||
|
import '../../../core/design/typography.dart';
|
||||||
import '../../../core/di.dart';
|
import '../../../core/di.dart';
|
||||||
import '../../../core/l10n/l10n.dart';
|
import '../../../core/l10n/l10n.dart';
|
||||||
import '../../../core/session/session_cubit.dart';
|
import '../../../core/session/session_cubit.dart';
|
||||||
@@ -54,6 +55,16 @@ class _ProfileEditPageState extends State<ProfileEditPage> {
|
|||||||
),
|
),
|
||||||
child: ListView(
|
child: ListView(
|
||||||
children: [
|
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(
|
TripzTextField(
|
||||||
controller: _name,
|
controller: _name,
|
||||||
label: l10n.profileNameLabel,
|
label: l10n.profileNameLabel,
|
||||||
@@ -91,3 +102,78 @@ class _ProfileEditPageState extends State<ProfileEditPage> {
|
|||||||
messenger.showSnackBar(SnackBar(content: Text(l10n.profileSaved)));
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '../../../core/design/tripz_colors.dart';
|
|||||||
import '../../../core/design/typography.dart';
|
import '../../../core/design/typography.dart';
|
||||||
import '../../../core/di.dart';
|
import '../../../core/di.dart';
|
||||||
import '../../../core/l10n/l10n.dart';
|
import '../../../core/l10n/l10n.dart';
|
||||||
|
import '../../../core/store_links.dart';
|
||||||
import '../../../core/ui/tripz_button.dart';
|
import '../../../core/ui/tripz_button.dart';
|
||||||
import '../../../core/ui/tripz_card.dart';
|
import '../../../core/ui/tripz_card.dart';
|
||||||
import '../../../core/ui/tripz_scaffold.dart';
|
import '../../../core/ui/tripz_scaffold.dart';
|
||||||
@@ -155,8 +156,11 @@ class _InviteCard extends StatelessWidget {
|
|||||||
TripzButton.primary(
|
TripzButton.primary(
|
||||||
label: l10n.menuShare,
|
label: l10n.menuShare,
|
||||||
icon: Icons.share_outlined,
|
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),
|
const SizedBox(height: Space.md),
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -1,15 +1,30 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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/tokens.dart';
|
||||||
import '../../../core/design/tripz_colors.dart';
|
import '../../../core/design/tripz_colors.dart';
|
||||||
|
import '../../../core/design/typography.dart';
|
||||||
|
import '../../../core/di.dart';
|
||||||
import '../../../core/l10n/l10n.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 '../../../core/ui/tripz_scaffold.dart';
|
||||||
|
import '../../profile/data/profile_repository.dart';
|
||||||
import '../cubit/settings_cubit.dart';
|
import '../cubit/settings_cubit.dart';
|
||||||
import '../cubit/settings_state.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 {
|
class SettingsPage extends StatelessWidget {
|
||||||
const SettingsPage({super.key});
|
const SettingsPage({super.key});
|
||||||
|
|
||||||
@@ -23,8 +38,8 @@ class SettingsPage extends StatelessWidget {
|
|||||||
title: l10n.settingsTitle,
|
title: l10n.settingsTitle,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
children: [
|
children: [
|
||||||
Text(l10n.settingsTheme, style: context.texts.titleMedium),
|
_SectionHeader(title: l10n.settingsSectionGeneral),
|
||||||
const SizedBox(height: Space.xs),
|
Text(l10n.settingsTheme, style: context.texts.titleSmall),
|
||||||
RadioGroup<ThemeMode>(
|
RadioGroup<ThemeMode>(
|
||||||
groupValue: state.themeMode,
|
groupValue: state.themeMode,
|
||||||
onChanged: (v) => cubit.setThemeMode(v ?? ThemeMode.system),
|
onChanged: (v) => cubit.setThemeMode(v ?? ThemeMode.system),
|
||||||
@@ -48,12 +63,15 @@ class SettingsPage extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: Space.xl),
|
const SizedBox(height: Space.md),
|
||||||
Text(l10n.settingsLanguage, style: context.texts.titleMedium),
|
Text(l10n.settingsLanguage, style: context.texts.titleSmall),
|
||||||
const SizedBox(height: Space.xs),
|
|
||||||
RadioGroup<String>(
|
RadioGroup<String>(
|
||||||
groupValue: state.locale.languageCode,
|
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(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
RadioListTile(
|
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<void> _setLanguage(BuildContext context, String code) async {
|
||||||
|
await context.read<SettingsCubit>().setLocale(Locale(code));
|
||||||
|
try {
|
||||||
|
final user = await sl<ProfileRepository>().update(language: code);
|
||||||
|
sl<SessionCubit>().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<PackageInfo>(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../../../core/api/api_exception.dart';
|
|||||||
import '../../../core/location/location_service.dart';
|
import '../../../core/location/location_service.dart';
|
||||||
import '../../../core/realtime/realtime_service.dart';
|
import '../../../core/realtime/realtime_service.dart';
|
||||||
import '../data/models/geo_point.dart';
|
import '../data/models/geo_point.dart';
|
||||||
|
import '../data/maps_repository.dart';
|
||||||
import '../data/models/trip.dart';
|
import '../data/models/trip.dart';
|
||||||
import '../data/trip_repository.dart';
|
import '../data/trip_repository.dart';
|
||||||
import 'duty_state.dart';
|
import 'duty_state.dart';
|
||||||
@@ -16,14 +17,17 @@ class DutyCubit extends Cubit<DutyState> {
|
|||||||
required TripRepository trips,
|
required TripRepository trips,
|
||||||
required LocationService location,
|
required LocationService location,
|
||||||
required RealtimeService realtime,
|
required RealtimeService realtime,
|
||||||
|
required MapsRepository maps,
|
||||||
}) : _trips = trips,
|
}) : _trips = trips,
|
||||||
_location = location,
|
_location = location,
|
||||||
_realtime = realtime,
|
_realtime = realtime,
|
||||||
|
_maps = maps,
|
||||||
super(const DutyState());
|
super(const DutyState());
|
||||||
|
|
||||||
final TripRepository _trips;
|
final TripRepository _trips;
|
||||||
final LocationService _location;
|
final LocationService _location;
|
||||||
final RealtimeService _realtime;
|
final RealtimeService _realtime;
|
||||||
|
final MapsRepository _maps;
|
||||||
|
|
||||||
StreamSubscription<RealtimeEvent>? _events;
|
StreamSubscription<RealtimeEvent>? _events;
|
||||||
Timer? _offerTimer;
|
Timer? _offerTimer;
|
||||||
@@ -100,6 +104,7 @@ class DutyCubit extends Cubit<DutyState> {
|
|||||||
final heading = position.heading as double?;
|
final heading = position.heading as double?;
|
||||||
|
|
||||||
emit(state.copyWith(myLocation: GeoPoint(lat, lng)));
|
emit(state.copyWith(myLocation: GeoPoint(lat, lng)));
|
||||||
|
_refreshNavRoute(GeoPoint(lat, lng));
|
||||||
|
|
||||||
// مساران عمداً: REST يغذّي المطابقة في Redis، والسوكت يغذّي خريطة
|
// مساران عمداً: REST يغذّي المطابقة في Redis، والسوكت يغذّي خريطة
|
||||||
// الراكب الحيّة. سقوط أحدهما لا يُعمي الآخر.
|
// الراكب الحيّة. سقوط أحدهما لا يُعمي الآخر.
|
||||||
@@ -202,6 +207,53 @@ class DutyCubit extends Cubit<DutyState> {
|
|||||||
busy: false,
|
busy: false,
|
||||||
phase: DutyPhase.active,
|
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<void> _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) {
|
void _onRealtime(RealtimeEvent event) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
import '../data/models/geo_point.dart';
|
import '../data/models/geo_point.dart';
|
||||||
|
import '../data/models/route_info.dart';
|
||||||
import '../data/models/trip.dart';
|
import '../data/models/trip.dart';
|
||||||
|
|
||||||
/// حالة السائق. الفرق الجوهري عن الراكب: السائق **يستقبل** عملاً بدل أن
|
/// حالة السائق. الفرق الجوهري عن الراكب: السائق **يستقبل** عملاً بدل أن
|
||||||
@@ -31,6 +32,7 @@ class DutyState extends Equatable {
|
|||||||
this.error = DutyError.none,
|
this.error = DutyError.none,
|
||||||
this.creditBalance,
|
this.creditBalance,
|
||||||
this.creditBlocked = false,
|
this.creditBlocked = false,
|
||||||
|
this.navRoute,
|
||||||
});
|
});
|
||||||
|
|
||||||
final DutyPhase phase;
|
final DutyPhase phase;
|
||||||
@@ -49,6 +51,22 @@ class DutyState extends Equatable {
|
|||||||
/// محجوب لنفاد الرصيد — لا يصله عمل، ولا بد أن يعرف السبب صراحةً.
|
/// محجوب لنفاد الرصيد — لا يصله عمل، ولا بد أن يعرف السبب صراحةً.
|
||||||
final bool creditBlocked;
|
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;
|
bool get isOnline => phase != DutyPhase.offline;
|
||||||
|
|
||||||
DutyState copyWith({
|
DutyState copyWith({
|
||||||
@@ -60,8 +78,10 @@ class DutyState extends Equatable {
|
|||||||
DutyError? error,
|
DutyError? error,
|
||||||
String? creditBalance,
|
String? creditBalance,
|
||||||
bool? creditBlocked,
|
bool? creditBlocked,
|
||||||
|
RouteInfo? navRoute,
|
||||||
bool clearOffer = false,
|
bool clearOffer = false,
|
||||||
bool clearTrip = false,
|
bool clearTrip = false,
|
||||||
|
bool clearNavRoute = false,
|
||||||
}) {
|
}) {
|
||||||
return DutyState(
|
return DutyState(
|
||||||
phase: phase ?? this.phase,
|
phase: phase ?? this.phase,
|
||||||
@@ -72,6 +92,9 @@ class DutyState extends Equatable {
|
|||||||
error: error ?? this.error,
|
error: error ?? this.error,
|
||||||
creditBalance: creditBalance ?? this.creditBalance,
|
creditBalance: creditBalance ?? this.creditBalance,
|
||||||
creditBlocked: creditBlocked ?? this.creditBlocked,
|
creditBlocked: creditBlocked ?? this.creditBlocked,
|
||||||
|
navRoute: clearNavRoute || clearTrip
|
||||||
|
? null
|
||||||
|
: (navRoute ?? this.navRoute),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,5 +108,6 @@ class DutyState extends Equatable {
|
|||||||
error,
|
error,
|
||||||
creditBalance,
|
creditBalance,
|
||||||
creditBlocked,
|
creditBlocked,
|
||||||
|
navRoute,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,14 @@ class Trip extends Equatable {
|
|||||||
this.driverPhone,
|
this.driverPhone,
|
||||||
this.vehiclePlate,
|
this.vehiclePlate,
|
||||||
this.vehicleModel,
|
this.vehicleModel,
|
||||||
|
this.vehicleColor,
|
||||||
|
this.vehicleColorHex,
|
||||||
this.driverRating,
|
this.driverRating,
|
||||||
|
this.requestedAt,
|
||||||
|
this.distanceKm,
|
||||||
|
this.durationMin,
|
||||||
|
this.paymentMethod,
|
||||||
|
this.serviceClass,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
@@ -90,8 +97,34 @@ class Trip extends Equatable {
|
|||||||
final String? driverPhone;
|
final String? driverPhone;
|
||||||
final String? vehiclePlate;
|
final String? vehiclePlate;
|
||||||
final String? vehicleModel;
|
final String? vehicleModel;
|
||||||
|
|
||||||
|
/// لون المركبة كاسم (`White`, `أبيض`) — **ما يبحث عنه الراكب في الشارع
|
||||||
|
/// قبل اللوحة**. الخادم يرسله في `vehicle.color`
|
||||||
|
/// (`trips.service.ts:427`).
|
||||||
|
final String? vehicleColor;
|
||||||
|
|
||||||
|
/// اللون كـhex إن وُجد (`vehicles` عندها `color_hex`). يُقدَّم على الاسم
|
||||||
|
/// عند الرسم لأنه دقيق، والاسم يبقى للعرض النصّي.
|
||||||
|
final String? vehicleColorHex;
|
||||||
|
|
||||||
final String? driverRating;
|
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<String, dynamic> json) {
|
factory Trip.fromJson(Map<String, dynamic> json) {
|
||||||
GeoPoint? point(String key) {
|
GeoPoint? point(String key) {
|
||||||
final v = json[key];
|
final v = json[key];
|
||||||
@@ -99,6 +132,11 @@ class Trip extends Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final driver = json['driver'] as Map<String, dynamic>?;
|
final driver = json['driver'] as Map<String, dynamic>?;
|
||||||
|
// المركبة تصل بشكلين: مسطّحة (`vehicle_plate`) في ردّ الرحلة، ومتداخلة
|
||||||
|
// (`vehicle: {plate, color}`) في حدث `assigned` (`trips.service.ts:423`).
|
||||||
|
// القراءة تقبل الاثنين بدل أن تُفرغ البطاقة عند أحدهما.
|
||||||
|
final vehicle = (json['vehicle'] ?? driver?['vehicle'])
|
||||||
|
as Map<String, dynamic>?;
|
||||||
return Trip(
|
return Trip(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
status: TripStatus.parse(json['status'] 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?,
|
driverName: driver?['name'] as String? ?? json['driver_name'] as String?,
|
||||||
driverPhone:
|
driverPhone:
|
||||||
driver?['phone'] as String? ?? json['driver_phone'] as String?,
|
driver?['phone'] as String? ?? json['driver_phone'] as String?,
|
||||||
vehiclePlate:
|
vehiclePlate: vehicle?['plate'] as String? ??
|
||||||
driver?['vehicle_plate'] as String? ?? json['vehicle_plate'] as String?,
|
driver?['vehicle_plate'] as String? ??
|
||||||
vehicleModel:
|
json['vehicle_plate'] as String?,
|
||||||
driver?['vehicle_model'] as String? ?? json['vehicle_model'] 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(),
|
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?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,38 @@ import '../../../core/ui/app_drawer.dart';
|
|||||||
import '../cubit/duty_cubit.dart';
|
import '../cubit/duty_cubit.dart';
|
||||||
import '../cubit/duty_state.dart';
|
import '../cubit/duty_state.dart';
|
||||||
import '../data/models/geo_point.dart';
|
import '../data/models/geo_point.dart';
|
||||||
|
import '../data/models/route_info.dart';
|
||||||
import 'widgets/active_duty_sheet.dart';
|
import 'widgets/active_duty_sheet.dart';
|
||||||
import 'widgets/duty_toggle.dart';
|
import 'widgets/duty_toggle.dart';
|
||||||
import 'widgets/offer_sheet.dart';
|
import 'widgets/offer_sheet.dart';
|
||||||
import 'widgets/ride_map.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 {
|
class DutyPage extends StatefulWidget {
|
||||||
const DutyPage({super.key});
|
const DutyPage({super.key});
|
||||||
@@ -42,9 +69,34 @@ class _DutyPageState extends State<DutyPage> {
|
|||||||
children: [
|
children: [
|
||||||
// الخريطة معزولة عن بقية الحالة: نبضة موقع كل 25 متراً يجب ألّا
|
// الخريطة معزولة عن بقية الحالة: نبضة موقع كل 25 متراً يجب ألّا
|
||||||
// تعيد بناء الورقة والمفتاح معها.
|
// تعيد بناء الورقة والمفتاح معها.
|
||||||
BlocSelector<DutyCubit, DutyState, GeoPoint?>(
|
//
|
||||||
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<DutyCubit, DutyState, _MapData>(
|
||||||
|
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(
|
SafeArea(
|
||||||
@@ -83,7 +135,7 @@ class _DutyPageState extends State<DutyPage> {
|
|||||||
selector: (s) => s.creditBlocked,
|
selector: (s) => s.creditBlocked,
|
||||||
builder: (context, blocked) => blocked
|
builder: (context, blocked) => blocked
|
||||||
? StatusBanner(
|
? StatusBanner(
|
||||||
message: context.l10n.errorGeneric,
|
message: context.l10n.creditBlocked,
|
||||||
tone: BannerTone.danger,
|
tone: BannerTone.danger,
|
||||||
icon: Icons.account_balance_wallet_outlined,
|
icon: Icons.account_balance_wallet_outlined,
|
||||||
)
|
)
|
||||||
@@ -132,13 +184,21 @@ class _Sheet extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
DutyPhase.offered => OfferSheet(offer: state.offer!),
|
DutyPhase.offered => OfferSheet(offer: state.offer!),
|
||||||
DutyPhase.active =>
|
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();
|
if (state.phase == DutyPhase.offline) return const SizedBox.shrink();
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
|
// الحدّ الأدنى = المساحة المحجوزة تحت الخريطة، وإلا ظهر فراغ بينهما.
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
minHeight: MediaQuery.sizeOf(context).height * _sheetFraction,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: context.colors.surface,
|
color: context.colors.surface,
|
||||||
borderRadius: Radii.sheetTop,
|
borderRadius: Radii.sheetTop,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
import '../../../core/design/tokens.dart';
|
import '../../../core/design/tokens.dart';
|
||||||
import '../../../core/design/tripz_colors.dart';
|
import '../../../core/design/tripz_colors.dart';
|
||||||
@@ -55,6 +56,16 @@ class _HistoryPageState extends State<HistoryPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// بطاقة رحلة في السجلّ.
|
||||||
|
///
|
||||||
|
/// كانت ثلاثة عناصر فقط (نقطة · حالة · سعر) — بلا تاريخ ولا مسافة ولا طريقة
|
||||||
|
/// دفع، رغم أن الخادم يرسلها كلها في `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 {
|
class _TripCard extends StatelessWidget {
|
||||||
const _TripCard({required this.trip});
|
const _TripCard({required this.trip});
|
||||||
|
|
||||||
@@ -77,25 +88,106 @@ class _TripCard extends StatelessWidget {
|
|||||||
_ => (l10n.historyStatusLive, colors.info),
|
_ => (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(
|
return TripzCard(
|
||||||
child: Row(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Row(
|
||||||
width: 8,
|
children: [
|
||||||
height: 8,
|
Container(
|
||||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
width: 8,
|
||||||
),
|
height: 8,
|
||||||
const SizedBox(width: Space.sm),
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||||
Expanded(
|
),
|
||||||
child: Text(label, style: context.texts.bodyMedium),
|
const SizedBox(width: Space.sm),
|
||||||
),
|
Expanded(
|
||||||
Text(
|
child: Text(
|
||||||
Money.format(trip.priceForPassenger ?? trip.quotedFare,
|
label,
|
||||||
trip.currency),
|
style: context.texts.bodyMedium?.copyWith(color: color),
|
||||||
style: numericStyle(context.texts.titleSmall),
|
),
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,16 +11,26 @@ import '../../../../core/tenant/feature_gate.dart';
|
|||||||
import '../../../../core/ui/tripz_button.dart';
|
import '../../../../core/ui/tripz_button.dart';
|
||||||
import '../../cubit/duty_cubit.dart';
|
import '../../cubit/duty_cubit.dart';
|
||||||
import '../../data/models/trip.dart';
|
import '../../data/models/trip.dart';
|
||||||
|
import '../../data/models/route_info.dart';
|
||||||
import 'fare_summary.dart';
|
import 'fare_summary.dart';
|
||||||
|
import 'nav_eta_strip.dart';
|
||||||
|
|
||||||
/// الرحلة من طرف السائق: **زر واحد** يدفع آلة الحالات خطوة واحدة.
|
/// الرحلة من طرف السائق: **زر واحد** يدفع آلة الحالات خطوة واحدة.
|
||||||
/// السائق لا يختار الحالة التالية — الكيوبت يشتقّها (docs/38 §4).
|
/// السائق لا يختار الحالة التالية — الكيوبت يشتقّها (docs/38 §4).
|
||||||
class ActiveDutySheet extends StatelessWidget {
|
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 Trip trip;
|
||||||
final bool busy;
|
final bool busy;
|
||||||
|
|
||||||
|
/// مسار القيادة إلى الهدف الحالي — منه شريط الوقت والمسافة.
|
||||||
|
final RouteInfo? navRoute;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
@@ -31,6 +41,13 @@ class ActiveDutySheet extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text(_statusText(context), style: context.texts.titleMedium),
|
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) ...[
|
if (trip.priceForDriver != null) ...[
|
||||||
const SizedBox(height: Space.sm),
|
const SizedBox(height: Space.sm),
|
||||||
// السائق يرى **أرباحه** لا أجرة الراكب: حقلان منفصلان والفرق عمولة.
|
// السائق يرى **أرباحه** لا أجرة الراكب: حقلان منفصلان والفرق عمولة.
|
||||||
@@ -42,7 +59,7 @@ class ActiveDutySheet extends StatelessWidget {
|
|||||||
label: l10n.chatTitle,
|
label: l10n.chatTitle,
|
||||||
icon: Icons.forum_outlined,
|
icon: Icons.forum_outlined,
|
||||||
expanded: true,
|
expanded: true,
|
||||||
onPressed: () => context.push('${Routes.chat}?trip=\${trip.id}'),
|
onPressed: () => context.push('${Routes.chat}?trip=${trip.id}'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: Space.md),
|
const SizedBox(height: Space.md),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import '../../../../core/design/tokens.dart';
|
|||||||
import '../../../../core/design/tripz_colors.dart';
|
import '../../../../core/design/tripz_colors.dart';
|
||||||
import '../../../../core/design/typography.dart';
|
import '../../../../core/design/typography.dart';
|
||||||
import '../../data/models/trip.dart';
|
import '../../data/models/trip.dart';
|
||||||
|
import 'vehicle_color.dart';
|
||||||
|
|
||||||
/// بطاقة السائق ولوحة المركبة — أهم ما يبحث عنه الراكب في الشارع، فيأخذ
|
/// بطاقة السائق ولوحة المركبة — أهم ما يبحث عنه الراكب في الشارع، فيأخذ
|
||||||
/// أوضح موضع وأكبر تباين.
|
/// أوضح موضع وأكبر تباين.
|
||||||
@@ -15,25 +16,35 @@ class DriverBadge extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (trip.driverName == null) return const SizedBox.shrink();
|
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(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
_CarAvatar(color: carColor),
|
||||||
radius: 24,
|
|
||||||
backgroundColor: context.colors.primaryContainer,
|
|
||||||
child: Icon(
|
|
||||||
Icons.person_rounded,
|
|
||||||
color: context.colors.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: Space.sm),
|
const SizedBox(width: Space.sm),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(trip.driverName!, style: context.texts.titleSmall),
|
Text(trip.driverName!, style: context.texts.titleSmall),
|
||||||
if (trip.vehicleModel != null)
|
if (vehicleLine.isNotEmpty)
|
||||||
Text(
|
Text(
|
||||||
trip.vehicleModel!,
|
vehicleLine,
|
||||||
style: context.texts.bodySmall?.copyWith(
|
style: context.texts.bodySmall?.copyWith(
|
||||||
color: context.colors.onSurfaceVariant,
|
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 {
|
class _Plate extends StatelessWidget {
|
||||||
const _Plate({required this.plate});
|
const _Plate({required this.plate});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:developer' as dev;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intaleq_maps/intaleq_maps.dart' as im;
|
import 'package:intaleq_maps/intaleq_maps.dart' as im;
|
||||||
|
|
||||||
@@ -83,6 +85,29 @@ class _RideMapState extends State<RideMap> {
|
|||||||
|
|
||||||
static const _fallback = im.LatLng(31.9539, 35.9106); // عمّان
|
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/` — منقولان من سيرو الميداني.
|
/// الستايلان مرفقان في `assets/` — منقولان من سيرو الميداني.
|
||||||
static const _lightStyleAsset = 'assets/style.json';
|
static const _lightStyleAsset = 'assets/style.json';
|
||||||
static const _darkStyleAsset = 'assets/style_dark.json';
|
static const _darkStyleAsset = 'assets/style_dark.json';
|
||||||
@@ -123,9 +148,43 @@ class _RideMapState extends State<RideMap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _moveTo(GeoPoint p) {
|
void _moveTo(GeoPoint p) {
|
||||||
_controller?.animateCamera(
|
final target = _safe(p, 'moveTo');
|
||||||
im.CameraUpdate.newLatLngZoom(im.LatLng(p.lat, p.lng), 15.5),
|
if (target == null) return;
|
||||||
);
|
_controller?.animateCamera(im.CameraUpdate.newLatLngZoom(target, 15.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// يفكّ ترميز المسار **مصفّىً**: مسار مشوّه من الخادم (أو سلسلة مقطوعة)
|
||||||
|
/// يعطي خطوط عرض خارج ±90، وتلك تصل `mbgl::LatLng` فتُسقط التطبيق أصلاً.
|
||||||
|
static List<im.LatLng> _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<im.Polyline> _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<RideMap> {
|
|||||||
void _fitRoute() {
|
void _fitRoute() {
|
||||||
final encoded = widget.route?.encodedPoints;
|
final encoded = widget.route?.encodedPoints;
|
||||||
if (encoded == null || encoded.isEmpty) return;
|
if (encoded == null || encoded.isEmpty) return;
|
||||||
final points = im.PolylineUtils.decode(encoded);
|
final points = _decodeRoute(encoded);
|
||||||
if (points.length < 2) return;
|
if (points.length < 2) return;
|
||||||
|
|
||||||
var minLat = points.first.latitude, maxLat = points.first.latitude;
|
var minLat = points.first.latitude, maxLat = points.first.latitude;
|
||||||
@@ -147,6 +206,10 @@ class _RideMapState extends State<RideMap> {
|
|||||||
if (p.longitude > maxLng) maxLng = p.longitude;
|
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(
|
_controller?.animateCamera(
|
||||||
im.CameraUpdate.newLatLngBounds(
|
im.CameraUpdate.newLatLngBounds(
|
||||||
im.LatLngBounds(
|
im.LatLngBounds(
|
||||||
@@ -156,7 +219,7 @@ class _RideMapState extends State<RideMap> {
|
|||||||
left: 48,
|
left: 48,
|
||||||
right: 48,
|
right: 48,
|
||||||
top: 120,
|
top: 120,
|
||||||
bottom: widget.bottomInset,
|
bottom: bottom,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -167,19 +230,19 @@ class _RideMapState extends State<RideMap> {
|
|||||||
if (controller == null || _syncing) return;
|
if (controller == null || _syncing) return;
|
||||||
_syncing = true;
|
_syncing = true;
|
||||||
|
|
||||||
Future<Offset?> px(GeoPoint? p) async {
|
// `getScreenCoordinate` يعبر إلى `mbgl::LatLng` هو الآخر — نفس الحارس.
|
||||||
if (p == null) return null;
|
Future<Offset?> px(GeoPoint? p, String tag) async {
|
||||||
final point = await controller.getScreenCoordinate(
|
final target = _safe(p, tag);
|
||||||
im.LatLng(p.lat, p.lng),
|
if (target == null) return null;
|
||||||
);
|
final point = await controller.getScreenCoordinate(target);
|
||||||
return Offset(point.x.toDouble(), point.y.toDouble());
|
return Offset(point.x.toDouble(), point.y.toDouble());
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
px(widget.origin),
|
px(widget.origin, 'origin'),
|
||||||
px(widget.destination),
|
px(widget.destination, 'destination'),
|
||||||
px(widget.driver),
|
px(widget.driver, 'driver'),
|
||||||
]);
|
]);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -205,22 +268,19 @@ class _RideMapState extends State<RideMap> {
|
|||||||
// الخريطة تبدّل ستايلها مع الثيم — خريطة نهارية في وضع ليلي تكسر
|
// الخريطة تبدّل ستايلها مع الثيم — خريطة نهارية في وضع ليلي تكسر
|
||||||
// الغرض (docs/26 §2). وتبديله يهدم مديري التعليقات، فيُصفَّر العَلَم.
|
// الغرض (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`
|
// `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).
|
// إقلاع» (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;
|
final styleUrl = isDark ? _darkStyleAsset : _lightStyleAsset;
|
||||||
if (_styleUrl != null && _styleUrl != styleUrl) _styleReady = false;
|
if (_styleUrl != null && _styleUrl != styleUrl) _styleReady = false;
|
||||||
_styleUrl = styleUrl;
|
_styleUrl = styleUrl;
|
||||||
@@ -234,13 +294,17 @@ class _RideMapState extends State<RideMap> {
|
|||||||
// فنقفله من هنا بدل الاتّكال على ذلك.
|
// فنقفله من هنا بدل الاتّكال على ذلك.
|
||||||
autoCache: false,
|
autoCache: false,
|
||||||
initialCameraPosition: im.CameraPosition(
|
initialCameraPosition: im.CameraPosition(
|
||||||
target: widget.origin == null
|
target: _safe(widget.origin, 'initialCamera') ?? _fallback,
|
||||||
? _fallback
|
|
||||||
: im.LatLng(widget.origin!.lat, widget.origin!.lng),
|
|
||||||
zoom: 15,
|
zoom: 15,
|
||||||
),
|
),
|
||||||
styleUrl: styleUrl,
|
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,
|
compassEnabled: false,
|
||||||
zoomControlsEnabled: false,
|
zoomControlsEnabled: false,
|
||||||
onMapCreated: _onCreated,
|
onMapCreated: _onCreated,
|
||||||
@@ -257,20 +321,9 @@ class _RideMapState extends State<RideMap> {
|
|||||||
}
|
}
|
||||||
_syncPixels();
|
_syncPixels();
|
||||||
},
|
},
|
||||||
// **المسار وحده annotation** — لا بديل عنه، ومحروسٌ بعَلَم الستايل.
|
// **المسار وحده annotation** — لا بديل عنه، ومحروسٌ بعَلَم الستايل
|
||||||
polylines: _styleReady && widget.route != null &&
|
// وبتصفية الإحداثيات (نقاط المسار تعبر إلى `mbgl::LatLng` أيضاً).
|
||||||
widget.route!.encodedPoints.isNotEmpty
|
polylines: _routePolylines(colors.mapRoute),
|
||||||
? {
|
|
||||||
im.Polyline(
|
|
||||||
polylineId: const im.PolylineId('route'),
|
|
||||||
points: im.PolylineUtils.decode(
|
|
||||||
widget.route!.encodedPoints,
|
|
||||||
),
|
|
||||||
color: colors.mapRoute,
|
|
||||||
width: 5,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: const {},
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── الطبقة الفلاترية: نقاط وأسماء وسائق ──────────────────────────
|
// ── الطبقة الفلاترية: نقاط وأسماء وسائق ──────────────────────────
|
||||||
|
|||||||
@@ -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 = <String, Color>{
|
||||||
|
'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()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -393,10 +393,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: geolocator_linux
|
name: geolocator_linux
|
||||||
sha256: d64112a205931926f4363bb6bd48f14cb38e7326833041d170615586cd143797
|
sha256: c4e966f0a7a87e70049eac7a2617f9e16fd4c585a26e4330bdfc3a71e6a721f3
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.4"
|
version: "0.2.3"
|
||||||
geolocator_platform_interface:
|
geolocator_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -702,13 +702,13 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
version: "3.0.0"
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: package_info_plus
|
name: package_info_plus
|
||||||
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
|
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.0.1"
|
version: "8.3.1"
|
||||||
package_info_plus_platform_interface:
|
package_info_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ dependencies:
|
|||||||
permission_handler: ^12.0.1
|
permission_handler: ^12.0.1
|
||||||
device_info_plus: 12.3.0
|
device_info_plus: 12.3.0
|
||||||
|
|
||||||
|
# رقم الإصدار في شاشة الإعدادات — كانت موجودة كاعتماد غير مباشر أصلاً،
|
||||||
|
# فترقيتها لمباشر لا تجرّ كوداً أصلياً جديداً.
|
||||||
|
package_info_plus: ^8.1.1
|
||||||
|
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
|
|
||||||
# باقي الحزم تُضاف في مرحلتها لا قبلها (docs/37 §1.7):
|
# باقي الحزم تُضاف في مرحلتها لا قبلها (docs/37 §1.7):
|
||||||
|
|||||||
@@ -23,11 +23,32 @@ class BuildConfig {
|
|||||||
static const String logoAsset = 'assets/images/logo.png';
|
static const String logoAsset = 'assets/images/logo.png';
|
||||||
static const String splashLogoAsset = 'assets/images/splash_logo.png';
|
static const String splashLogoAsset = 'assets/images/splash_logo.png';
|
||||||
|
|
||||||
/// روابط خارجية لكل مستأجر — تُولَّد مع بقية الملف (N3).
|
/// روابط المتاجر الأربعة — **صفحات المتجر الحقيقية المنشورة**، أعطاها
|
||||||
static const String storeUrl = String.fromEnvironment(
|
/// المالك 2026-08-05. لا تُستعمل مباشرةً: `StoreLinks` هو من يختار الرابط
|
||||||
'STORE_URL',
|
/// حسب المنصّة والدور، لأن «قيّم التطبيق» على آيفون سائق ≠ نفس الرابط على
|
||||||
defaultValue: 'https://tripz.app',
|
/// أندرويد راكب.
|
||||||
|
///
|
||||||
|
/// المعرّفات تطابق هوية النشر المقفلة: `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(
|
static const String privacyUrl = String.fromEnvironment(
|
||||||
'PRIVACY_URL',
|
'PRIVACY_URL',
|
||||||
defaultValue: 'https://tripz.app/privacy',
|
defaultValue: 'https://tripz.app/privacy',
|
||||||
@@ -38,12 +59,6 @@ class BuildConfig {
|
|||||||
'SUPPORT_PHONE',
|
'SUPPORT_PHONE',
|
||||||
defaultValue: '962790000000',
|
defaultValue: '962790000000',
|
||||||
);
|
);
|
||||||
|
|
||||||
/// رابط تطبيق الطرف الآخر — الراكب يدعو نفسه ليصير سائقاً والعكس.
|
|
||||||
static const String driverAppUrl = String.fromEnvironment(
|
|
||||||
'DRIVER_APP_URL',
|
|
||||||
defaultValue: 'https://tripz.app/driver',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// أعلام الاستحقاقات (docs/23 §5). **`const` إجبارياً** كي يُحذف كود الميزة
|
/// أعلام الاستحقاقات (docs/23 §5). **`const` إجبارياً** كي يُحذف كود الميزة
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import 'api/antlaq_api.dart';
|
|||||||
import 'api/api_client.dart';
|
import 'api/api_client.dart';
|
||||||
import 'build_config.dart';
|
import 'build_config.dart';
|
||||||
import 'location/location_service.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 'notifications/push_service.dart';
|
||||||
import 'realtime/realtime_service.dart';
|
import 'realtime/realtime_service.dart';
|
||||||
import 'session/session_cubit.dart';
|
import 'session/session_cubit.dart';
|
||||||
@@ -79,7 +81,12 @@ Future<void> setupInjector() async {
|
|||||||
sl.registerSingleton<RealtimeService>(RealtimeService(tokens));
|
sl.registerSingleton<RealtimeService>(RealtimeService(tokens));
|
||||||
|
|
||||||
sl.registerSingleton<TenantCubit>(TenantCubit(sl(), prefs));
|
sl.registerSingleton<TenantCubit>(TenantCubit(sl(), prefs));
|
||||||
sl.registerSingleton<PushService>(PushService(sl()));
|
|
||||||
|
// الصندوق يُحمَّل قبل `PushService` كي لا يضيع إشعارٌ يصل أثناء الإقلاع.
|
||||||
|
final notifications = NotificationStore();
|
||||||
|
await notifications.load();
|
||||||
|
sl.registerSingleton<NotificationStore>(notifications);
|
||||||
|
sl.registerSingleton<PushService>(PushService(sl(), notifications));
|
||||||
|
|
||||||
sl.registerSingleton<HistoryRepository>(HistoryRepository(sl()));
|
sl.registerSingleton<HistoryRepository>(HistoryRepository(sl()));
|
||||||
sl.registerFactory<HistoryCubit>(() => HistoryCubit(sl()));
|
sl.registerFactory<HistoryCubit>(() => HistoryCubit(sl()));
|
||||||
@@ -105,11 +112,18 @@ Future<void> setupInjector() async {
|
|||||||
sl.registerFactory<WalletCubit>(() => WalletCubit(sl()));
|
sl.registerFactory<WalletCubit>(() => WalletCubit(sl()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// الأماكن الأخيرة تُحمَّل مرة واحدة عند الإقلاع: الشاشة الرئيسية تقرأها
|
||||||
|
// في أول إطار، وقراءةُ القرص وقتها تعني صفّاً يقفز بعد الرسم.
|
||||||
|
final recents = RecentPlacesStore();
|
||||||
|
await recents.load();
|
||||||
|
sl.registerSingleton<RecentPlacesStore>(recents);
|
||||||
|
|
||||||
sl.registerFactory<RideCubit>(() => RideCubit(
|
sl.registerFactory<RideCubit>(() => RideCubit(
|
||||||
trips: sl(),
|
trips: sl(),
|
||||||
maps: sl(),
|
maps: sl(),
|
||||||
location: sl(),
|
location: sl(),
|
||||||
realtime: sl(),
|
realtime: sl(),
|
||||||
|
recents: sl(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -65,6 +65,10 @@
|
|||||||
"settingsLanguage": "اللغة",
|
"settingsLanguage": "اللغة",
|
||||||
"settingsLanguageArabic": "العربية",
|
"settingsLanguageArabic": "العربية",
|
||||||
"settingsLanguageEnglish": "English",
|
"settingsLanguageEnglish": "English",
|
||||||
|
"settingsSectionGeneral": "عام",
|
||||||
|
"settingsSectionSupport": "الدعم والمعلومات",
|
||||||
|
"settingsSupportLead": "أسئلة شائعة وطرق التواصل",
|
||||||
|
"settingsShareLead": "شارك التطبيق مع أصدقائك",
|
||||||
"rideWhereTo": "إلى أين؟",
|
"rideWhereTo": "إلى أين؟",
|
||||||
"rideOrigin": "من",
|
"rideOrigin": "من",
|
||||||
"rideDestination": "إلى",
|
"rideDestination": "إلى",
|
||||||
@@ -131,6 +135,8 @@
|
|||||||
"rateSubmit": "إرسال التقييم",
|
"rateSubmit": "إرسال التقييم",
|
||||||
"rateSkip": "لاحقاً",
|
"rateSkip": "لاحقاً",
|
||||||
"rateAlready": "سبق أن قيّمت هذه الرحلة",
|
"rateAlready": "سبق أن قيّمت هذه الرحلة",
|
||||||
|
"tripCall": "اتصال",
|
||||||
|
"tripMessage": "رسالة",
|
||||||
"tripFareTitle": "تفاصيل الأجرة",
|
"tripFareTitle": "تفاصيل الأجرة",
|
||||||
"tripFare": "الأجرة",
|
"tripFare": "الأجرة",
|
||||||
"tripEarnings": "أرباحك",
|
"tripEarnings": "أرباحك",
|
||||||
@@ -169,9 +175,14 @@
|
|||||||
"menuEarnings": "أرباحي",
|
"menuEarnings": "أرباحي",
|
||||||
"menuSettings": "الإعدادات",
|
"menuSettings": "الإعدادات",
|
||||||
"menuSupport": "المساعدة",
|
"menuSupport": "المساعدة",
|
||||||
|
"menuNotifications": "الإشعارات",
|
||||||
|
"notificationsEmpty": "لا إشعارات بعد — سنُعلمك هنا بكل ما يخصّ رحلاتك",
|
||||||
|
"notificationsClear": "مسح الكل",
|
||||||
"menuAbout": "عن التطبيق",
|
"menuAbout": "عن التطبيق",
|
||||||
"menuLogout": "تسجيل الخروج",
|
"menuLogout": "تسجيل الخروج",
|
||||||
"menuLogoutConfirm": "هل تريد الخروج من حسابك؟",
|
"menuLogoutConfirm": "هل تريد الخروج من حسابك؟",
|
||||||
|
"paymentCash": "نقداً",
|
||||||
|
"paymentWallet": "من المحفظة",
|
||||||
"historyTitle": "رحلاتي",
|
"historyTitle": "رحلاتي",
|
||||||
"historyEmpty": "لا رحلات بعد — اطلب أول رحلة لك",
|
"historyEmpty": "لا رحلات بعد — اطلب أول رحلة لك",
|
||||||
"historyStatusCompleted": "مكتملة",
|
"historyStatusCompleted": "مكتملة",
|
||||||
@@ -224,12 +235,24 @@
|
|||||||
"menuNotifications": "الإشعارات",
|
"menuNotifications": "الإشعارات",
|
||||||
"menuPrivacy": "سياسة الخصوصية",
|
"menuPrivacy": "سياسة الخصوصية",
|
||||||
"menuRateApp": "قيّم التطبيق",
|
"menuRateApp": "قيّم التطبيق",
|
||||||
"shareMessage": "جرّب تطبيق Tripz — استعمل كودي {code} واحصل على خصم أول رحلة",
|
"shareMessage": "جرّب تطبيق Tripz — استعمل كودي {code} واحصل على خصم أول رحلة\n{link}",
|
||||||
|
"shareMessagePlain": "جرّب تطبيق Tripz\n{link}",
|
||||||
|
"menuRiderApp": "حمّل تطبيق الراكب",
|
||||||
"menuDriverApp": "سجّل كسائق",
|
"menuDriverApp": "سجّل كسائق",
|
||||||
"@shareMessage": {
|
"@shareMessage": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"code": {
|
"code": {
|
||||||
"type": "String"
|
"type": "String"
|
||||||
|
},
|
||||||
|
"link": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@shareMessagePlain": {
|
||||||
|
"placeholders": {
|
||||||
|
"link": {
|
||||||
|
"type": "String"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -65,6 +65,10 @@
|
|||||||
"settingsLanguage": "Language",
|
"settingsLanguage": "Language",
|
||||||
"settingsLanguageArabic": "العربية",
|
"settingsLanguageArabic": "العربية",
|
||||||
"settingsLanguageEnglish": "English",
|
"settingsLanguageEnglish": "English",
|
||||||
|
"settingsSectionGeneral": "General",
|
||||||
|
"settingsSectionSupport": "Support & info",
|
||||||
|
"settingsSupportLead": "FAQ and ways to reach us",
|
||||||
|
"settingsShareLead": "Share the app with your friends",
|
||||||
"rideWhereTo": "Where to?",
|
"rideWhereTo": "Where to?",
|
||||||
"rideOrigin": "From",
|
"rideOrigin": "From",
|
||||||
"rideDestination": "To",
|
"rideDestination": "To",
|
||||||
@@ -131,6 +135,8 @@
|
|||||||
"rateSubmit": "Submit rating",
|
"rateSubmit": "Submit rating",
|
||||||
"rateSkip": "Later",
|
"rateSkip": "Later",
|
||||||
"rateAlready": "You already rated this trip",
|
"rateAlready": "You already rated this trip",
|
||||||
|
"tripCall": "Call",
|
||||||
|
"tripMessage": "Message",
|
||||||
"tripFareTitle": "Fare breakdown",
|
"tripFareTitle": "Fare breakdown",
|
||||||
"tripFare": "Fare",
|
"tripFare": "Fare",
|
||||||
"tripEarnings": "Your earnings",
|
"tripEarnings": "Your earnings",
|
||||||
@@ -169,9 +175,14 @@
|
|||||||
"menuEarnings": "Earnings",
|
"menuEarnings": "Earnings",
|
||||||
"menuSettings": "Settings",
|
"menuSettings": "Settings",
|
||||||
"menuSupport": "Help",
|
"menuSupport": "Help",
|
||||||
|
"menuNotifications": "Notifications",
|
||||||
|
"notificationsEmpty": "No notifications yet — we'll keep you posted about your trips here",
|
||||||
|
"notificationsClear": "Clear all",
|
||||||
"menuAbout": "About",
|
"menuAbout": "About",
|
||||||
"menuLogout": "Sign out",
|
"menuLogout": "Sign out",
|
||||||
"menuLogoutConfirm": "Sign out of your account?",
|
"menuLogoutConfirm": "Sign out of your account?",
|
||||||
|
"paymentCash": "Cash",
|
||||||
|
"paymentWallet": "Wallet",
|
||||||
"historyTitle": "My trips",
|
"historyTitle": "My trips",
|
||||||
"historyEmpty": "No trips yet — request your first ride",
|
"historyEmpty": "No trips yet — request your first ride",
|
||||||
"historyStatusCompleted": "Completed",
|
"historyStatusCompleted": "Completed",
|
||||||
@@ -224,12 +235,24 @@
|
|||||||
"menuNotifications": "Notifications",
|
"menuNotifications": "Notifications",
|
||||||
"menuPrivacy": "Privacy policy",
|
"menuPrivacy": "Privacy policy",
|
||||||
"menuRateApp": "Rate the app",
|
"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",
|
"menuDriverApp": "Become a driver",
|
||||||
"@shareMessage": {
|
"@shareMessage": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"code": {
|
"code": {
|
||||||
"type": "String"
|
"type": "String"
|
||||||
|
},
|
||||||
|
"link": {
|
||||||
|
"type": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"@shareMessagePlain": {
|
||||||
|
"placeholders": {
|
||||||
|
"link": {
|
||||||
|
"type": "String"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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<String, dynamic> toJson() => {
|
||||||
|
't': title,
|
||||||
|
'b': body,
|
||||||
|
'at': receivedAt.toIso8601String(),
|
||||||
|
'r': read,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory AppNotification.fromJson(Map<String, dynamic> 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<AppNotification> _items = const [];
|
||||||
|
List<AppNotification> get items => _items;
|
||||||
|
|
||||||
|
int get unreadCount => _items.where((n) => !n.read).length;
|
||||||
|
|
||||||
|
Future<void> 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<dynamic>;
|
||||||
|
_items = list
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(AppNotification.fromJson)
|
||||||
|
.toList(growable: false);
|
||||||
|
} catch (_) {
|
||||||
|
// تخزين تالف لا يمنع فتح الشاشة — يُهمل ويُكتب فوقه عند أول إشعار.
|
||||||
|
_items = const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> add(AppNotification notification) async {
|
||||||
|
_items = [notification, ..._items].take(_max).toList(growable: false);
|
||||||
|
await _persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markAllRead() async {
|
||||||
|
if (_items.every((n) => n.read)) return;
|
||||||
|
_items = _items.map((n) => n.copyWith(read: true)).toList(growable: false);
|
||||||
|
await _persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clear() async {
|
||||||
|
_items = const [];
|
||||||
|
await _persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persist() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(
|
||||||
|
_key,
|
||||||
|
jsonEncode(_items.map((n) => n.toJson()).toList(growable: false)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,20 +4,23 @@ import 'package:flutter/foundation.dart';
|
|||||||
|
|
||||||
import '../api/api_client.dart';
|
import '../api/api_client.dart';
|
||||||
import '../api/api_exception.dart';
|
import '../api/api_exception.dart';
|
||||||
|
import 'notification_store.dart';
|
||||||
|
|
||||||
/// إشعارات FCM — **الطبقة الوحيدة التي تعرف Firebase**.
|
/// إشعارات FCM — **الطبقة الوحيدة التي تعرف Firebase**.
|
||||||
///
|
///
|
||||||
/// عقد الخادم نقطة واحدة: `POST /notifications/token { token, platform }`
|
/// عقد الخادم نقطة واحدة: `POST /notifications/token { token, platform }`
|
||||||
/// (docs/38 §11). لا نقطة سرد إشعارات، فلا شاشة «صندوق إشعارات» تُبنى على
|
/// (docs/38 §11). لا نقطة **سرد** إشعارات، فالصندوق محلّي: ما يصل أثناء عمل
|
||||||
/// وهم — ما يصل يصل من النظام.
|
/// التطبيق يُحفظ في [NotificationStore] ويُعرض في `NotificationsPage` —
|
||||||
|
/// انظر تعليق ذلك الصنف لحدود هذا الاختيار.
|
||||||
///
|
///
|
||||||
/// الإعداد الأصلي جاهز مسبقاً: `google-services.json` و
|
/// الإعداد الأصلي جاهز مسبقاً: `google-services.json` و
|
||||||
/// `GoogleService-Info.plist` منقولان مع الطبقة الأصلية، و`build.gradle`
|
/// `GoogleService-Info.plist` منقولان مع الطبقة الأصلية، و`build.gradle`
|
||||||
/// يطبّق `com.google.gms.google-services` أصلاً.
|
/// يطبّق `com.google.gms.google-services` أصلاً.
|
||||||
class PushService {
|
class PushService {
|
||||||
PushService(this._api);
|
PushService(this._api, this._store);
|
||||||
|
|
||||||
final ApiClient _api;
|
final ApiClient _api;
|
||||||
|
final NotificationStore _store;
|
||||||
|
|
||||||
/// يُنادى **بعد** الدخول: التسجيل يربط الجهاز بالمستخدم، فقبل الجلسة
|
/// يُنادى **بعد** الدخول: التسجيل يربط الجهاز بالمستخدم، فقبل الجلسة
|
||||||
/// لا معنى له.
|
/// لا معنى له.
|
||||||
@@ -35,11 +38,30 @@ class PushService {
|
|||||||
// الرمز يُدوَّر من Google دورياً — بلا هذا الاشتراك تتوقّف الإشعارات
|
// الرمز يُدوَّر من Google دورياً — بلا هذا الاشتراك تتوقّف الإشعارات
|
||||||
// بصمت بعد أسابيع.
|
// بصمت بعد أسابيع.
|
||||||
messaging.onTokenRefresh.listen(_send);
|
messaging.onTokenRefresh.listen(_send);
|
||||||
|
|
||||||
|
// ما يصل والتطبيق مفتوح، وما فُتح التطبيق بالضغط عليه.
|
||||||
|
FirebaseMessaging.onMessage.listen(_capture);
|
||||||
|
FirebaseMessaging.onMessageOpenedApp.listen(_capture);
|
||||||
|
final initial = await messaging.getInitialMessage();
|
||||||
|
if (initial != null) await _capture(initial);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// غياب الإشعارات لا يمنع استعمال التطبيق — لا يُسقط الإقلاع.
|
// غياب الإشعارات لا يمنع استعمال التطبيق — لا يُسقط الإقلاع.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// إشعار بلا عنوان ولا نصّ (رسالة بيانات صرفة) لا يُعرض — سطر فارغ في
|
||||||
|
/// الصندوق أسوأ من لا شيء.
|
||||||
|
Future<void> _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<void> _send(String token) async {
|
Future<void> _send(String token) async {
|
||||||
try {
|
try {
|
||||||
await _api.post<Map<String, dynamic>>(
|
await _api.post<Map<String, dynamic>>(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '../features/chat/view/chat_page.dart';
|
|||||||
import '../features/profile/view/profile_edit_page.dart';
|
import '../features/profile/view/profile_edit_page.dart';
|
||||||
import '../features/profile/view/profile_setup_page.dart';
|
import '../features/profile/view/profile_setup_page.dart';
|
||||||
import '../features/rating/data/rating_repository.dart';
|
import '../features/rating/data/rating_repository.dart';
|
||||||
|
import '../features/notifications/view/notifications_page.dart';
|
||||||
import '../features/rewards/view/rewards_page.dart';
|
import '../features/rewards/view/rewards_page.dart';
|
||||||
import '../features/rating/view/rating_page.dart';
|
import '../features/rating/view/rating_page.dart';
|
||||||
import '../features/settings/view/settings_page.dart';
|
import '../features/settings/view/settings_page.dart';
|
||||||
@@ -34,6 +35,7 @@ class Routes {
|
|||||||
static const support = '/support';
|
static const support = '/support';
|
||||||
static const rewards = '/rewards';
|
static const rewards = '/rewards';
|
||||||
static const chat = '/chat';
|
static const chat = '/chat';
|
||||||
|
static const notifications = '/notifications';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// التوجيه يُشتقّ من `SessionCubit` وحده — لا `Navigator.push` بعد الدخول
|
/// التوجيه يُشتقّ من `SessionCubit` وحده — لا `Navigator.push` بعد الدخول
|
||||||
@@ -120,6 +122,10 @@ final appRouter = GoRouter(
|
|||||||
path: Routes.support,
|
path: Routes.support,
|
||||||
builder: (context, state) => const SupportPage(),
|
builder: (context, state) => const SupportPage(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: Routes.notifications,
|
||||||
|
builder: (context, state) => const NotificationsPage(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: Routes.home,
|
path: Routes.home,
|
||||||
builder: (context, state) => const RidePage(),
|
builder: (context, state) => const RidePage(),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -8,10 +8,13 @@ import '../build_config.dart';
|
|||||||
import '../config.dart';
|
import '../config.dart';
|
||||||
import '../design/tokens.dart';
|
import '../design/tokens.dart';
|
||||||
import '../design/tripz_colors.dart';
|
import '../design/tripz_colors.dart';
|
||||||
|
import '../di.dart';
|
||||||
import '../l10n/l10n.dart';
|
import '../l10n/l10n.dart';
|
||||||
|
import '../notifications/notification_store.dart';
|
||||||
import '../router.dart';
|
import '../router.dart';
|
||||||
import '../session/session_cubit.dart';
|
import '../session/session_cubit.dart';
|
||||||
import '../session/session_state.dart';
|
import '../session/session_state.dart';
|
||||||
|
import '../store_links.dart';
|
||||||
import '../tenant/feature_gate.dart';
|
import '../tenant/feature_gate.dart';
|
||||||
|
|
||||||
/// الدراور — نقطة تطبيق **طبقتَي الميزات** معاً (docs/23 §5 + docs/38 §10):
|
/// الدراور — نقطة تطبيق **طبقتَي الميزات** معاً (docs/23 §5 + docs/38 §10):
|
||||||
@@ -43,6 +46,14 @@ class AppDrawer extends StatelessWidget {
|
|||||||
label: l10n.menuProfile,
|
label: l10n.menuProfile,
|
||||||
route: Routes.profileEdit,
|
route: Routes.profileEdit,
|
||||||
),
|
),
|
||||||
|
_Item(
|
||||||
|
icon: Icons.notifications_none_rounded,
|
||||||
|
label: l10n.menuNotifications,
|
||||||
|
route: Routes.notifications,
|
||||||
|
// العدّاد يُقرأ عند بناء الدراور — والدراور يُبنى عند كل
|
||||||
|
// فتح، فلا حاجة لتيّار يراقب مخزناً في الذاكرة.
|
||||||
|
badge: sl<NotificationStore>().unreadCount,
|
||||||
|
),
|
||||||
_Item(
|
_Item(
|
||||||
icon: Icons.route_outlined,
|
icon: Icons.route_outlined,
|
||||||
label: l10n.menuTrips,
|
label: l10n.menuTrips,
|
||||||
@@ -72,28 +83,36 @@ class AppDrawer extends StatelessWidget {
|
|||||||
route: Routes.support,
|
route: Routes.support,
|
||||||
),
|
),
|
||||||
// مداخل محليّة بلا نقطة خادم — تُنفَّذ في الجهاز مباشرة.
|
// مداخل محليّة بلا نقطة خادم — تُنفَّذ في الجهاز مباشرة.
|
||||||
|
//
|
||||||
|
// الروابط من `StoreLinks` لا من ثابتٍ واحد: هذا البناء
|
||||||
|
// يشارك **رابط نفسه** (سائق يشارك رابط السائق)، ويفتح
|
||||||
|
// متجر منصّته هو.
|
||||||
_Action(
|
_Action(
|
||||||
icon: Icons.share_outlined,
|
icon: Icons.share_outlined,
|
||||||
label: l10n.menuShare,
|
label: l10n.menuShare,
|
||||||
onTap: () => SharePlus.instance
|
onTap: () => SharePlus.instance.share(
|
||||||
.share(ShareParams(text: l10n.shareMessage(''))),
|
ShareParams(
|
||||||
|
text: l10n.shareMessagePlain(StoreLinks.thisApp),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_Action(
|
_Action(
|
||||||
icon: Icons.star_outline_rounded,
|
icon: Icons.star_outline_rounded,
|
||||||
label: l10n.menuRateApp,
|
label: l10n.menuRateApp,
|
||||||
onTap: () => _open(BuildConfig.storeUrl),
|
onTap: () => _open(StoreLinks.thisApp),
|
||||||
),
|
),
|
||||||
_Action(
|
_Action(
|
||||||
icon: Icons.shield_outlined,
|
icon: Icons.shield_outlined,
|
||||||
label: l10n.menuPrivacy,
|
label: l10n.menuPrivacy,
|
||||||
onTap: () => _open(BuildConfig.privacyUrl),
|
onTap: () => _open(BuildConfig.privacyUrl),
|
||||||
),
|
),
|
||||||
if (!isDriver)
|
_Action(
|
||||||
_Action(
|
icon: isDriver
|
||||||
icon: Icons.local_taxi_outlined,
|
? Icons.person_outline_rounded
|
||||||
label: l10n.menuDriverApp,
|
: Icons.local_taxi_outlined,
|
||||||
onTap: () => _open(BuildConfig.driverAppUrl),
|
label: isDriver ? l10n.menuRiderApp : l10n.menuDriverApp,
|
||||||
),
|
onTap: () => _open(StoreLinks.otherApp),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -220,17 +239,43 @@ class _Action extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _Item 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 IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
final String route;
|
final String route;
|
||||||
|
|
||||||
|
/// عدّاد غير المقروء — يُخفى عند الصفر.
|
||||||
|
final int badge;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: Icon(icon),
|
leading: Icon(icon),
|
||||||
title: Text(label),
|
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: () {
|
onTap: () {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
context.push(route);
|
context.push(route);
|
||||||
|
|||||||
@@ -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<NotificationsPage> createState() => _NotificationsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NotificationsPageState extends State<NotificationsPage> {
|
||||||
|
final NotificationStore _store = sl<NotificationStore>();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// فتح الشاشة هو القراءة — لا زرّ «تعليم كمقروء» منفصل.
|
||||||
|
_store.markAllRead();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
|
|
||||||
import '../../../core/design/tokens.dart';
|
import '../../../core/design/tokens.dart';
|
||||||
import '../../../core/design/tripz_colors.dart';
|
import '../../../core/design/tripz_colors.dart';
|
||||||
|
import '../../../core/design/typography.dart';
|
||||||
import '../../../core/di.dart';
|
import '../../../core/di.dart';
|
||||||
import '../../../core/l10n/l10n.dart';
|
import '../../../core/l10n/l10n.dart';
|
||||||
import '../../../core/session/session_cubit.dart';
|
import '../../../core/session/session_cubit.dart';
|
||||||
@@ -54,6 +55,16 @@ class _ProfileEditPageState extends State<ProfileEditPage> {
|
|||||||
),
|
),
|
||||||
child: ListView(
|
child: ListView(
|
||||||
children: [
|
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(
|
TripzTextField(
|
||||||
controller: _name,
|
controller: _name,
|
||||||
label: l10n.profileNameLabel,
|
label: l10n.profileNameLabel,
|
||||||
@@ -91,3 +102,78 @@ class _ProfileEditPageState extends State<ProfileEditPage> {
|
|||||||
messenger.showSnackBar(SnackBar(content: Text(l10n.profileSaved)));
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '../../../core/design/tripz_colors.dart';
|
|||||||
import '../../../core/design/typography.dart';
|
import '../../../core/design/typography.dart';
|
||||||
import '../../../core/di.dart';
|
import '../../../core/di.dart';
|
||||||
import '../../../core/l10n/l10n.dart';
|
import '../../../core/l10n/l10n.dart';
|
||||||
|
import '../../../core/store_links.dart';
|
||||||
import '../../../core/ui/tripz_button.dart';
|
import '../../../core/ui/tripz_button.dart';
|
||||||
import '../../../core/ui/tripz_card.dart';
|
import '../../../core/ui/tripz_card.dart';
|
||||||
import '../../../core/ui/tripz_scaffold.dart';
|
import '../../../core/ui/tripz_scaffold.dart';
|
||||||
@@ -155,8 +156,11 @@ class _InviteCard extends StatelessWidget {
|
|||||||
TripzButton.primary(
|
TripzButton.primary(
|
||||||
label: l10n.menuShare,
|
label: l10n.menuShare,
|
||||||
icon: Icons.share_outlined,
|
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),
|
const SizedBox(height: Space.md),
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -1,15 +1,30 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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/tokens.dart';
|
||||||
import '../../../core/design/tripz_colors.dart';
|
import '../../../core/design/tripz_colors.dart';
|
||||||
|
import '../../../core/design/typography.dart';
|
||||||
|
import '../../../core/di.dart';
|
||||||
import '../../../core/l10n/l10n.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 '../../../core/ui/tripz_scaffold.dart';
|
||||||
|
import '../../profile/data/profile_repository.dart';
|
||||||
import '../cubit/settings_cubit.dart';
|
import '../cubit/settings_cubit.dart';
|
||||||
import '../cubit/settings_state.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 {
|
class SettingsPage extends StatelessWidget {
|
||||||
const SettingsPage({super.key});
|
const SettingsPage({super.key});
|
||||||
|
|
||||||
@@ -23,8 +38,8 @@ class SettingsPage extends StatelessWidget {
|
|||||||
title: l10n.settingsTitle,
|
title: l10n.settingsTitle,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
children: [
|
children: [
|
||||||
Text(l10n.settingsTheme, style: context.texts.titleMedium),
|
_SectionHeader(title: l10n.settingsSectionGeneral),
|
||||||
const SizedBox(height: Space.xs),
|
Text(l10n.settingsTheme, style: context.texts.titleSmall),
|
||||||
RadioGroup<ThemeMode>(
|
RadioGroup<ThemeMode>(
|
||||||
groupValue: state.themeMode,
|
groupValue: state.themeMode,
|
||||||
onChanged: (v) => cubit.setThemeMode(v ?? ThemeMode.system),
|
onChanged: (v) => cubit.setThemeMode(v ?? ThemeMode.system),
|
||||||
@@ -48,12 +63,15 @@ class SettingsPage extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(height: Space.xl),
|
const SizedBox(height: Space.md),
|
||||||
Text(l10n.settingsLanguage, style: context.texts.titleMedium),
|
Text(l10n.settingsLanguage, style: context.texts.titleSmall),
|
||||||
const SizedBox(height: Space.xs),
|
|
||||||
RadioGroup<String>(
|
RadioGroup<String>(
|
||||||
groupValue: state.locale.languageCode,
|
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(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
RadioListTile(
|
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<void> _setLanguage(BuildContext context, String code) async {
|
||||||
|
await context.read<SettingsCubit>().setLocale(Locale(code));
|
||||||
|
try {
|
||||||
|
final user = await sl<ProfileRepository>().update(language: code);
|
||||||
|
sl<SessionCubit>().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<PackageInfo>(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import '../data/models/geo_point.dart';
|
|||||||
import '../data/models/place.dart';
|
import '../data/models/place.dart';
|
||||||
import '../data/models/ride_type.dart';
|
import '../data/models/ride_type.dart';
|
||||||
import '../data/models/trip.dart';
|
import '../data/models/trip.dart';
|
||||||
|
import '../data/recent_places_store.dart';
|
||||||
import '../data/trip_repository.dart';
|
import '../data/trip_repository.dart';
|
||||||
import 'ride_state.dart';
|
import 'ride_state.dart';
|
||||||
|
|
||||||
@@ -25,16 +26,19 @@ class RideCubit extends Cubit<RideState> {
|
|||||||
required MapsRepository maps,
|
required MapsRepository maps,
|
||||||
required LocationService location,
|
required LocationService location,
|
||||||
required RealtimeService realtime,
|
required RealtimeService realtime,
|
||||||
|
required RecentPlacesStore recents,
|
||||||
}) : _trips = trips,
|
}) : _trips = trips,
|
||||||
_maps = maps,
|
_maps = maps,
|
||||||
_location = location,
|
_location = location,
|
||||||
_realtime = realtime,
|
_realtime = realtime,
|
||||||
|
_recents = recents,
|
||||||
super(const RideState());
|
super(const RideState());
|
||||||
|
|
||||||
final TripRepository _trips;
|
final TripRepository _trips;
|
||||||
final MapsRepository _maps;
|
final MapsRepository _maps;
|
||||||
final LocationService _location;
|
final LocationService _location;
|
||||||
final RealtimeService _realtime;
|
final RealtimeService _realtime;
|
||||||
|
final RecentPlacesStore _recents;
|
||||||
|
|
||||||
StreamSubscription<RealtimeEvent>? _events;
|
StreamSubscription<RealtimeEvent>? _events;
|
||||||
Timer? _searchTimeout;
|
Timer? _searchTimeout;
|
||||||
@@ -116,8 +120,6 @@ class RideCubit extends Cubit<RideState> {
|
|||||||
|
|
||||||
void couponChanged(String code) => emit(state.copyWith(couponCode: code));
|
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));
|
void collapsePlanner() => emit(state.copyWith(phase: RidePhase.idle));
|
||||||
|
|
||||||
/// تبديل النوع **لا يُطلق نداءً**: كل الأنواع مسعَّرة مسبقاً في [_priceAll].
|
/// تبديل النوع **لا يُطلق نداءً**: كل الأنواع مسعَّرة مسبقاً في [_priceAll].
|
||||||
@@ -150,7 +152,12 @@ class RideCubit extends Cubit<RideState> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// الأماكن الأخيرة — تُقرأ في الشاشة، وتُحدَّث عند كل اختيار وجهة.
|
||||||
|
List<Place> get recentPlaces => _recents.items;
|
||||||
|
|
||||||
Future<void> pickPlace(Place place, PickTarget target) async {
|
Future<void> pickPlace(Place place, PickTarget target) async {
|
||||||
|
// الوجهة وحدها تُحفظ: «الأخيرة» تعني أين ذهبتُ لا من أين انطلقت.
|
||||||
|
if (target == PickTarget.destination) unawaited(_recents.add(place));
|
||||||
emit(state.copyWith(
|
emit(state.copyWith(
|
||||||
origin: target == PickTarget.origin ? place.point : null,
|
origin: target == PickTarget.origin ? place.point : null,
|
||||||
originLabel: target == PickTarget.origin ? place.label : null,
|
originLabel: target == PickTarget.origin ? place.label : null,
|
||||||
@@ -416,6 +423,29 @@ class RideCubit extends Cubit<RideState> {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── المحاكاة ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// عَلَم بناء: `--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) {
|
void _onRealtime(RealtimeEvent event) {
|
||||||
switch (event.name) {
|
switch (event.name) {
|
||||||
case RealtimeEvent.tripUpdate:
|
case RealtimeEvent.tripUpdate:
|
||||||
@@ -427,10 +457,62 @@ class RideCubit extends Cubit<RideState> {
|
|||||||
final lat = event.data['lat'];
|
final lat = event.data['lat'];
|
||||||
final lng = event.data['lng'];
|
final lng = event.data['lng'];
|
||||||
if (lat is num && lng is num) {
|
if (lat is num && lng is num) {
|
||||||
emit(state.copyWith(
|
_onDriverMoved(GeoPoint(lat.toDouble(), lng.toDouble()));
|
||||||
driverLocation: 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<void> _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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class RideState extends Equatable {
|
|||||||
this.quotes = const {},
|
this.quotes = const {},
|
||||||
this.trip,
|
this.trip,
|
||||||
this.driverLocation,
|
this.driverLocation,
|
||||||
|
this.pickupRoute,
|
||||||
this.searchResults = const [],
|
this.searchResults = const [],
|
||||||
this.searching = false,
|
this.searching = false,
|
||||||
this.busy = false,
|
this.busy = false,
|
||||||
@@ -95,6 +96,12 @@ class RideState extends Equatable {
|
|||||||
/// موقع السائق الحيّ من `driver:location`.
|
/// موقع السائق الحيّ من `driver:location`.
|
||||||
final GeoPoint? driverLocation;
|
final GeoPoint? driverLocation;
|
||||||
|
|
||||||
|
/// مسار **السائق ← الراكب** أثناء الاقتراب، ومنه الوقت والمسافة المعروضان.
|
||||||
|
///
|
||||||
|
/// منفصل عن [route] (مسار الرحلة نفسها) لأنهما يُرسمان معاً بلونين
|
||||||
|
/// مختلفين، ولأن هذا يُعاد حسابه مع حركة السائق بينما ذاك ثابت.
|
||||||
|
final RouteInfo? pickupRoute;
|
||||||
|
|
||||||
final List<Place> searchResults;
|
final List<Place> searchResults;
|
||||||
|
|
||||||
/// بحث نصّي جارٍ — منفصل عن [busy] كي لا يقفل مؤشّرُ البحثِ الشاشةَ كلها.
|
/// بحث نصّي جارٍ — منفصل عن [busy] كي لا يقفل مؤشّرُ البحثِ الشاشةَ كلها.
|
||||||
@@ -133,6 +140,7 @@ class RideState extends Equatable {
|
|||||||
Map<String, FareQuote>? quotes,
|
Map<String, FareQuote>? quotes,
|
||||||
Trip? trip,
|
Trip? trip,
|
||||||
GeoPoint? driverLocation,
|
GeoPoint? driverLocation,
|
||||||
|
RouteInfo? pickupRoute,
|
||||||
List<Place>? searchResults,
|
List<Place>? searchResults,
|
||||||
bool? searching,
|
bool? searching,
|
||||||
bool? busy,
|
bool? busy,
|
||||||
@@ -144,6 +152,7 @@ class RideState extends Equatable {
|
|||||||
bool clearRoute = false,
|
bool clearRoute = false,
|
||||||
bool clearTrip = false,
|
bool clearTrip = false,
|
||||||
bool clearCamera = false,
|
bool clearCamera = false,
|
||||||
|
bool clearPickupRoute = false,
|
||||||
}) {
|
}) {
|
||||||
return RideState(
|
return RideState(
|
||||||
phase: phase ?? this.phase,
|
phase: phase ?? this.phase,
|
||||||
@@ -161,6 +170,9 @@ class RideState extends Equatable {
|
|||||||
quotes: clearRoute ? const {} : (quotes ?? this.quotes),
|
quotes: clearRoute ? const {} : (quotes ?? this.quotes),
|
||||||
trip: clearTrip ? null : (trip ?? this.trip),
|
trip: clearTrip ? null : (trip ?? this.trip),
|
||||||
driverLocation: driverLocation ?? this.driverLocation,
|
driverLocation: driverLocation ?? this.driverLocation,
|
||||||
|
pickupRoute: clearPickupRoute || clearRoute
|
||||||
|
? null
|
||||||
|
: (pickupRoute ?? this.pickupRoute),
|
||||||
searchResults: searchResults ?? this.searchResults,
|
searchResults: searchResults ?? this.searchResults,
|
||||||
searching: searching ?? this.searching,
|
searching: searching ?? this.searching,
|
||||||
busy: busy ?? this.busy,
|
busy: busy ?? this.busy,
|
||||||
@@ -186,6 +198,7 @@ class RideState extends Equatable {
|
|||||||
quotes,
|
quotes,
|
||||||
trip,
|
trip,
|
||||||
driverLocation,
|
driverLocation,
|
||||||
|
pickupRoute,
|
||||||
searchResults,
|
searchResults,
|
||||||
searching,
|
searching,
|
||||||
busy,
|
busy,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,7 +70,14 @@ class Trip extends Equatable {
|
|||||||
this.driverPhone,
|
this.driverPhone,
|
||||||
this.vehiclePlate,
|
this.vehiclePlate,
|
||||||
this.vehicleModel,
|
this.vehicleModel,
|
||||||
|
this.vehicleColor,
|
||||||
|
this.vehicleColorHex,
|
||||||
this.driverRating,
|
this.driverRating,
|
||||||
|
this.requestedAt,
|
||||||
|
this.distanceKm,
|
||||||
|
this.durationMin,
|
||||||
|
this.paymentMethod,
|
||||||
|
this.serviceClass,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
@@ -90,8 +97,34 @@ class Trip extends Equatable {
|
|||||||
final String? driverPhone;
|
final String? driverPhone;
|
||||||
final String? vehiclePlate;
|
final String? vehiclePlate;
|
||||||
final String? vehicleModel;
|
final String? vehicleModel;
|
||||||
|
|
||||||
|
/// لون المركبة كاسم (`White`, `أبيض`) — **ما يبحث عنه الراكب في الشارع
|
||||||
|
/// قبل اللوحة**. الخادم يرسله في `vehicle.color`
|
||||||
|
/// (`trips.service.ts:427`).
|
||||||
|
final String? vehicleColor;
|
||||||
|
|
||||||
|
/// اللون كـhex إن وُجد (`vehicles` عندها `color_hex`). يُقدَّم على الاسم
|
||||||
|
/// عند الرسم لأنه دقيق، والاسم يبقى للعرض النصّي.
|
||||||
|
final String? vehicleColorHex;
|
||||||
|
|
||||||
final String? driverRating;
|
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<String, dynamic> json) {
|
factory Trip.fromJson(Map<String, dynamic> json) {
|
||||||
GeoPoint? point(String key) {
|
GeoPoint? point(String key) {
|
||||||
final v = json[key];
|
final v = json[key];
|
||||||
@@ -99,6 +132,11 @@ class Trip extends Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final driver = json['driver'] as Map<String, dynamic>?;
|
final driver = json['driver'] as Map<String, dynamic>?;
|
||||||
|
// المركبة تصل بشكلين: مسطّحة (`vehicle_plate`) في ردّ الرحلة، ومتداخلة
|
||||||
|
// (`vehicle: {plate, color}`) في حدث `assigned` (`trips.service.ts:423`).
|
||||||
|
// القراءة تقبل الاثنين بدل أن تُفرغ البطاقة عند أحدهما.
|
||||||
|
final vehicle = (json['vehicle'] ?? driver?['vehicle'])
|
||||||
|
as Map<String, dynamic>?;
|
||||||
return Trip(
|
return Trip(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
status: TripStatus.parse(json['status'] 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?,
|
driverName: driver?['name'] as String? ?? json['driver_name'] as String?,
|
||||||
driverPhone:
|
driverPhone:
|
||||||
driver?['phone'] as String? ?? json['driver_phone'] as String?,
|
driver?['phone'] as String? ?? json['driver_phone'] as String?,
|
||||||
vehiclePlate:
|
vehiclePlate: vehicle?['plate'] as String? ??
|
||||||
driver?['vehicle_plate'] as String? ?? json['vehicle_plate'] as String?,
|
driver?['vehicle_plate'] as String? ??
|
||||||
vehicleModel:
|
json['vehicle_plate'] as String?,
|
||||||
driver?['vehicle_model'] as String? ?? json['vehicle_model'] 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(),
|
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?,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<Place> _items = const [];
|
||||||
|
List<Place> get items => _items;
|
||||||
|
|
||||||
|
Future<void> 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<dynamic>;
|
||||||
|
_items = list
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(_decode)
|
||||||
|
.toList(growable: false);
|
||||||
|
} catch (_) {
|
||||||
|
_items = const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// يضيف المكان في الصدارة، ويزيح تكراره إن سبق — لا يتكدّس نفس المكان.
|
||||||
|
Future<void> 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<String, dynamic> _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<String, dynamic> 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
import '../../../core/design/tokens.dart';
|
import '../../../core/design/tokens.dart';
|
||||||
import '../../../core/design/tripz_colors.dart';
|
import '../../../core/design/tripz_colors.dart';
|
||||||
@@ -55,6 +56,16 @@ class _HistoryPageState extends State<HistoryPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// بطاقة رحلة في السجلّ.
|
||||||
|
///
|
||||||
|
/// كانت ثلاثة عناصر فقط (نقطة · حالة · سعر) — بلا تاريخ ولا مسافة ولا طريقة
|
||||||
|
/// دفع، رغم أن الخادم يرسلها كلها في `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 {
|
class _TripCard extends StatelessWidget {
|
||||||
const _TripCard({required this.trip});
|
const _TripCard({required this.trip});
|
||||||
|
|
||||||
@@ -77,25 +88,106 @@ class _TripCard extends StatelessWidget {
|
|||||||
_ => (l10n.historyStatusLive, colors.info),
|
_ => (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(
|
return TripzCard(
|
||||||
child: Row(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Row(
|
||||||
width: 8,
|
children: [
|
||||||
height: 8,
|
Container(
|
||||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
width: 8,
|
||||||
),
|
height: 8,
|
||||||
const SizedBox(width: Space.sm),
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||||
Expanded(
|
),
|
||||||
child: Text(label, style: context.texts.bodyMedium),
|
const SizedBox(width: Space.sm),
|
||||||
),
|
Expanded(
|
||||||
Text(
|
child: Text(
|
||||||
Money.format(trip.priceForPassenger ?? trip.quotedFare,
|
label,
|
||||||
trip.currency),
|
style: context.texts.bodyMedium?.copyWith(color: color),
|
||||||
style: numericStyle(context.texts.titleSmall),
|
),
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ import '../../../core/ui/status_banner.dart';
|
|||||||
import '../../../core/ui/tripz_button.dart';
|
import '../../../core/ui/tripz_button.dart';
|
||||||
import '../cubit/ride_cubit.dart';
|
import '../cubit/ride_cubit.dart';
|
||||||
import '../cubit/ride_state.dart';
|
import '../cubit/ride_state.dart';
|
||||||
|
import '../cubit/trip_simulator.dart';
|
||||||
import '../data/models/geo_point.dart';
|
import '../data/models/geo_point.dart';
|
||||||
|
import '../data/models/route_info.dart';
|
||||||
import 'widgets/active_trip_sheet.dart';
|
import 'widgets/active_trip_sheet.dart';
|
||||||
import 'widgets/center_pin.dart';
|
import 'widgets/center_pin.dart';
|
||||||
import 'widgets/confirm_sheet.dart';
|
import 'widgets/confirm_sheet.dart';
|
||||||
|
import 'widgets/place_search_dialog.dart';
|
||||||
import 'widgets/planner_sheet.dart';
|
import 'widgets/planner_sheet.dart';
|
||||||
import 'widgets/ride_map.dart';
|
import 'widgets/ride_map.dart';
|
||||||
import 'widgets/searching_sheet.dart';
|
import 'widgets/searching_sheet.dart';
|
||||||
@@ -30,8 +33,16 @@ class RidePage extends StatefulWidget {
|
|||||||
State<RidePage> createState() => _RidePageState();
|
State<RidePage> createState() => _RidePageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// نصيب الورقة السفلية من ارتفاع الشاشة — الخريطة تأخذ ما تبقّى (~67%).
|
||||||
|
///
|
||||||
|
/// قرار المالك 2026-08-05: الخريطة تُرفع عن القاع بالثلث كي تبقى مساحة
|
||||||
|
/// الورقة خالصة لها. سيرو يرفعها بـ20%، وتريبز بـ33% لأن ورقته تعرض
|
||||||
|
/// خطوطاً أكثر (الخط الزمني + الأنواع + السعر).
|
||||||
|
const double _sheetFraction = 0.33;
|
||||||
|
|
||||||
class _RidePageState extends State<RidePage> with WidgetsBindingObserver {
|
class _RidePageState extends State<RidePage> with WidgetsBindingObserver {
|
||||||
late final RideCubit _cubit = sl<RideCubit>()..init();
|
late final RideCubit _cubit = sl<RideCubit>()..init();
|
||||||
|
late final TripSimulator _simulator = TripSimulator(_cubit);
|
||||||
GeoPoint? _mapCenter;
|
GeoPoint? _mapCenter;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -43,6 +54,7 @@ class _RidePageState extends State<RidePage> with WidgetsBindingObserver {
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
_simulator.stop();
|
||||||
_cubit.close();
|
_cubit.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -65,47 +77,82 @@ class _RidePageState extends State<RidePage> with WidgetsBindingObserver {
|
|||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
// ── الخريطة ────────────────────────────────────────────────
|
// ── الخريطة ────────────────────────────────────────────────
|
||||||
BlocSelector<RideCubit, RideState, _MapData>(
|
//
|
||||||
selector: (s) => _MapData(
|
// **مرفوعة عن القاع بمقدار [_sheetFraction]** لا مالئةً الشاشة:
|
||||||
origin: s.origin,
|
// الثلث السفلي مساحة الورقة وحدها، فلا تغطّي الورقةُ الخريطةَ
|
||||||
destination: s.destination,
|
// (نمط سيرو — `Positioned(bottom: Get.height * .2, top: 0)` في
|
||||||
driver: s.driverLocation,
|
// `google_map_passenger_widget.dart`).
|
||||||
route: s.route,
|
//
|
||||||
camera: s.cameraTarget,
|
// والدبّوس **داخل نفس المنطقة** لا في الـ`Stack` الخارجي: مركز
|
||||||
originLabel: s.originLabel,
|
// الكاميرا (`onCameraIdle`) هو مركز ودجت الخريطة، فلو بقي
|
||||||
destinationLabel: s.destinationLabel,
|
// الدبّوس متمركزاً على الشاشة لأشار إلى نقطة غير التي تُؤكَّد.
|
||||||
),
|
Positioned(
|
||||||
builder: (context, data) => RideMap(
|
top: 0,
|
||||||
origin: data.origin,
|
left: 0,
|
||||||
destination: data.destination,
|
right: 0,
|
||||||
driver: data.driver,
|
bottom: MediaQuery.sizeOf(context).height * _sheetFraction,
|
||||||
route: data.route,
|
child: Stack(
|
||||||
cameraTarget: data.camera,
|
children: [
|
||||||
onCameraIdle: (p) => _mapCenter = p,
|
BlocSelector<RideCubit, RideState, _MapData>(
|
||||||
// الورقة تغطّي نحو ثلث الشاشة — يُحسب منه إطار المسار كي لا
|
selector: (s) => _MapData(
|
||||||
// يختفي نصفه تحتها.
|
origin: s.origin,
|
||||||
bottomInset: MediaQuery.sizeOf(context).height * 0.34,
|
destination: s.destination,
|
||||||
originLabel: data.originLabel,
|
driver: s.driverLocation,
|
||||||
destinationLabel: data.destinationLabel,
|
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<RideCubit, RideState, bool>(
|
BlocSelector<RideCubit, RideState, bool>(
|
||||||
selector: (s) => s.phase == RidePhase.pickingOnMap,
|
selector: (s) => s.phase == RidePhase.pickingOnMap,
|
||||||
builder: (context, picking) =>
|
builder: (context, picking) =>
|
||||||
picking ? const CenterPin() : const SizedBox.shrink(),
|
picking ? const CenterPin() : const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// زر القائمة — يطفو فوق الخريطة، ويحترم المنطقة الآمنة.
|
// زر القائمة — يطفو فوق الخريطة، ويحترم المنطقة الآمنة.
|
||||||
SafeArea(
|
SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: Space.page,
|
padding: Space.page,
|
||||||
child: Builder(
|
child: Row(
|
||||||
builder: (context) => _MapFab(
|
children: [
|
||||||
icon: Icons.menu_rounded,
|
Builder(
|
||||||
onTap: Scaffold.of(context).openDrawer,
|
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.destination,
|
||||||
this.driver,
|
this.driver,
|
||||||
this.route,
|
this.route,
|
||||||
|
this.pickupRoute,
|
||||||
this.camera,
|
this.camera,
|
||||||
this.originLabel = '',
|
this.originLabel = '',
|
||||||
this.destinationLabel = '',
|
this.destinationLabel = '',
|
||||||
@@ -145,7 +193,8 @@ class _MapData {
|
|||||||
final GeoPoint? origin;
|
final GeoPoint? origin;
|
||||||
final GeoPoint? destination;
|
final GeoPoint? destination;
|
||||||
final GeoPoint? driver;
|
final GeoPoint? driver;
|
||||||
final dynamic route;
|
final RouteInfo? route;
|
||||||
|
final RouteInfo? pickupRoute;
|
||||||
final GeoPoint? camera;
|
final GeoPoint? camera;
|
||||||
final String originLabel;
|
final String originLabel;
|
||||||
final String destinationLabel;
|
final String destinationLabel;
|
||||||
@@ -157,6 +206,7 @@ class _MapData {
|
|||||||
other.destination == destination &&
|
other.destination == destination &&
|
||||||
other.driver == driver &&
|
other.driver == driver &&
|
||||||
other.route == route &&
|
other.route == route &&
|
||||||
|
other.pickupRoute == pickupRoute &&
|
||||||
other.camera == camera &&
|
other.camera == camera &&
|
||||||
other.originLabel == originLabel &&
|
other.originLabel == originLabel &&
|
||||||
other.destinationLabel == destinationLabel;
|
other.destinationLabel == destinationLabel;
|
||||||
@@ -167,6 +217,7 @@ class _MapData {
|
|||||||
destination,
|
destination,
|
||||||
driver,
|
driver,
|
||||||
route,
|
route,
|
||||||
|
pickupRoute,
|
||||||
camera,
|
camera,
|
||||||
originLabel,
|
originLabel,
|
||||||
destinationLabel,
|
destinationLabel,
|
||||||
@@ -190,11 +241,20 @@ class _Sheet extends StatelessWidget {
|
|||||||
RidePhase.pickingOnMap => _PickConfirm(onConfirm: onConfirmPick),
|
RidePhase.pickingOnMap => _PickConfirm(onConfirm: onConfirmPick),
|
||||||
RidePhase.confirming => ConfirmSheet(state: state),
|
RidePhase.confirming => ConfirmSheet(state: state),
|
||||||
RidePhase.searching => const SearchingSheet(),
|
RidePhase.searching => const SearchingSheet(),
|
||||||
RidePhase.active => ActiveTripSheet(trip: state.trip!),
|
RidePhase.active => ActiveTripSheet(
|
||||||
|
trip: state.trip!,
|
||||||
|
pickupRoute: state.pickupRoute,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
|
// الحدّ الأدنى = المساحة المحجوزة للورقة تحت الخريطة، وإلا ظهر فراغُ
|
||||||
|
// الـ`Scaffold` بينهما في المراحل قصيرة المحتوى (`idle` مثلاً).
|
||||||
|
// وهو حدّ أدنى لا ثابت: `active` و`confirming` يكبران فوقه بحرّية.
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
minHeight: MediaQuery.sizeOf(context).height * _sheetFraction,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: context.colors.surface,
|
color: context.colors.surface,
|
||||||
borderRadius: Radii.sheetTop,
|
borderRadius: Radii.sheetTop,
|
||||||
@@ -299,21 +359,32 @@ class _Collapsed extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: Space.sm),
|
const SizedBox(height: Space.sm),
|
||||||
],
|
],
|
||||||
// حقل بحث لا زرّ: الضغط عليه يفتح المخطّط والكيبورد معاً.
|
// حقل بحث لا زرّ: الضغط عليه يفتح بحث الأماكن بملء الشاشة مباشرةً
|
||||||
|
// (docs/40 §2) — لا ورقةً وسيطة ثم بحثاً داخلها.
|
||||||
|
//
|
||||||
|
// الحشوة سخيّة عمداً: الثلث السفلي صار مساحة الورقة وحدها بعد رفع
|
||||||
|
// الخريطة، وحقلٌ نحيف وسط فراغ يبدو مقصوصاً لا أنيقاً.
|
||||||
Material(
|
Material(
|
||||||
color: context.colors.surfaceContainerHighest,
|
color: context.colors.surfaceContainerHighest,
|
||||||
borderRadius: Radii.card_,
|
borderRadius: Radii.card_,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: cubit.openPlanner,
|
onTap: () => PlaceSearchDialog.show(
|
||||||
|
context,
|
||||||
|
target: PickTarget.destination,
|
||||||
|
),
|
||||||
borderRadius: Radii.card_,
|
borderRadius: Radii.card_,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: Space.md,
|
horizontal: Space.md,
|
||||||
vertical: Space.sm,
|
vertical: Space.md,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
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),
|
const SizedBox(width: Space.sm),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
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<RideCubit>();
|
||||||
|
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 {
|
class _PickConfirm extends StatelessWidget {
|
||||||
const _PickConfirm({required this.onConfirm});
|
const _PickConfirm({required this.onConfirm});
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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/tokens.dart';
|
||||||
import '../../../../core/design/tripz_colors.dart';
|
import '../../../../core/design/tripz_colors.dart';
|
||||||
import '../../../../core/l10n/l10n.dart';
|
import '../../../../core/l10n/l10n.dart';
|
||||||
import '../../../../core/router.dart';
|
|
||||||
import '../../../../core/tenant/feature_gate.dart';
|
|
||||||
import '../../../../core/ui/tripz_button.dart';
|
import '../../../../core/ui/tripz_button.dart';
|
||||||
import '../../cubit/ride_cubit.dart';
|
import '../../cubit/ride_cubit.dart';
|
||||||
|
import '../../data/models/route_info.dart';
|
||||||
import '../../data/models/trip.dart';
|
import '../../data/models/trip.dart';
|
||||||
import 'cancel_sheet.dart';
|
import 'cancel_sheet.dart';
|
||||||
import 'driver_badge.dart';
|
import 'driver_badge.dart';
|
||||||
import 'fare_summary.dart';
|
import 'fare_summary.dart';
|
||||||
|
import 'trip_contact_row.dart';
|
||||||
|
|
||||||
/// بطاقة الرحلة الجارية: حالة واضحة ثم بيانات السائق ثم فعل واحد.
|
/// بطاقة الرحلة الجارية: حالة واضحة ثم بيانات السائق ثم فعل واحد.
|
||||||
class ActiveTripSheet extends StatelessWidget {
|
class ActiveTripSheet extends StatelessWidget {
|
||||||
const ActiveTripSheet({super.key, required this.trip});
|
const ActiveTripSheet({super.key, required this.trip, this.pickupRoute});
|
||||||
|
|
||||||
final Trip trip;
|
final Trip trip;
|
||||||
|
|
||||||
|
/// مسار اقتراب السائق — منه شريط الوقت والمسافة.
|
||||||
|
final RouteInfo? pickupRoute;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
@@ -29,22 +30,27 @@ class ActiveTripSheet extends StatelessWidget {
|
|||||||
TripStatus.driverArriving,
|
TripStatus.driverArriving,
|
||||||
}.contains(trip.status);
|
}.contains(trip.status);
|
||||||
|
|
||||||
|
// الشريط أثناء الاقتراب وحده: بعد ركوب الراكب لم يعد «كم بقي ليصل» سؤالاً.
|
||||||
|
final eta = const {
|
||||||
|
TripStatus.assigned,
|
||||||
|
TripStatus.driverArriving,
|
||||||
|
}.contains(trip.status)
|
||||||
|
? pickupRoute
|
||||||
|
: null;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text(_statusText(context), style: context.texts.titleMedium),
|
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),
|
const SizedBox(height: Space.md),
|
||||||
DriverBadge(trip: trip),
|
DriverBadge(trip: trip),
|
||||||
if (Features.chat && context.tenantHas('chat')) ...[
|
const SizedBox(height: Space.sm),
|
||||||
const SizedBox(height: Space.sm),
|
TripContactRow(trip: trip),
|
||||||
TripzButton.secondary(
|
|
||||||
label: l10n.chatTitle,
|
|
||||||
icon: Icons.forum_outlined,
|
|
||||||
expanded: true,
|
|
||||||
onPressed: () => context.push('${Routes.chat}?trip=\${trip.id}'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
if (trip.priceForPassenger != null) ...[
|
if (trip.priceForPassenger != null) ...[
|
||||||
const SizedBox(height: Space.md),
|
const SizedBox(height: Space.md),
|
||||||
FareSummary(trip: trip),
|
FareSummary(trip: trip),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import '../../../../core/design/tokens.dart';
|
|||||||
import '../../../../core/design/tripz_colors.dart';
|
import '../../../../core/design/tripz_colors.dart';
|
||||||
import '../../../../core/design/typography.dart';
|
import '../../../../core/design/typography.dart';
|
||||||
import '../../data/models/trip.dart';
|
import '../../data/models/trip.dart';
|
||||||
|
import 'vehicle_color.dart';
|
||||||
|
|
||||||
/// بطاقة السائق ولوحة المركبة — أهم ما يبحث عنه الراكب في الشارع، فيأخذ
|
/// بطاقة السائق ولوحة المركبة — أهم ما يبحث عنه الراكب في الشارع، فيأخذ
|
||||||
/// أوضح موضع وأكبر تباين.
|
/// أوضح موضع وأكبر تباين.
|
||||||
@@ -15,25 +16,35 @@ class DriverBadge extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (trip.driverName == null) return const SizedBox.shrink();
|
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(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
CircleAvatar(
|
_CarAvatar(color: carColor),
|
||||||
radius: 24,
|
|
||||||
backgroundColor: context.colors.primaryContainer,
|
|
||||||
child: Icon(
|
|
||||||
Icons.person_rounded,
|
|
||||||
color: context.colors.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: Space.sm),
|
const SizedBox(width: Space.sm),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(trip.driverName!, style: context.texts.titleSmall),
|
Text(trip.driverName!, style: context.texts.titleSmall),
|
||||||
if (trip.vehicleModel != null)
|
if (vehicleLine.isNotEmpty)
|
||||||
Text(
|
Text(
|
||||||
trip.vehicleModel!,
|
vehicleLine,
|
||||||
style: context.texts.bodySmall?.copyWith(
|
style: context.texts.bodySmall?.copyWith(
|
||||||
color: context.colors.onSurfaceVariant,
|
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 {
|
class _Plate extends StatelessWidget {
|
||||||
const _Plate({required this.plate});
|
const _Plate({required this.plate});
|
||||||
|
|||||||
@@ -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<void> show(BuildContext context, {required PickTarget target}) {
|
||||||
|
final cubit = context.read<RideCubit>();
|
||||||
|
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<Offset>(
|
||||||
|
begin: const Offset(0, 1),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(parent: animation, curve: Curves.easeOutCubic)),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PlaceSearchDialog> createState() => _PlaceSearchDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlaceSearchDialogState extends State<PlaceSearchDialog> {
|
||||||
|
final _query = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
// البحث ينتهي مع الديالوج — لا يبقى مصدر نتائج قديمة معلَّقاً في الحالة
|
||||||
|
// حين يُفتح البحث ثانيةً بحقل فارغ.
|
||||||
|
context.read<RideCubit>().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<RideCubit>().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<RideCubit, RideState, ({String o, String d, int stops})>(
|
||||||
|
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<RideCubit>().searchPlaces,
|
||||||
|
suffix: _query.text.isEmpty
|
||||||
|
? null
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(Icons.close_rounded),
|
||||||
|
onPressed: () {
|
||||||
|
_query.clear();
|
||||||
|
context.read<RideCubit>().searchPlaces('');
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: Space.sm),
|
||||||
|
Expanded(
|
||||||
|
child: BlocBuilder<RideCubit, RideState>(
|
||||||
|
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<RideCubit>().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<RideCubit>().startMapPick(widget.target);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,52 +2,25 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
|
||||||
import '../../../../core/design/tokens.dart';
|
import '../../../../core/design/tokens.dart';
|
||||||
import '../../../../core/design/tripz_colors.dart';
|
|
||||||
import '../../../../core/l10n/l10n.dart';
|
import '../../../../core/l10n/l10n.dart';
|
||||||
import '../../../../core/ui/tripz_button.dart';
|
import '../../../../core/ui/tripz_button.dart';
|
||||||
import '../../../../core/ui/tripz_text_field.dart';
|
|
||||||
import '../../cubit/ride_cubit.dart';
|
import '../../cubit/ride_cubit.dart';
|
||||||
import '../../cubit/ride_state.dart';
|
import '../../cubit/ride_state.dart';
|
||||||
|
import 'place_search_dialog.dart';
|
||||||
import 'route_timeline.dart';
|
import 'route_timeline.dart';
|
||||||
|
|
||||||
/// المخطّط الموسّع بترتيبه المثبت ميدانياً (docs/39 §3):
|
/// المخطّط الموسّع: الخط الزمني للمسار فقط — البحث بحدّ ذاته انتقل إلى
|
||||||
/// الخط الزمني للمسار ← البحث ← تحديد من الخريطة.
|
/// [PlaceSearchDialog] بملء الشاشة (docs/40 §2). الضغط على أي حقل في الخط
|
||||||
class PlannerSheet extends StatefulWidget {
|
/// الزمني يفتح ذلك الديالوج بدل بحث مضغوط داخل هذه الورقة.
|
||||||
|
class PlannerSheet extends StatelessWidget {
|
||||||
const PlannerSheet({super.key, required this.state});
|
const PlannerSheet({super.key, required this.state});
|
||||||
|
|
||||||
final RideState state;
|
final RideState state;
|
||||||
|
|
||||||
@override
|
|
||||||
State<PlannerSheet> createState() => _PlannerSheetState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _PlannerSheetState extends State<PlannerSheet> {
|
|
||||||
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final cubit = context.read<RideCubit>();
|
final cubit = context.read<RideCubit>();
|
||||||
final state = widget.state;
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -57,81 +30,12 @@ class _PlannerSheetState extends State<PlannerSheet> {
|
|||||||
originLabel: state.originLabel,
|
originLabel: state.originLabel,
|
||||||
destinationLabel: state.destinationLabel,
|
destinationLabel: state.destinationLabel,
|
||||||
stopCount: state.stops.length,
|
stopCount: state.stops.length,
|
||||||
onTapOrigin: () => cubit.startMapPick(PickTarget.origin),
|
onTapOrigin: () =>
|
||||||
onTapDestination: () => cubit.startMapPick(PickTarget.destination),
|
PlaceSearchDialog.show(context, target: PickTarget.origin),
|
||||||
|
onTapDestination: () =>
|
||||||
|
PlaceSearchDialog.show(context, target: PickTarget.destination),
|
||||||
),
|
),
|
||||||
const SizedBox(height: Space.md),
|
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(
|
TripzButton.secondary(
|
||||||
label: l10n.ridePickOnMap,
|
label: l10n.ridePickOnMap,
|
||||||
icon: Icons.map_outlined,
|
icon: Icons.map_outlined,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:developer' as dev;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intaleq_maps/intaleq_maps.dart' as im;
|
import 'package:intaleq_maps/intaleq_maps.dart' as im;
|
||||||
|
|
||||||
@@ -35,6 +37,7 @@ class RideMap extends StatefulWidget {
|
|||||||
this.destination,
|
this.destination,
|
||||||
this.driver,
|
this.driver,
|
||||||
this.route,
|
this.route,
|
||||||
|
this.pickupRoute,
|
||||||
this.cameraTarget,
|
this.cameraTarget,
|
||||||
this.onCameraIdle,
|
this.onCameraIdle,
|
||||||
this.bottomInset = 280,
|
this.bottomInset = 280,
|
||||||
@@ -46,6 +49,10 @@ class RideMap extends StatefulWidget {
|
|||||||
final GeoPoint? destination;
|
final GeoPoint? destination;
|
||||||
final GeoPoint? driver;
|
final GeoPoint? driver;
|
||||||
final RouteInfo? route;
|
final RouteInfo? route;
|
||||||
|
|
||||||
|
/// مسار اقتراب السائق — يُرسم بلون وعرض مختلفين عن [route].
|
||||||
|
final RouteInfo? pickupRoute;
|
||||||
|
|
||||||
final GeoPoint? cameraTarget;
|
final GeoPoint? cameraTarget;
|
||||||
|
|
||||||
/// مركز الخريطة عند استقرارها — يغذّي اختيار النقطة بالدبّوس الثابت.
|
/// مركز الخريطة عند استقرارها — يغذّي اختيار النقطة بالدبّوس الثابت.
|
||||||
@@ -83,6 +90,29 @@ class _RideMapState extends State<RideMap> {
|
|||||||
|
|
||||||
static const _fallback = im.LatLng(31.9539, 35.9106); // عمّان
|
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/` — منقولان من سيرو الميداني.
|
/// الستايلان مرفقان في `assets/` — منقولان من سيرو الميداني.
|
||||||
static const _lightStyleAsset = 'assets/style.json';
|
static const _lightStyleAsset = 'assets/style.json';
|
||||||
static const _darkStyleAsset = 'assets/style_dark.json';
|
static const _darkStyleAsset = 'assets/style_dark.json';
|
||||||
@@ -123,9 +153,66 @@ class _RideMapState extends State<RideMap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _moveTo(GeoPoint p) {
|
void _moveTo(GeoPoint p) {
|
||||||
_controller?.animateCamera(
|
final target = _safe(p, 'moveTo');
|
||||||
im.CameraUpdate.newLatLngZoom(im.LatLng(p.lat, p.lng), 15.5),
|
if (target == null) return;
|
||||||
);
|
_controller?.animateCamera(im.CameraUpdate.newLatLngZoom(target, 15.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// يفكّ ترميز المسار **مصفّىً**: مسار مشوّه من الخادم (أو سلسلة مقطوعة)
|
||||||
|
/// يعطي خطوط عرض خارج ±90، وتلك تصل `mbgl::LatLng` فتُسقط التطبيق أصلاً.
|
||||||
|
static List<im.LatLng> _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<im.Polyline> _routePolylines(Color routeColor, Color pickupColor) {
|
||||||
|
if (!_styleReady) return const {};
|
||||||
|
|
||||||
|
final lines = <im.Polyline>{};
|
||||||
|
|
||||||
|
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<RideMap> {
|
|||||||
void _fitRoute() {
|
void _fitRoute() {
|
||||||
final encoded = widget.route?.encodedPoints;
|
final encoded = widget.route?.encodedPoints;
|
||||||
if (encoded == null || encoded.isEmpty) return;
|
if (encoded == null || encoded.isEmpty) return;
|
||||||
final points = im.PolylineUtils.decode(encoded);
|
final points = _decodeRoute(encoded);
|
||||||
if (points.length < 2) return;
|
if (points.length < 2) return;
|
||||||
|
|
||||||
var minLat = points.first.latitude, maxLat = points.first.latitude;
|
var minLat = points.first.latitude, maxLat = points.first.latitude;
|
||||||
@@ -147,6 +234,10 @@ class _RideMapState extends State<RideMap> {
|
|||||||
if (p.longitude > maxLng) maxLng = p.longitude;
|
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(
|
_controller?.animateCamera(
|
||||||
im.CameraUpdate.newLatLngBounds(
|
im.CameraUpdate.newLatLngBounds(
|
||||||
im.LatLngBounds(
|
im.LatLngBounds(
|
||||||
@@ -156,7 +247,7 @@ class _RideMapState extends State<RideMap> {
|
|||||||
left: 48,
|
left: 48,
|
||||||
right: 48,
|
right: 48,
|
||||||
top: 120,
|
top: 120,
|
||||||
bottom: widget.bottomInset,
|
bottom: bottom,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -167,19 +258,19 @@ class _RideMapState extends State<RideMap> {
|
|||||||
if (controller == null || _syncing) return;
|
if (controller == null || _syncing) return;
|
||||||
_syncing = true;
|
_syncing = true;
|
||||||
|
|
||||||
Future<Offset?> px(GeoPoint? p) async {
|
// `getScreenCoordinate` يعبر إلى `mbgl::LatLng` هو الآخر — نفس الحارس.
|
||||||
if (p == null) return null;
|
Future<Offset?> px(GeoPoint? p, String tag) async {
|
||||||
final point = await controller.getScreenCoordinate(
|
final target = _safe(p, tag);
|
||||||
im.LatLng(p.lat, p.lng),
|
if (target == null) return null;
|
||||||
);
|
final point = await controller.getScreenCoordinate(target);
|
||||||
return Offset(point.x.toDouble(), point.y.toDouble());
|
return Offset(point.x.toDouble(), point.y.toDouble());
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final results = await Future.wait([
|
final results = await Future.wait([
|
||||||
px(widget.origin),
|
px(widget.origin, 'origin'),
|
||||||
px(widget.destination),
|
px(widget.destination, 'destination'),
|
||||||
px(widget.driver),
|
px(widget.driver, 'driver'),
|
||||||
]);
|
]);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -205,22 +296,19 @@ class _RideMapState extends State<RideMap> {
|
|||||||
// الخريطة تبدّل ستايلها مع الثيم — خريطة نهارية في وضع ليلي تكسر
|
// الخريطة تبدّل ستايلها مع الثيم — خريطة نهارية في وضع ليلي تكسر
|
||||||
// الغرض (docs/26 §2). وتبديله يهدم مديري التعليقات، فيُصفَّر العَلَم.
|
// الغرض (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`
|
// `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).
|
// إقلاع» (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;
|
final styleUrl = isDark ? _darkStyleAsset : _lightStyleAsset;
|
||||||
if (_styleUrl != null && _styleUrl != styleUrl) _styleReady = false;
|
if (_styleUrl != null && _styleUrl != styleUrl) _styleReady = false;
|
||||||
_styleUrl = styleUrl;
|
_styleUrl = styleUrl;
|
||||||
@@ -234,13 +322,17 @@ class _RideMapState extends State<RideMap> {
|
|||||||
// فنقفله من هنا بدل الاتّكال على ذلك.
|
// فنقفله من هنا بدل الاتّكال على ذلك.
|
||||||
autoCache: false,
|
autoCache: false,
|
||||||
initialCameraPosition: im.CameraPosition(
|
initialCameraPosition: im.CameraPosition(
|
||||||
target: widget.origin == null
|
target: _safe(widget.origin, 'initialCamera') ?? _fallback,
|
||||||
? _fallback
|
|
||||||
: im.LatLng(widget.origin!.lat, widget.origin!.lng),
|
|
||||||
zoom: 15,
|
zoom: 15,
|
||||||
),
|
),
|
||||||
styleUrl: styleUrl,
|
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,
|
compassEnabled: false,
|
||||||
zoomControlsEnabled: false,
|
zoomControlsEnabled: false,
|
||||||
onMapCreated: _onCreated,
|
onMapCreated: _onCreated,
|
||||||
@@ -257,20 +349,9 @@ class _RideMapState extends State<RideMap> {
|
|||||||
}
|
}
|
||||||
_syncPixels();
|
_syncPixels();
|
||||||
},
|
},
|
||||||
// **المسار وحده annotation** — لا بديل عنه، ومحروسٌ بعَلَم الستايل.
|
// **المسار وحده annotation** — لا بديل عنه، ومحروسٌ بعَلَم الستايل
|
||||||
polylines: _styleReady && widget.route != null &&
|
// وبتصفية الإحداثيات (نقاط المسار تعبر إلى `mbgl::LatLng` أيضاً).
|
||||||
widget.route!.encodedPoints.isNotEmpty
|
polylines: _routePolylines(colors.mapRoute, colors.info),
|
||||||
? {
|
|
||||||
im.Polyline(
|
|
||||||
polylineId: const im.PolylineId('route'),
|
|
||||||
points: im.PolylineUtils.decode(
|
|
||||||
widget.route!.encodedPoints,
|
|
||||||
),
|
|
||||||
color: colors.mapRoute,
|
|
||||||
width: 5,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: const {},
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── الطبقة الفلاترية: نقاط وأسماء وسائق ──────────────────────────
|
// ── الطبقة الفلاترية: نقاط وأسماء وسائق ──────────────────────────
|
||||||
|
|||||||
@@ -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<void> _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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 = <String, Color>{
|
||||||
|
'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()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -353,10 +353,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: geolocator_linux
|
name: geolocator_linux
|
||||||
sha256: d64112a205931926f4363bb6bd48f14cb38e7326833041d170615586cd143797
|
sha256: c4e966f0a7a87e70049eac7a2617f9e16fd4c585a26e4330bdfc3a71e6a721f3
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.2.4"
|
version: "0.2.3"
|
||||||
geolocator_platform_interface:
|
geolocator_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -598,13 +598,13 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
version: "3.0.0"
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: package_info_plus
|
name: package_info_plus
|
||||||
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
|
sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "9.0.1"
|
version: "8.3.1"
|
||||||
package_info_plus_platform_interface:
|
package_info_plus_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ dependencies:
|
|||||||
permission_handler: ^12.0.1
|
permission_handler: ^12.0.1
|
||||||
device_info_plus: 12.3.0
|
device_info_plus: 12.3.0
|
||||||
|
|
||||||
|
# رقم الإصدار في شاشة الإعدادات — كانت موجودة كاعتماد غير مباشر أصلاً،
|
||||||
|
# فترقيتها لمباشر لا تجرّ كوداً أصلياً جديداً.
|
||||||
|
package_info_plus: ^8.1.1
|
||||||
|
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
|
|
||||||
# باقي الحزم تُضاف في مرحلتها لا قبلها (docs/37 §1.7):
|
# باقي الحزم تُضاف في مرحلتها لا قبلها (docs/37 §1.7):
|
||||||
|
|||||||
@@ -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<Offset>(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]] (شاشات سيرو المرصودة)
|
||||||
Reference in New Issue
Block a user