Update codebase

This commit is contained in:
Hamza-Ayed
2026-08-05 14:12:41 +03:00
parent 58928cd258
commit 95e2e4f35d
51 changed files with 3141 additions and 390 deletions
+25 -10
View File
@@ -23,11 +23,32 @@ class BuildConfig {
static const String logoAsset = 'assets/images/logo.png';
static const String splashLogoAsset = 'assets/images/splash_logo.png';
/// روابط خارجية لكل مستأجر — تُولَّد مع بقية الملف (N3).
static const String storeUrl = String.fromEnvironment(
'STORE_URL',
defaultValue: 'https://tripz.app',
/// روابط المتاجر الأربعة — **صفحات المتجر الحقيقية المنشورة**، أعطاها
/// المالك 2026-08-05. لا تُستعمل مباشرةً: `StoreLinks` هو من يختار الرابط
/// حسب المنصّة والدور، لأن «قيّم التطبيق» على آيفون سائق ≠ نفس الرابط على
/// أندرويد راكب.
///
/// المعرّفات تطابق هوية النشر المقفلة: `com.mobileapp.store.ride` للراكب
/// و`com.sefer_driver` للسائق.
static const String riderIosUrl = String.fromEnvironment(
'RIDER_IOS_URL',
defaultValue: 'https://apps.apple.com/eg/app/tripz-rider/id6458734951',
);
static const String driverIosUrl = String.fromEnvironment(
'DRIVER_IOS_URL',
defaultValue: 'https://apps.apple.com/eg/app/tripz-driver/id6502189302',
);
static const String riderAndroidUrl = String.fromEnvironment(
'RIDER_ANDROID_URL',
defaultValue:
'https://play.google.com/store/apps/details?id=com.mobileapp.store.ride',
);
static const String driverAndroidUrl = String.fromEnvironment(
'DRIVER_ANDROID_URL',
defaultValue:
'https://play.google.com/store/apps/details?id=com.sefer_driver',
);
static const String privacyUrl = String.fromEnvironment(
'PRIVACY_URL',
defaultValue: 'https://tripz.app/privacy',
@@ -38,12 +59,6 @@ class BuildConfig {
'SUPPORT_PHONE',
defaultValue: '962790000000',
);
/// رابط تطبيق الطرف الآخر — الراكب يدعو نفسه ليصير سائقاً والعكس.
static const String driverAppUrl = String.fromEnvironment(
'DRIVER_APP_URL',
defaultValue: 'https://tripz.app/driver',
);
}
/// أعلام الاستحقاقات (docs/23 §5). **`const` إجبارياً** كي يُحذف كود الميزة
+15 -1
View File
@@ -25,6 +25,8 @@ import 'api/antlaq_api.dart';
import 'api/api_client.dart';
import 'build_config.dart';
import 'location/location_service.dart';
import '../features/trip/data/recent_places_store.dart';
import 'notifications/notification_store.dart';
import 'notifications/push_service.dart';
import 'realtime/realtime_service.dart';
import 'session/session_cubit.dart';
@@ -79,7 +81,12 @@ Future<void> setupInjector() async {
sl.registerSingleton<RealtimeService>(RealtimeService(tokens));
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.registerFactory<HistoryCubit>(() => HistoryCubit(sl()));
@@ -105,11 +112,18 @@ Future<void> setupInjector() async {
sl.registerFactory<WalletCubit>(() => WalletCubit(sl()));
}
// الأماكن الأخيرة تُحمَّل مرة واحدة عند الإقلاع: الشاشة الرئيسية تقرأها
// في أول إطار، وقراءةُ القرص وقتها تعني صفّاً يقفز بعد الرسم.
final recents = RecentPlacesStore();
await recents.load();
sl.registerSingleton<RecentPlacesStore>(recents);
sl.registerFactory<RideCubit>(() => RideCubit(
trips: sl(),
maps: sl(),
location: sl(),
realtime: sl(),
recents: sl(),
));
}
+24 -1
View File
@@ -65,6 +65,10 @@
"settingsLanguage": "اللغة",
"settingsLanguageArabic": "العربية",
"settingsLanguageEnglish": "English",
"settingsSectionGeneral": "عام",
"settingsSectionSupport": "الدعم والمعلومات",
"settingsSupportLead": "أسئلة شائعة وطرق التواصل",
"settingsShareLead": "شارك التطبيق مع أصدقائك",
"rideWhereTo": "إلى أين؟",
"rideOrigin": "من",
"rideDestination": "إلى",
@@ -131,6 +135,8 @@
"rateSubmit": "إرسال التقييم",
"rateSkip": "لاحقاً",
"rateAlready": "سبق أن قيّمت هذه الرحلة",
"tripCall": "اتصال",
"tripMessage": "رسالة",
"tripFareTitle": "تفاصيل الأجرة",
"tripFare": "الأجرة",
"tripEarnings": "أرباحك",
@@ -169,9 +175,14 @@
"menuEarnings": "أرباحي",
"menuSettings": "الإعدادات",
"menuSupport": "المساعدة",
"menuNotifications": "الإشعارات",
"notificationsEmpty": "لا إشعارات بعد — سنُعلمك هنا بكل ما يخصّ رحلاتك",
"notificationsClear": "مسح الكل",
"menuAbout": "عن التطبيق",
"menuLogout": "تسجيل الخروج",
"menuLogoutConfirm": "هل تريد الخروج من حسابك؟",
"paymentCash": "نقداً",
"paymentWallet": "من المحفظة",
"historyTitle": "رحلاتي",
"historyEmpty": "لا رحلات بعد — اطلب أول رحلة لك",
"historyStatusCompleted": "مكتملة",
@@ -224,12 +235,24 @@
"menuNotifications": "الإشعارات",
"menuPrivacy": "سياسة الخصوصية",
"menuRateApp": "قيّم التطبيق",
"shareMessage": "جرّب تطبيق Tripz — استعمل كودي {code} واحصل على خصم أول رحلة",
"shareMessage": "جرّب تطبيق Tripz — استعمل كودي {code} واحصل على خصم أول رحلة\n{link}",
"shareMessagePlain": "جرّب تطبيق Tripz\n{link}",
"menuRiderApp": "حمّل تطبيق الراكب",
"menuDriverApp": "سجّل كسائق",
"@shareMessage": {
"placeholders": {
"code": {
"type": "String"
},
"link": {
"type": "String"
}
}
},
"@shareMessagePlain": {
"placeholders": {
"link": {
"type": "String"
}
}
},
+24 -1
View File
@@ -65,6 +65,10 @@
"settingsLanguage": "Language",
"settingsLanguageArabic": "العربية",
"settingsLanguageEnglish": "English",
"settingsSectionGeneral": "General",
"settingsSectionSupport": "Support & info",
"settingsSupportLead": "FAQ and ways to reach us",
"settingsShareLead": "Share the app with your friends",
"rideWhereTo": "Where to?",
"rideOrigin": "From",
"rideDestination": "To",
@@ -131,6 +135,8 @@
"rateSubmit": "Submit rating",
"rateSkip": "Later",
"rateAlready": "You already rated this trip",
"tripCall": "Call",
"tripMessage": "Message",
"tripFareTitle": "Fare breakdown",
"tripFare": "Fare",
"tripEarnings": "Your earnings",
@@ -169,9 +175,14 @@
"menuEarnings": "Earnings",
"menuSettings": "Settings",
"menuSupport": "Help",
"menuNotifications": "Notifications",
"notificationsEmpty": "No notifications yet — we'll keep you posted about your trips here",
"notificationsClear": "Clear all",
"menuAbout": "About",
"menuLogout": "Sign out",
"menuLogoutConfirm": "Sign out of your account?",
"paymentCash": "Cash",
"paymentWallet": "Wallet",
"historyTitle": "My trips",
"historyEmpty": "No trips yet — request your first ride",
"historyStatusCompleted": "Completed",
@@ -224,12 +235,24 @@
"menuNotifications": "Notifications",
"menuPrivacy": "Privacy policy",
"menuRateApp": "Rate the app",
"shareMessage": "Try Tripz — use my code {code} for a discount on your first ride",
"shareMessage": "Try Tripz — use my code {code} for a discount on your first ride\n{link}",
"shareMessagePlain": "Try Tripz\n{link}",
"menuRiderApp": "Get the rider app",
"menuDriverApp": "Become a driver",
"@shareMessage": {
"placeholders": {
"code": {
"type": "String"
},
"link": {
"type": "String"
}
}
},
"@shareMessagePlain": {
"placeholders": {
"link": {
"type": "String"
}
}
},
@@ -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_exception.dart';
import 'notification_store.dart';
/// إشعارات FCM — **الطبقة الوحيدة التي تعرف Firebase**.
///
/// عقد الخادم نقطة واحدة: `POST /notifications/token { token, platform }`
/// (docs/38 §11). لا نقطة سرد إشعارات، فلا شاشة «صندوق إشعارات» تُبنى على
/// وهم — ما يصل يصل من النظام.
/// (docs/38 §11). لا نقطة **سرد** إشعارات، فالصندوق محلّي: ما يصل أثناء عمل
/// التطبيق يُحفظ في [NotificationStore] ويُعرض في `NotificationsPage` —
/// انظر تعليق ذلك الصنف لحدود هذا الاختيار.
///
/// الإعداد الأصلي جاهز مسبقاً: `google-services.json` و
/// `GoogleService-Info.plist` منقولان مع الطبقة الأصلية، و`build.gradle`
/// يطبّق `com.google.gms.google-services` أصلاً.
class PushService {
PushService(this._api);
PushService(this._api, this._store);
final ApiClient _api;
final NotificationStore _store;
/// يُنادى **بعد** الدخول: التسجيل يربط الجهاز بالمستخدم، فقبل الجلسة
/// لا معنى له.
@@ -35,11 +38,30 @@ class PushService {
// الرمز يُدوَّر من Google دورياً — بلا هذا الاشتراك تتوقّف الإشعارات
// بصمت بعد أسابيع.
messaging.onTokenRefresh.listen(_send);
// ما يصل والتطبيق مفتوح، وما فُتح التطبيق بالضغط عليه.
FirebaseMessaging.onMessage.listen(_capture);
FirebaseMessaging.onMessageOpenedApp.listen(_capture);
final initial = await messaging.getInitialMessage();
if (initial != null) await _capture(initial);
} catch (_) {
// غياب الإشعارات لا يمنع استعمال التطبيق — لا يُسقط الإقلاع.
}
}
/// إشعار بلا عنوان ولا نصّ (رسالة بيانات صرفة) لا يُعرض — سطر فارغ في
/// الصندوق أسوأ من لا شيء.
Future<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 {
try {
await _api.post<Map<String, dynamic>>(
+6
View File
@@ -8,6 +8,7 @@ import '../features/chat/view/chat_page.dart';
import '../features/profile/view/profile_edit_page.dart';
import '../features/profile/view/profile_setup_page.dart';
import '../features/rating/data/rating_repository.dart';
import '../features/notifications/view/notifications_page.dart';
import '../features/rewards/view/rewards_page.dart';
import '../features/rating/view/rating_page.dart';
import '../features/settings/view/settings_page.dart';
@@ -34,6 +35,7 @@ class Routes {
static const support = '/support';
static const rewards = '/rewards';
static const chat = '/chat';
static const notifications = '/notifications';
}
/// التوجيه يُشتقّ من `SessionCubit` وحده — لا `Navigator.push` بعد الدخول
@@ -120,6 +122,10 @@ final appRouter = GoRouter(
path: Routes.support,
builder: (context, state) => const SupportPage(),
),
GoRoute(
path: Routes.notifications,
builder: (context, state) => const NotificationsPage(),
),
GoRoute(
path: Routes.home,
builder: (context, state) => const RidePage(),
+37
View File
@@ -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;
}
+55 -10
View File
@@ -8,10 +8,13 @@ import '../build_config.dart';
import '../config.dart';
import '../design/tokens.dart';
import '../design/tripz_colors.dart';
import '../di.dart';
import '../l10n/l10n.dart';
import '../notifications/notification_store.dart';
import '../router.dart';
import '../session/session_cubit.dart';
import '../session/session_state.dart';
import '../store_links.dart';
import '../tenant/feature_gate.dart';
/// الدراور — نقطة تطبيق **طبقتَي الميزات** معاً (docs/23 §5 + docs/38 §10):
@@ -43,6 +46,14 @@ class AppDrawer extends StatelessWidget {
label: l10n.menuProfile,
route: Routes.profileEdit,
),
_Item(
icon: Icons.notifications_none_rounded,
label: l10n.menuNotifications,
route: Routes.notifications,
// العدّاد يُقرأ عند بناء الدراور — والدراور يُبنى عند كل
// فتح، فلا حاجة لتيّار يراقب مخزناً في الذاكرة.
badge: sl<NotificationStore>().unreadCount,
),
_Item(
icon: Icons.route_outlined,
label: l10n.menuTrips,
@@ -72,28 +83,36 @@ class AppDrawer extends StatelessWidget {
route: Routes.support,
),
// مداخل محليّة بلا نقطة خادم — تُنفَّذ في الجهاز مباشرة.
//
// الروابط من `StoreLinks` لا من ثابتٍ واحد: هذا البناء
// يشارك **رابط نفسه** (سائق يشارك رابط السائق)، ويفتح
// متجر منصّته هو.
_Action(
icon: Icons.share_outlined,
label: l10n.menuShare,
onTap: () => SharePlus.instance
.share(ShareParams(text: l10n.shareMessage(''))),
onTap: () => SharePlus.instance.share(
ShareParams(
text: l10n.shareMessagePlain(StoreLinks.thisApp),
),
),
),
_Action(
icon: Icons.star_outline_rounded,
label: l10n.menuRateApp,
onTap: () => _open(BuildConfig.storeUrl),
onTap: () => _open(StoreLinks.thisApp),
),
_Action(
icon: Icons.shield_outlined,
label: l10n.menuPrivacy,
onTap: () => _open(BuildConfig.privacyUrl),
),
if (!isDriver)
_Action(
icon: Icons.local_taxi_outlined,
label: l10n.menuDriverApp,
onTap: () => _open(BuildConfig.driverAppUrl),
),
_Action(
icon: isDriver
? Icons.person_outline_rounded
: Icons.local_taxi_outlined,
label: isDriver ? l10n.menuRiderApp : l10n.menuDriverApp,
onTap: () => _open(StoreLinks.otherApp),
),
],
),
),
@@ -220,17 +239,43 @@ class _Action extends StatelessWidget {
}
class _Item extends StatelessWidget {
const _Item({required this.icon, required this.label, required this.route});
const _Item({
required this.icon,
required this.label,
required this.route,
this.badge = 0,
});
final IconData icon;
final String label;
final String route;
/// عدّاد غير المقروء — يُخفى عند الصفر.
final int badge;
@override
Widget build(BuildContext context) {
return ListTile(
leading: Icon(icon),
title: Text(label),
trailing: badge == 0
? null
: Container(
padding: const EdgeInsets.symmetric(
horizontal: Space.xs,
vertical: Space.xxs / 2,
),
decoration: BoxDecoration(
color: context.tripzColors.danger,
borderRadius: Radii.field_,
),
child: Text(
'$badge',
style: context.texts.labelSmall?.copyWith(
color: context.colors.onPrimary,
),
),
),
onTap: () {
Navigator.of(context).pop();
context.push(route);
@@ -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/tripz_colors.dart';
import '../../../core/design/typography.dart';
import '../../../core/di.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/session/session_cubit.dart';
@@ -54,6 +55,16 @@ class _ProfileEditPageState extends State<ProfileEditPage> {
),
child: ListView(
children: [
// ترويسة الحساب — الاسم والرقم والتقييم في مكان واحد.
// **التقييم كان يصل ولا يُعرض أبداً**: `AppUser.rating` مقروء
// من `GET /users/me` (docs/38 §2) ولم تكن أي شاشة تستعمله،
// بينما الراكب يُقيَّم من السائق ويحقّ له أن يرى نتيجته.
_ProfileHeader(
name: user?.name ?? '',
phone: user?.phone ?? '',
rating: user?.rating,
),
const SizedBox(height: Space.lg),
TripzTextField(
controller: _name,
label: l10n.profileNameLabel,
@@ -91,3 +102,78 @@ class _ProfileEditPageState extends State<ProfileEditPage> {
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/di.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/store_links.dart';
import '../../../core/ui/tripz_button.dart';
import '../../../core/ui/tripz_card.dart';
import '../../../core/ui/tripz_scaffold.dart';
@@ -155,8 +156,11 @@ class _InviteCard extends StatelessWidget {
TripzButton.primary(
label: l10n.menuShare,
icon: Icons.share_outlined,
onPressed: () =>
SharePlus.instance.share(ShareParams(text: l10n.shareMessage(code))),
// الكود بلا رابط لا ينفع: المدعوّ لا يعرف من أين يُنزّل. ورابطُ
// **هذا** البناء لا رابطٌ ثابت — سائق يدعو سائقاً وراكب راكباً.
onPressed: () => SharePlus.instance.share(
ShareParams(text: l10n.shareMessage(code, StoreLinks.thisApp)),
),
),
const SizedBox(height: Space.md),
Row(
@@ -1,15 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:share_plus/share_plus.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../core/build_config.dart';
import '../../../core/design/tokens.dart';
import '../../../core/design/tripz_colors.dart';
import '../../../core/design/typography.dart';
import '../../../core/di.dart';
import '../../../core/l10n/l10n.dart';
import '../../../core/router.dart';
import '../../../core/session/session_cubit.dart';
import '../../../core/store_links.dart';
import '../../../core/ui/tripz_scaffold.dart';
import '../../profile/data/profile_repository.dart';
import '../cubit/settings_cubit.dart';
import '../cubit/settings_state.dart';
/// المظهر واللغة. تبديل اللغة **يقلب الاتجاه كاملاً** بلا إعادة تشغيل —
/// `MaterialApp` يشتقّ RTL/LTR من الـlocale (docs/26 §4).
/// المظهر واللغة ومعلومات التطبيق — بأقسام كما في سيرو
/// (`setting_page.dart`: General · Preferences · Support & Info) بدل قائمة
/// مسطّحة بلا عناوين.
///
/// تبديل اللغة **يقلب الاتجاه كاملاً** بلا إعادة تشغيل — `MaterialApp`
/// يشتقّ RTL/LTR من الـlocale (docs/26 §4).
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key});
@@ -23,8 +38,8 @@ class SettingsPage extends StatelessWidget {
title: l10n.settingsTitle,
child: ListView(
children: [
Text(l10n.settingsTheme, style: context.texts.titleMedium),
const SizedBox(height: Space.xs),
_SectionHeader(title: l10n.settingsSectionGeneral),
Text(l10n.settingsTheme, style: context.texts.titleSmall),
RadioGroup<ThemeMode>(
groupValue: state.themeMode,
onChanged: (v) => cubit.setThemeMode(v ?? ThemeMode.system),
@@ -48,12 +63,15 @@ class SettingsPage extends StatelessWidget {
],
),
),
const Divider(height: Space.xl),
Text(l10n.settingsLanguage, style: context.texts.titleMedium),
const SizedBox(height: Space.xs),
const SizedBox(height: Space.md),
Text(l10n.settingsLanguage, style: context.texts.titleSmall),
RadioGroup<String>(
groupValue: state.locale.languageCode,
onChanged: (v) => cubit.setLocale(Locale(v ?? 'ar')),
// اللغة تُحفظ محلياً **وتُرسل للخادم**: الإشعارات تُصاغ على
// الخادم بلغة المستخدم المخزّنة عنده (`PATCH /users/me`
// يقبل `language` — docs/38 §3)، فلغةٌ محليّة وحدها تعني
// واجهةً عربية وإشعاراتٍ إنجليزية.
onChanged: (v) => _setLanguage(context, v ?? 'ar'),
child: Column(
children: [
RadioListTile(
@@ -69,10 +87,127 @@ class SettingsPage extends StatelessWidget {
],
),
),
_SectionHeader(title: l10n.settingsSectionSupport),
_Tile(
icon: Icons.help_outline_rounded,
title: l10n.menuSupport,
subtitle: l10n.settingsSupportLead,
onTap: () => context.push(Routes.support),
),
_Tile(
icon: Icons.share_outlined,
title: l10n.menuShare,
subtitle: l10n.settingsShareLead,
onTap: () => SharePlus.instance.share(
ShareParams(text: l10n.shareMessagePlain(StoreLinks.thisApp)),
),
),
_Tile(
icon: Icons.shield_outlined,
title: l10n.menuPrivacy,
onTap: () async {
final uri = Uri.tryParse(BuildConfig.privacyUrl);
if (uri == null) return;
await launchUrl(uri, mode: LaunchMode.externalApplication);
},
),
const _VersionTile(),
],
),
);
},
);
}
/// فشل الخادم **لا يمنع** تبديل اللغة محلياً: الواجهة تستجيب فوراً،
/// والمزامنة تُعاد عند التبديل التالي. عكسه يعني زرّاً لا يستجيب بلا شبكة.
static Future<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/ride_type.dart';
import '../data/models/trip.dart';
import '../data/recent_places_store.dart';
import '../data/trip_repository.dart';
import 'ride_state.dart';
@@ -25,16 +26,19 @@ class RideCubit extends Cubit<RideState> {
required MapsRepository maps,
required LocationService location,
required RealtimeService realtime,
required RecentPlacesStore recents,
}) : _trips = trips,
_maps = maps,
_location = location,
_realtime = realtime,
_recents = recents,
super(const RideState());
final TripRepository _trips;
final MapsRepository _maps;
final LocationService _location;
final RealtimeService _realtime;
final RecentPlacesStore _recents;
StreamSubscription<RealtimeEvent>? _events;
Timer? _searchTimeout;
@@ -116,8 +120,6 @@ class RideCubit extends Cubit<RideState> {
void couponChanged(String code) => emit(state.copyWith(couponCode: code));
void openPlanner() => emit(state.copyWith(phase: RidePhase.planning));
void collapsePlanner() => emit(state.copyWith(phase: RidePhase.idle));
/// تبديل النوع **لا يُطلق نداءً**: كل الأنواع مسعَّرة مسبقاً في [_priceAll].
@@ -150,7 +152,12 @@ class RideCubit extends Cubit<RideState> {
});
}
/// الأماكن الأخيرة — تُقرأ في الشاشة، وتُحدَّث عند كل اختيار وجهة.
List<Place> get recentPlaces => _recents.items;
Future<void> pickPlace(Place place, PickTarget target) async {
// الوجهة وحدها تُحفظ: «الأخيرة» تعني أين ذهبتُ لا من أين انطلقت.
if (target == PickTarget.destination) unawaited(_recents.add(place));
emit(state.copyWith(
origin: target == PickTarget.origin ? place.point : null,
originLabel: target == PickTarget.origin ? place.label : null,
@@ -416,6 +423,29 @@ class RideCubit extends Cubit<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) {
switch (event.name) {
case RealtimeEvent.tripUpdate:
@@ -427,10 +457,62 @@ class RideCubit extends Cubit<RideState> {
final lat = event.data['lat'];
final lng = event.data['lng'];
if (lat is num && lng is num) {
emit(state.copyWith(
driverLocation: GeoPoint(lat.toDouble(), lng.toDouble()),
));
_onDriverMoved(GeoPoint(lat.toDouble(), lng.toDouble()));
}
}
}
// ── مسار الاقتراب ──────────────────────────────────────────────────────
/// أقلّ إزاحة تستدعي إعادة رسم المسار، بالدرجات (~50 متراً).
///
/// السائق يبثّ موقعه كل بضع ثوانٍ؛ نداءُ مسارٍ عند كل بثّة يعني عشرات
/// النداءات في الدقيقة بلا فرق مرئي. وهذا الحدّ يلتقط **تغيّر الاتجاه**
/// (انعطاف فعلي) ويتجاهل رجفة GPS في المكان.
static const double _redrawThreshold = 0.0005;
GeoPoint? _lastPickupFrom;
bool _pickupRouting = false;
void _onDriverMoved(GeoPoint point) {
emit(state.copyWith(driverLocation: point));
// المسار يُرسم أثناء الاقتراب فقط — بعد بدء الرحلة صار مسار الرحلة هو
// المعروض، ورسم خطٍّ للسائق إلى راكبٍ في سيارته بلا معنى.
final approaching = const {
TripStatus.assigned,
TripStatus.driverArriving,
}.contains(state.trip?.status);
if (!approaching) {
if (state.pickupRoute != null) {
emit(state.copyWith(clearPickupRoute: true));
}
return;
}
final rider = state.origin;
if (rider == null || _pickupRouting) return;
final last = _lastPickupFrom;
final moved = last == null ||
(point.lat - last.lat).abs() > _redrawThreshold ||
(point.lng - last.lng).abs() > _redrawThreshold;
if (!moved) return;
_lastPickupFrom = point;
unawaited(_routePickup(point, rider));
}
Future<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.trip,
this.driverLocation,
this.pickupRoute,
this.searchResults = const [],
this.searching = false,
this.busy = false,
@@ -95,6 +96,12 @@ class RideState extends Equatable {
/// موقع السائق الحيّ من `driver:location`.
final GeoPoint? driverLocation;
/// مسار **السائق ← الراكب** أثناء الاقتراب، ومنه الوقت والمسافة المعروضان.
///
/// منفصل عن [route] (مسار الرحلة نفسها) لأنهما يُرسمان معاً بلونين
/// مختلفين، ولأن هذا يُعاد حسابه مع حركة السائق بينما ذاك ثابت.
final RouteInfo? pickupRoute;
final List<Place> searchResults;
/// بحث نصّي جارٍ — منفصل عن [busy] كي لا يقفل مؤشّرُ البحثِ الشاشةَ كلها.
@@ -133,6 +140,7 @@ class RideState extends Equatable {
Map<String, FareQuote>? quotes,
Trip? trip,
GeoPoint? driverLocation,
RouteInfo? pickupRoute,
List<Place>? searchResults,
bool? searching,
bool? busy,
@@ -144,6 +152,7 @@ class RideState extends Equatable {
bool clearRoute = false,
bool clearTrip = false,
bool clearCamera = false,
bool clearPickupRoute = false,
}) {
return RideState(
phase: phase ?? this.phase,
@@ -161,6 +170,9 @@ class RideState extends Equatable {
quotes: clearRoute ? const {} : (quotes ?? this.quotes),
trip: clearTrip ? null : (trip ?? this.trip),
driverLocation: driverLocation ?? this.driverLocation,
pickupRoute: clearPickupRoute || clearRoute
? null
: (pickupRoute ?? this.pickupRoute),
searchResults: searchResults ?? this.searchResults,
searching: searching ?? this.searching,
busy: busy ?? this.busy,
@@ -186,6 +198,7 @@ class RideState extends Equatable {
quotes,
trip,
driverLocation,
pickupRoute,
searchResults,
searching,
busy,
@@ -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.vehiclePlate,
this.vehicleModel,
this.vehicleColor,
this.vehicleColorHex,
this.driverRating,
this.requestedAt,
this.distanceKm,
this.durationMin,
this.paymentMethod,
this.serviceClass,
});
final String id;
@@ -90,8 +97,34 @@ class Trip extends Equatable {
final String? driverPhone;
final String? vehiclePlate;
final String? vehicleModel;
/// لون المركبة كاسم (`White`, `أبيض`) — **ما يبحث عنه الراكب في الشارع
/// قبل اللوحة**. الخادم يرسله في `vehicle.color`
/// (`trips.service.ts:427`).
final String? vehicleColor;
/// اللون كـhex إن وُجد (`vehicles` عندها `color_hex`). يُقدَّم على الاسم
/// عند الرسم لأنه دقيق، والاسم يبقى للعرض النصّي.
final String? vehicleColorHex;
final String? driverRating;
// ── حقول السجلّ ────────────────────────────────────────────────────────
// كلها موجودة في `trip.entity.ts` وتصل مع `GET /trips/mine`، وكانت
// مُهمَلة في القراءة — فبدت بطاقة «رحلاتي» فارغة إلا من سعر وحالة.
/// وقت الطلب (`requested_at`, `CreateDateColumn`).
final DateTime? requestedAt;
final double? distanceKm;
final double? durationMin;
/// `cash` افتراضاً في الخادم — يُعرض كي يعرف الراكب كيف دفع.
final String? paymentMethod;
/// نوع الخدمة (`economy` افتراضاً).
final String? serviceClass;
factory Trip.fromJson(Map<String, dynamic> json) {
GeoPoint? point(String key) {
final v = json[key];
@@ -99,6 +132,11 @@ class Trip extends Equatable {
}
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(
id: json['id'] as String,
status: TripStatus.parse(json['status'] as String?),
@@ -112,11 +150,26 @@ class Trip extends Equatable {
driverName: driver?['name'] as String? ?? json['driver_name'] as String?,
driverPhone:
driver?['phone'] as String? ?? json['driver_phone'] as String?,
vehiclePlate:
driver?['vehicle_plate'] as String? ?? json['vehicle_plate'] as String?,
vehicleModel:
driver?['vehicle_model'] as String? ?? json['vehicle_model'] as String?,
vehiclePlate: vehicle?['plate'] as String? ??
driver?['vehicle_plate'] as String? ??
json['vehicle_plate'] as String?,
vehicleModel: vehicle?['model'] as String? ??
driver?['vehicle_model'] as String? ??
json['vehicle_model'] as String?,
vehicleColor: vehicle?['color'] as String? ??
driver?['vehicle_color'] as String? ??
json['vehicle_color'] as String?,
vehicleColorHex: vehicle?['color_hex'] as String? ??
driver?['vehicle_color_hex'] as String? ??
json['vehicle_color_hex'] as String?,
driverRating: driver?['rating']?.toString(),
requestedAt: DateTime.tryParse(json['requested_at']?.toString() ?? ''),
// المسافة الفعلية أدقّ من المقدّرة حين تتوفّر بعد انتهاء الرحلة.
distanceKm: (json['actual_distance_km'] as num?)?.toDouble() ??
(json['distance_km'] as num?)?.toDouble(),
durationMin: (json['duration_min'] as num?)?.toDouble(),
paymentMethod: json['payment_method'] as String?,
serviceClass: json['service_class'] as String?,
);
}
@@ -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_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../../core/design/tokens.dart';
import '../../../core/design/tripz_colors.dart';
@@ -55,6 +56,16 @@ class _HistoryPageState extends State<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 {
const _TripCard({required this.trip});
@@ -77,25 +88,106 @@ class _TripCard extends StatelessWidget {
_ => (l10n.historyStatusLive, colors.info),
};
// سطر التفاصيل: ما توفّر منه فقط، بفواصل — رحلة ملغاة بلا مسافة لا
// تعرض «0 كم».
final details = [
if (trip.distanceKm != null && trip.distanceKm! > 0)
l10n.rideDistanceKm(trip.distanceKm!.toStringAsFixed(1)),
if (trip.durationMin != null && trip.durationMin! > 0)
l10n.rideEta(trip.durationMin!.round().toString()),
].join(' · ');
return TripzCard(
child: Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: Space.sm),
Expanded(
child: Text(label, style: context.texts.bodyMedium),
),
Text(
Money.format(trip.priceForPassenger ?? trip.quotedFare,
trip.currency),
style: numericStyle(context.texts.titleSmall),
Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: Space.sm),
Expanded(
child: Text(
label,
style: context.texts.bodyMedium?.copyWith(color: color),
),
),
Text(
Money.format(
trip.priceForPassenger ?? trip.quotedFare,
trip.currency,
),
style: numericStyle(context.texts.titleSmall),
),
],
),
if (trip.requestedAt != null) ...[
const SizedBox(height: Space.xs),
Text(
DateFormat.yMMMd().add_jm().format(trip.requestedAt!.toLocal()),
style: context.texts.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
if (details.isNotEmpty || trip.paymentMethod != null) ...[
const SizedBox(height: Space.xs),
Row(
children: [
if (details.isNotEmpty)
Expanded(
child: Text(
details,
style: numericStyle(context.texts.bodySmall).copyWith(
color: context.colors.onSurfaceVariant,
),
),
)
else
const Spacer(),
if (trip.paymentMethod != null)
_Tag(text: _paymentLabel(context, trip.paymentMethod!)),
],
),
],
],
),
);
}
/// طرق الدفع المعروفة تُترجم؛ وأي قيمة جديدة من الخادم تُعرض كما هي بدل
/// أن تختفي.
String _paymentLabel(BuildContext context, String method) {
final l10n = context.l10n;
return switch (method) {
'cash' => l10n.paymentCash,
'wallet' => l10n.paymentWallet,
_ => method,
};
}
}
/// شارة صغيرة — طريقة الدفع في السجلّ.
class _Tag extends StatelessWidget {
const _Tag({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: Space.xs,
vertical: Space.xxs / 2,
),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: Radii.field_,
),
child: Text(text, style: context.texts.labelSmall),
);
}
}
@@ -10,10 +10,13 @@ import '../../../core/ui/status_banner.dart';
import '../../../core/ui/tripz_button.dart';
import '../cubit/ride_cubit.dart';
import '../cubit/ride_state.dart';
import '../cubit/trip_simulator.dart';
import '../data/models/geo_point.dart';
import '../data/models/route_info.dart';
import 'widgets/active_trip_sheet.dart';
import 'widgets/center_pin.dart';
import 'widgets/confirm_sheet.dart';
import 'widgets/place_search_dialog.dart';
import 'widgets/planner_sheet.dart';
import 'widgets/ride_map.dart';
import 'widgets/searching_sheet.dart';
@@ -30,8 +33,16 @@ class RidePage extends StatefulWidget {
State<RidePage> createState() => _RidePageState();
}
/// نصيب الورقة السفلية من ارتفاع الشاشة — الخريطة تأخذ ما تبقّى (~67%).
///
/// قرار المالك 2026-08-05: الخريطة تُرفع عن القاع بالثلث كي تبقى مساحة
/// الورقة خالصة لها. سيرو يرفعها بـ20%، وتريبز بـ33% لأن ورقته تعرض
/// خطوطاً أكثر (الخط الزمني + الأنواع + السعر).
const double _sheetFraction = 0.33;
class _RidePageState extends State<RidePage> with WidgetsBindingObserver {
late final RideCubit _cubit = sl<RideCubit>()..init();
late final TripSimulator _simulator = TripSimulator(_cubit);
GeoPoint? _mapCenter;
@override
@@ -43,6 +54,7 @@ class _RidePageState extends State<RidePage> with WidgetsBindingObserver {
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_simulator.stop();
_cubit.close();
super.dispose();
}
@@ -65,47 +77,82 @@ class _RidePageState extends State<RidePage> with WidgetsBindingObserver {
body: Stack(
children: [
// ── الخريطة ────────────────────────────────────────────────
BlocSelector<RideCubit, RideState, _MapData>(
selector: (s) => _MapData(
origin: s.origin,
destination: s.destination,
driver: s.driverLocation,
route: s.route,
camera: s.cameraTarget,
originLabel: s.originLabel,
destinationLabel: s.destinationLabel,
),
builder: (context, data) => RideMap(
origin: data.origin,
destination: data.destination,
driver: data.driver,
route: data.route,
cameraTarget: data.camera,
onCameraIdle: (p) => _mapCenter = p,
// الورقة تغطّي نحو ثلث الشاشة — يُحسب منه إطار المسار كي لا
// يختفي نصفه تحتها.
bottomInset: MediaQuery.sizeOf(context).height * 0.34,
originLabel: data.originLabel,
destinationLabel: data.destinationLabel,
),
),
//
// **مرفوعة عن القاع بمقدار [_sheetFraction]** لا مالئةً الشاشة:
// الثلث السفلي مساحة الورقة وحدها، فلا تغطّي الورقةُ الخريطةَ
// (نمط سيرو — `Positioned(bottom: Get.height * .2, top: 0)` في
// `google_map_passenger_widget.dart`).
//
// والدبّوس **داخل نفس المنطقة** لا في الـ`Stack` الخارجي: مركز
// الكاميرا (`onCameraIdle`) هو مركز ودجت الخريطة، فلو بقي
// الدبّوس متمركزاً على الشاشة لأشار إلى نقطة غير التي تُؤكَّد.
Positioned(
top: 0,
left: 0,
right: 0,
bottom: MediaQuery.sizeOf(context).height * _sheetFraction,
child: Stack(
children: [
BlocSelector<RideCubit, RideState, _MapData>(
selector: (s) => _MapData(
origin: s.origin,
destination: s.destination,
driver: s.driverLocation,
route: s.route,
pickupRoute: s.pickupRoute,
camera: s.cameraTarget,
originLabel: s.originLabel,
destinationLabel: s.destinationLabel,
),
builder: (context, data) => RideMap(
origin: data.origin,
destination: data.destination,
driver: data.driver,
route: data.route,
pickupRoute: data.pickupRoute,
cameraTarget: data.camera,
onCameraIdle: (p) => _mapCenter = p,
// الورقة لم تعد تغطّي الخريطة — تبقى حشوة تنفّس فقط
// كي لا يلتصق المسار بحافّة الخريطة السفلية.
bottomInset: Space.xxl,
originLabel: data.originLabel,
destinationLabel: data.destinationLabel,
),
),
// ── دبّوس الاختيار ─────────────────────────────────────────
BlocSelector<RideCubit, RideState, bool>(
selector: (s) => s.phase == RidePhase.pickingOnMap,
builder: (context, picking) =>
picking ? const CenterPin() : const SizedBox.shrink(),
// ── دبّوس الاختيار ─────────────────────────────────────
BlocSelector<RideCubit, RideState, bool>(
selector: (s) => s.phase == RidePhase.pickingOnMap,
builder: (context, picking) =>
picking ? const CenterPin() : const SizedBox.shrink(),
),
],
),
),
// زر القائمة — يطفو فوق الخريطة، ويحترم المنطقة الآمنة.
SafeArea(
child: Padding(
padding: Space.page,
child: Builder(
builder: (context) => _MapFab(
icon: Icons.menu_rounded,
onTap: Scaffold.of(context).openDrawer,
),
child: Row(
children: [
Builder(
builder: (context) => _MapFab(
icon: Icons.menu_rounded,
onTap: Scaffold.of(context).openDrawer,
),
),
// زرّ المحاكاة — يظهر فقط مع
// `--dart-define=SIMULATE_TRIP=true`. الشرط `const` فيُحذف
// هو وما تحته من بناء الإنتاج.
if (RideCubit.simulationEnabled) ...[
const SizedBox(width: Space.xs),
_MapFab(
icon: Icons.play_arrow_rounded,
onTap: () => _simulator.start(),
),
],
],
),
),
),
@@ -137,6 +184,7 @@ class _MapData {
this.destination,
this.driver,
this.route,
this.pickupRoute,
this.camera,
this.originLabel = '',
this.destinationLabel = '',
@@ -145,7 +193,8 @@ class _MapData {
final GeoPoint? origin;
final GeoPoint? destination;
final GeoPoint? driver;
final dynamic route;
final RouteInfo? route;
final RouteInfo? pickupRoute;
final GeoPoint? camera;
final String originLabel;
final String destinationLabel;
@@ -157,6 +206,7 @@ class _MapData {
other.destination == destination &&
other.driver == driver &&
other.route == route &&
other.pickupRoute == pickupRoute &&
other.camera == camera &&
other.originLabel == originLabel &&
other.destinationLabel == destinationLabel;
@@ -167,6 +217,7 @@ class _MapData {
destination,
driver,
route,
pickupRoute,
camera,
originLabel,
destinationLabel,
@@ -190,11 +241,20 @@ class _Sheet extends StatelessWidget {
RidePhase.pickingOnMap => _PickConfirm(onConfirm: onConfirmPick),
RidePhase.confirming => ConfirmSheet(state: state),
RidePhase.searching => const SearchingSheet(),
RidePhase.active => ActiveTripSheet(trip: state.trip!),
RidePhase.active => ActiveTripSheet(
trip: state.trip!,
pickupRoute: state.pickupRoute,
),
};
return Container(
width: double.infinity,
// الحدّ الأدنى = المساحة المحجوزة للورقة تحت الخريطة، وإلا ظهر فراغُ
// الـ`Scaffold` بينهما في المراحل قصيرة المحتوى (`idle` مثلاً).
// وهو حدّ أدنى لا ثابت: `active` و`confirming` يكبران فوقه بحرّية.
constraints: BoxConstraints(
minHeight: MediaQuery.sizeOf(context).height * _sheetFraction,
),
decoration: BoxDecoration(
color: context.colors.surface,
borderRadius: Radii.sheetTop,
@@ -299,21 +359,32 @@ class _Collapsed extends StatelessWidget {
),
const SizedBox(height: Space.sm),
],
// حقل بحث لا زرّ: الضغط عليه يفتح المخطّط والكيبورد معاً.
// حقل بحث لا زرّ: الضغط عليه يفتح بحث الأماكن بملء الشاشة مباشرةً
// (docs/40 §2) — لا ورقةً وسيطة ثم بحثاً داخلها.
//
// الحشوة سخيّة عمداً: الثلث السفلي صار مساحة الورقة وحدها بعد رفع
// الخريطة، وحقلٌ نحيف وسط فراغ يبدو مقصوصاً لا أنيقاً.
Material(
color: context.colors.surfaceContainerHighest,
borderRadius: Radii.card_,
child: InkWell(
onTap: cubit.openPlanner,
onTap: () => PlaceSearchDialog.show(
context,
target: PickTarget.destination,
),
borderRadius: Radii.card_,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Space.md,
vertical: Space.sm,
vertical: Space.md,
),
child: Row(
children: [
Icon(Icons.search_rounded, color: context.colors.primary),
Icon(
Icons.search_rounded,
size: Sizes.icon,
color: context.colors.primary,
),
const SizedBox(width: Space.sm),
Expanded(
child: Text(
@@ -323,16 +394,63 @@ class _Collapsed extends StatelessWidget {
),
),
),
Icon(
Icons.chevron_left_rounded,
color: context.colors.onSurfaceVariant,
),
],
),
),
),
),
// الوجهات الأخيرة — نمط سيرو (`rp_favorites.dart: RpRecentsRow`).
// أكثر الرحلات تتكرّر، وبلا هذا يعيد الراكب كتابة الاسم نفسه كل مرة.
const _RecentsRow(),
],
);
}
}
/// صفّ أفقي للوجهات الأخيرة — يختفي كلياً حين لا توجد، فلا يترك فراغاً.
class _RecentsRow extends StatelessWidget {
const _RecentsRow();
@override
Widget build(BuildContext context) {
final cubit = context.read<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 {
const _PickConfirm({required this.onConfirm});
@@ -1,26 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/build_config.dart';
import '../../../../core/design/tokens.dart';
import '../../../../core/design/tripz_colors.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/router.dart';
import '../../../../core/tenant/feature_gate.dart';
import '../../../../core/ui/tripz_button.dart';
import '../../cubit/ride_cubit.dart';
import '../../data/models/route_info.dart';
import '../../data/models/trip.dart';
import 'cancel_sheet.dart';
import 'driver_badge.dart';
import 'fare_summary.dart';
import 'trip_contact_row.dart';
/// بطاقة الرحلة الجارية: حالة واضحة ثم بيانات السائق ثم فعل واحد.
class ActiveTripSheet extends StatelessWidget {
const ActiveTripSheet({super.key, required this.trip});
const ActiveTripSheet({super.key, required this.trip, this.pickupRoute});
final Trip trip;
/// مسار اقتراب السائق — منه شريط الوقت والمسافة.
final RouteInfo? pickupRoute;
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
@@ -29,22 +30,27 @@ class ActiveTripSheet extends StatelessWidget {
TripStatus.driverArriving,
}.contains(trip.status);
// الشريط أثناء الاقتراب وحده: بعد ركوب الراكب لم يعد «كم بقي ليصل» سؤالاً.
final eta = const {
TripStatus.assigned,
TripStatus.driverArriving,
}.contains(trip.status)
? pickupRoute
: null;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(_statusText(context), style: context.texts.titleMedium),
if (eta != null) ...[
const SizedBox(height: Space.sm),
TripEtaStrip(minutes: eta.durationMin, km: eta.distanceKm),
],
const SizedBox(height: Space.md),
DriverBadge(trip: trip),
if (Features.chat && context.tenantHas('chat')) ...[
const SizedBox(height: Space.sm),
TripzButton.secondary(
label: l10n.chatTitle,
icon: Icons.forum_outlined,
expanded: true,
onPressed: () => context.push('${Routes.chat}?trip=\${trip.id}'),
),
],
const SizedBox(height: Space.sm),
TripContactRow(trip: trip),
if (trip.priceForPassenger != null) ...[
const SizedBox(height: Space.md),
FareSummary(trip: trip),
@@ -4,6 +4,7 @@ import '../../../../core/design/tokens.dart';
import '../../../../core/design/tripz_colors.dart';
import '../../../../core/design/typography.dart';
import '../../data/models/trip.dart';
import 'vehicle_color.dart';
/// بطاقة السائق ولوحة المركبة — أهم ما يبحث عنه الراكب في الشارع، فيأخذ
/// أوضح موضع وأكبر تباين.
@@ -15,25 +16,35 @@ class DriverBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (trip.driverName == null) return const SizedBox.shrink();
// اللون **يُرسم لا يُكتب فقط**: الراكب يمسح الشارع بعينه بحثاً عن سيارة
// بيضاء، لا يقرأ كلمة «أبيض». نمط سيرو (`apply_order_widget.dart:333`)
// يلوّن رمز السيارة بلون المركبة الفعلي — وهذا مقابله.
final carColor = VehicleColor.resolve(
hex: trip.vehicleColorHex,
name: trip.vehicleColor,
);
// سطر المركبة: الموديل واللون معاً بفاصل — أحدهما قد يغيب.
final vehicleLine = [
if (trip.vehicleModel != null && trip.vehicleModel!.isNotEmpty)
trip.vehicleModel!,
if (trip.vehicleColor != null && trip.vehicleColor!.isNotEmpty)
trip.vehicleColor!,
].join(' • ');
return Row(
children: [
CircleAvatar(
radius: 24,
backgroundColor: context.colors.primaryContainer,
child: Icon(
Icons.person_rounded,
color: context.colors.onPrimaryContainer,
),
),
_CarAvatar(color: carColor),
const SizedBox(width: Space.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(trip.driverName!, style: context.texts.titleSmall),
if (trip.vehicleModel != null)
if (vehicleLine.isNotEmpty)
Text(
trip.vehicleModel!,
vehicleLine,
style: context.texts.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
@@ -62,6 +73,43 @@ class DriverBadge extends StatelessWidget {
}
}
/// رمز سيارة ملوّن بلون المركبة الحقيقي.
///
/// اللون الفاتح (أبيض/بيج) يختفي على سطح فاتح، فتُرسم حلقة حدّ حوله دائماً —
/// وبها تبقى السيارة البيضاء مقروءة في الوضع النهاري.
class _CarAvatar extends StatelessWidget {
const _CarAvatar({this.color});
final Color? color;
@override
Widget build(BuildContext context) {
final resolved = color ?? context.colors.onSurfaceVariant;
return Container(
width: 48,
height: 48,
decoration: BoxDecoration(
// ‏10% من لون السيارة خلفيةً — يربط الرمز بلونه بلا صراخ.
color: color?.withValues(alpha: 0.12) ??
context.colors.surfaceContainerHighest,
shape: BoxShape.circle,
border: Border.all(color: context.colors.outlineVariant),
),
child: Center(
child: Icon(
Icons.directions_car_filled_rounded,
size: Sizes.icon,
color: resolved,
// ظلّ خفيف كي لا تذوب السيارة البيضاء في الخلفية الفاتحة.
shadows: const [
Shadow(color: Color(0x33000000), blurRadius: 2),
],
),
),
);
}
}
/// لوحة مرسومة لا نصّ عادي — تُقرأ من بعيد ومن زاوية.
class _Plate extends StatelessWidget {
const _Plate({required this.plate});
@@ -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 '../../../../core/design/tokens.dart';
import '../../../../core/design/tripz_colors.dart';
import '../../../../core/l10n/l10n.dart';
import '../../../../core/ui/tripz_button.dart';
import '../../../../core/ui/tripz_text_field.dart';
import '../../cubit/ride_cubit.dart';
import '../../cubit/ride_state.dart';
import 'place_search_dialog.dart';
import 'route_timeline.dart';
/// المخطّط الموسّع بترتيبه المثبت ميدانياً (docs/39 §3):
/// الخط الزمني للمسار ← البحث ← تحديد من الخريطة.
class PlannerSheet extends StatefulWidget {
/// المخطّط الموسّع: الخط الزمني للمسار فقط — البحث بحدّ ذاته انتقل إلى
/// [PlaceSearchDialog] بملء الشاشة (docs/40 §2). الضغط على أي حقل في الخط
/// الزمني يفتح ذلك الديالوج بدل بحث مضغوط داخل هذه الورقة.
class PlannerSheet extends StatelessWidget {
const PlannerSheet({super.key, required this.state});
final RideState state;
@override
State<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
Widget build(BuildContext context) {
final l10n = context.l10n;
final cubit = context.read<RideCubit>();
final state = widget.state;
return Column(
mainAxisSize: MainAxisSize.min,
@@ -57,81 +30,12 @@ class _PlannerSheetState extends State<PlannerSheet> {
originLabel: state.originLabel,
destinationLabel: state.destinationLabel,
stopCount: state.stops.length,
onTapOrigin: () => cubit.startMapPick(PickTarget.origin),
onTapDestination: () => cubit.startMapPick(PickTarget.destination),
onTapOrigin: () =>
PlaceSearchDialog.show(context, target: PickTarget.origin),
onTapDestination: () =>
PlaceSearchDialog.show(context, target: PickTarget.destination),
),
const SizedBox(height: Space.md),
TripzTextField(
controller: _query,
hint: l10n.rideSearchHint,
prefixIcon: Icons.search_rounded,
// الكيبورد يفتح فوراً: المستخدم فتح المخطّط ليكتب، لا لينظر.
autofocus: true,
onChanged: cubit.searchPlaces,
suffix: _query.text.isEmpty
? null
: IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () {
_query.clear();
cubit.searchPlaces('');
setState(() {});
},
),
),
if (state.searching)
const Padding(
padding: EdgeInsets.only(top: Space.md),
child: LinearProgressIndicator(minHeight: 2),
),
if (!state.searching &&
_query.text.trim().length >= 2 &&
state.searchResults.isEmpty)
Padding(
padding: const EdgeInsets.only(top: Space.md),
child: Text(
l10n.rideSearchNoResults,
textAlign: TextAlign.center,
style: context.texts.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
if (state.searchResults.isNotEmpty)
Flexible(
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.only(top: Space.xs),
itemCount: state.searchResults.length,
separatorBuilder: (context, i) => const Divider(height: 1),
itemBuilder: (context, i) {
final place = state.searchResults[i];
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
radius: 18,
backgroundColor: context.colors.surfaceContainerHighest,
child: Icon(
_iconFor(place.category),
size: Sizes.iconSm,
color: context.colors.onSurfaceVariant,
),
),
title: Text(place.label, maxLines: 1,
overflow: TextOverflow.ellipsis),
subtitle: place.address.isEmpty
? null
: Text(place.address,
maxLines: 1, overflow: TextOverflow.ellipsis),
onTap: () {
_query.clear();
cubit.pickPlace(place, PickTarget.destination);
},
);
},
),
),
const SizedBox(height: Space.md),
TripzButton.secondary(
label: l10n.ridePickOnMap,
icon: Icons.map_outlined,
@@ -1,3 +1,5 @@
import 'dart:developer' as dev;
import 'package:flutter/material.dart';
import 'package:intaleq_maps/intaleq_maps.dart' as im;
@@ -35,6 +37,7 @@ class RideMap extends StatefulWidget {
this.destination,
this.driver,
this.route,
this.pickupRoute,
this.cameraTarget,
this.onCameraIdle,
this.bottomInset = 280,
@@ -46,6 +49,10 @@ class RideMap extends StatefulWidget {
final GeoPoint? destination;
final GeoPoint? driver;
final RouteInfo? route;
/// مسار اقتراب السائق — يُرسم بلون وعرض مختلفين عن [route].
final RouteInfo? pickupRoute;
final GeoPoint? cameraTarget;
/// مركز الخريطة عند استقرارها — يغذّي اختيار النقطة بالدبّوس الثابت.
@@ -83,6 +90,29 @@ class _RideMapState extends State<RideMap> {
static const _fallback = im.LatLng(31.9539, 35.9106); // عمّان
/// حارس الإحداثيات — **بلا هذا يسقط التطبيق أصلاً لا استثناءً في دارت**.
///
/// `std::domain_error` الذي رأيناه (2026-08-05) يأتي من `mbgl::LatLng`
/// في MapLibre الأصلي، وهو يرمي حرفياً عند: `latitude must not be NaN` ·
/// `longitude must not be NaN` · `latitude must be between -90 and 90` ·
/// `longitude must not be infinite`. (السلاسل الأربع موجودة في ثنائي
/// `MapLibre.framework`، وهي مصدر `std::domain_error` الوحيد فيه — أمّا
/// الستايل فبريء: الملفّات الخمسة كلها اجتازت `validateStyleMin` بصفر
/// أخطاء.)
///
/// وهو استثناء **C++ يعبر حدّ المنصّة**، فلا `try/catch` في دارت يمسكه:
/// المنع قبل التمرير هو العلاج الوحيد.
static im.LatLng? _safe(GeoPoint? p, String tag) {
if (p == null) return null;
final ok = p.lat.isFinite && p.lng.isFinite && p.lat.abs() <= 90;
if (!ok) {
dev.log('إحداثية غير صالحة رُفضت [$tag]: ${p.lat}, ${p.lng}',
name: 'RideMap');
return null;
}
return im.LatLng(p.lat, p.lng);
}
/// الستايلان مرفقان في `assets/` — منقولان من سيرو الميداني.
static const _lightStyleAsset = 'assets/style.json';
static const _darkStyleAsset = 'assets/style_dark.json';
@@ -123,9 +153,66 @@ class _RideMapState extends State<RideMap> {
}
void _moveTo(GeoPoint p) {
_controller?.animateCamera(
im.CameraUpdate.newLatLngZoom(im.LatLng(p.lat, p.lng), 15.5),
);
final target = _safe(p, 'moveTo');
if (target == null) return;
_controller?.animateCamera(im.CameraUpdate.newLatLngZoom(target, 15.5));
}
/// يفكّ ترميز المسار **مصفّىً**: مسار مشوّه من الخادم (أو سلسلة مقطوعة)
/// يعطي خطوط عرض خارج ±90، وتلك تصل `mbgl::LatLng` فتُسقط التطبيق أصلاً.
static List<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() {
final encoded = widget.route?.encodedPoints;
if (encoded == null || encoded.isEmpty) return;
final points = im.PolylineUtils.decode(encoded);
final points = _decodeRoute(encoded);
if (points.length < 2) return;
var minLat = points.first.latitude, maxLat = points.first.latitude;
@@ -147,6 +234,10 @@ class _RideMapState extends State<RideMap> {
if (p.longitude > maxLng) maxLng = p.longitude;
}
// `mbgl::EdgeInsets` يرمي هو الآخر على NaN (`bottom must not be NaN`)،
// و`bottomInset` مشتقّ من `MediaQuery` فقد يصل صفراً أو NaN في أوّل إطار.
final bottom = widget.bottomInset.isFinite ? widget.bottomInset : 280.0;
_controller?.animateCamera(
im.CameraUpdate.newLatLngBounds(
im.LatLngBounds(
@@ -156,7 +247,7 @@ class _RideMapState extends State<RideMap> {
left: 48,
right: 48,
top: 120,
bottom: widget.bottomInset,
bottom: bottom,
),
);
}
@@ -167,19 +258,19 @@ class _RideMapState extends State<RideMap> {
if (controller == null || _syncing) return;
_syncing = true;
Future<Offset?> px(GeoPoint? p) async {
if (p == null) return null;
final point = await controller.getScreenCoordinate(
im.LatLng(p.lat, p.lng),
);
// `getScreenCoordinate` يعبر إلى `mbgl::LatLng` هو الآخر — نفس الحارس.
Future<Offset?> px(GeoPoint? p, String tag) async {
final target = _safe(p, tag);
if (target == null) return null;
final point = await controller.getScreenCoordinate(target);
return Offset(point.x.toDouble(), point.y.toDouble());
}
try {
final results = await Future.wait([
px(widget.origin),
px(widget.destination),
px(widget.driver),
px(widget.origin, 'origin'),
px(widget.destination, 'destination'),
px(widget.driver, 'driver'),
]);
if (!mounted) return;
setState(() {
@@ -205,22 +296,19 @@ class _RideMapState extends State<RideMap> {
// الخريطة تبدّل ستايلها مع الثيم — خريطة نهارية في وضع ليلي تكسر
// الغرض (docs/26 §2). وتبديله يهدم مديري التعليقات، فيُصفَّر العَلَم.
//
// ⚠️ **ستايل محلّي لا بعيد.** ستايل map-saas البعيد
// (`/api/maps/style.json?theme=light`) فيه **12 طبقة `symbol` تستعمل
// `icon-image` بلا `sprite` معرَّف** — وMapLibre الأصلي يرمي عندها
// `std::domain_error` فيُسقط التطبيق قبل أن يظهر إطار واحد
// (مثبت 2026-08-05). الستايلان المرفقان في `assets/` سليمان: بهما
// `sprite` وبلاطاتهما من `tiles.intaleqapp.com` نفسها.
//
// وهذا حرفياً حلّ سيرو الميداني: انظر
// **ستايل محلّي لا بعيد** — كما في سيرو الميداني حرفياً:
// `apps/rider/lib/views/home/map_widget.dart/google_map_passenger_widget.dart`
// — يمرّر `'assets/style.json'` / `'assets/style_dark.json'` نفسهما، وملفّاه
// يمرّر `'assets/style.json'` / `'assets/style_dark.json'` نفسهما، وملفّاه
// مطابقان لهذين طبقةً بطبقة (لا يختلفان إلا في اسم العلامة التجارية).
// فلا تُعِد هذا السطر إلى `IntaleqStyles.light/obsidian` — جُرّب وأسقط
// التطبيق مرتين.
//
// ومعها نكسب الإقلاع بلا انتظار شبكة — وهو ما يعنيه «الشكل حتمي من أول
// إقلاع» (docs/26 §1).
//
// ملاحظة تصحيحية (2026-08-05): كان مكتوباً هنا أن ستايل map-saas البعيد
// هو سبب `std::domain_error` لأنه بلا `sprite`. **هذا خطأ.** الستايلات
// الخمسة (المحلّيان + البعيد + ستايلا الحزمة) اجتازت
// `validateStyleMin` من `@maplibre/maplibre-gl-style-spec` بصفر أخطاء،
// والسقوط تكرّر بالستايل المحلّي أيضاً. المصدر الحقيقي هو حارس
// الإحداثيات في `mbgl::LatLng`/`mbgl::EdgeInsets` — انظر `_safe` أعلاه.
final styleUrl = isDark ? _darkStyleAsset : _lightStyleAsset;
if (_styleUrl != null && _styleUrl != styleUrl) _styleReady = false;
_styleUrl = styleUrl;
@@ -234,13 +322,17 @@ class _RideMapState extends State<RideMap> {
// فنقفله من هنا بدل الاتّكال على ذلك.
autoCache: false,
initialCameraPosition: im.CameraPosition(
target: widget.origin == null
? _fallback
: im.LatLng(widget.origin!.lat, widget.origin!.lng),
target: _safe(widget.origin, 'initialCamera') ?? _fallback,
zoom: 15,
),
styleUrl: styleUrl,
myLocationEnabled: true,
// نقطة «موقعي» تُفعَّل **بعد** أن يصلنا موقع حقيقي لا عند الإنشاء.
// قبل ذلك يكون `MLNMapView.userLocation.coordinate` هو
// `kCLLocationCoordinate2DInvalid` أي (-180, -180) — وخط عرض -180
// يخترق حارس `mbgl::LatLng` نفسه (`latitude must be between -90
// and 90`). و`origin` لا يصير غير فارغ إلا بعد منح الإذن ووصول
// قراءة فعلية، فهو الشرط الصحيح تماماً.
myLocationEnabled: widget.origin != null,
compassEnabled: false,
zoomControlsEnabled: false,
onMapCreated: _onCreated,
@@ -257,20 +349,9 @@ class _RideMapState extends State<RideMap> {
}
_syncPixels();
},
// **المسار وحده annotation** — لا بديل عنه، ومحروسٌ بعَلَم الستايل.
polylines: _styleReady && widget.route != null &&
widget.route!.encodedPoints.isNotEmpty
? {
im.Polyline(
polylineId: const im.PolylineId('route'),
points: im.PolylineUtils.decode(
widget.route!.encodedPoints,
),
color: colors.mapRoute,
width: 5,
),
}
: const {},
// **المسار وحده annotation** — لا بديل عنه، ومحروسٌ بعَلَم الستايل
// وبتصفية الإحداثيات (نقاط المسار تعبر إلى `mbgl::LatLng` أيضاً).
polylines: _routePolylines(colors.mapRoute, colors.info),
),
// ── الطبقة الفلاترية: نقاط وأسماء وسائق ──────────────────────────
@@ -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()];
}
}