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 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` إجبارياً** كي يُحذف كود الميزة
+1
View File
@@ -140,6 +140,7 @@ Future<void> setupInjector() async {
trips: sl(), trips: sl(),
location: sl(), location: sl(),
realtime: sl(), realtime: sl(),
maps: sl(),
)); ));
} }
+19 -1
View File
@@ -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"
} }
} }
}, },
+19 -1
View File
@@ -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"
} }
} }
}, },
+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;
}
+20 -9
View File
@@ -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()];
}
}
+5 -5
View File
@@ -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:
+4
View File
@@ -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):
+25 -10
View File
@@ -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` إجبارياً** كي يُحذف كود الميزة
+15 -1
View File
@@ -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(),
)); ));
} }
+24 -1
View File
@@ -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"
} }
} }
}, },
+24 -1
View File
@@ -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>>(
+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_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(),
+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 '../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()];
}
}
+5 -5
View File
@@ -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:
+4
View File
@@ -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):
+210
View File
@@ -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]] (شاشات سيرو المرصودة)