Update codebase

This commit is contained in:
Hamza-Ayed
2026-08-05 14:12:41 +03:00
parent 58928cd258
commit 95e2e4f35d
51 changed files with 3141 additions and 390 deletions
+25 -10
View File
@@ -23,11 +23,32 @@ class BuildConfig {
static const String logoAsset = 'assets/images/logo.png';
static const String splashLogoAsset = 'assets/images/splash_logo.png';
/// روابط خارجية لكل مستأجر — تُولَّد مع بقية الملف (N3).
static const String storeUrl = String.fromEnvironment(
'STORE_URL',
defaultValue: 'https://tripz.app',
/// روابط المتاجر الأربعة — **صفحات المتجر الحقيقية المنشورة**، أعطاها
/// المالك 2026-08-05. لا تُستعمل مباشرةً: `StoreLinks` هو من يختار الرابط
/// حسب المنصّة والدور، لأن «قيّم التطبيق» على آيفون سائق ≠ نفس الرابط على
/// أندرويد راكب.
///
/// المعرّفات تطابق هوية النشر المقفلة: `com.mobileapp.store.ride` للراكب
/// و`com.sefer_driver` للسائق.
static const String riderIosUrl = String.fromEnvironment(
'RIDER_IOS_URL',
defaultValue: 'https://apps.apple.com/eg/app/tripz-rider/id6458734951',
);
static const String driverIosUrl = String.fromEnvironment(
'DRIVER_IOS_URL',
defaultValue: 'https://apps.apple.com/eg/app/tripz-driver/id6502189302',
);
static const String riderAndroidUrl = String.fromEnvironment(
'RIDER_ANDROID_URL',
defaultValue:
'https://play.google.com/store/apps/details?id=com.mobileapp.store.ride',
);
static const String driverAndroidUrl = String.fromEnvironment(
'DRIVER_ANDROID_URL',
defaultValue:
'https://play.google.com/store/apps/details?id=com.sefer_driver',
);
static const String privacyUrl = String.fromEnvironment(
'PRIVACY_URL',
defaultValue: 'https://tripz.app/privacy',
@@ -38,12 +59,6 @@ class BuildConfig {
'SUPPORT_PHONE',
defaultValue: '962790000000',
);
/// رابط تطبيق الطرف الآخر — الراكب يدعو نفسه ليصير سائقاً والعكس.
static const String driverAppUrl = String.fromEnvironment(
'DRIVER_APP_URL',
defaultValue: 'https://tripz.app/driver',
);
}
/// أعلام الاستحقاقات (docs/23 §5). **`const` إجبارياً** كي يُحذف كود الميزة
+15 -1
View File
@@ -25,6 +25,8 @@ import 'api/antlaq_api.dart';
import 'api/api_client.dart';
import 'build_config.dart';
import 'location/location_service.dart';
import '../features/trip/data/recent_places_store.dart';
import 'notifications/notification_store.dart';
import 'notifications/push_service.dart';
import 'realtime/realtime_service.dart';
import 'session/session_cubit.dart';
@@ -79,7 +81,12 @@ Future<void> setupInjector() async {
sl.registerSingleton<RealtimeService>(RealtimeService(tokens));
sl.registerSingleton<TenantCubit>(TenantCubit(sl(), prefs));
sl.registerSingleton<PushService>(PushService(sl()));
// الصندوق يُحمَّل قبل `PushService` كي لا يضيع إشعارٌ يصل أثناء الإقلاع.
final notifications = NotificationStore();
await notifications.load();
sl.registerSingleton<NotificationStore>(notifications);
sl.registerSingleton<PushService>(PushService(sl(), notifications));
sl.registerSingleton<HistoryRepository>(HistoryRepository(sl()));
sl.registerFactory<HistoryCubit>(() => HistoryCubit(sl()));
@@ -105,11 +112,18 @@ Future<void> setupInjector() async {
sl.registerFactory<WalletCubit>(() => WalletCubit(sl()));
}
// الأماكن الأخيرة تُحمَّل مرة واحدة عند الإقلاع: الشاشة الرئيسية تقرأها
// في أول إطار، وقراءةُ القرص وقتها تعني صفّاً يقفز بعد الرسم.
final recents = RecentPlacesStore();
await recents.load();
sl.registerSingleton<RecentPlacesStore>(recents);
sl.registerFactory<RideCubit>(() => RideCubit(
trips: sl(),
maps: sl(),
location: sl(),
realtime: sl(),
recents: sl(),
));
}
+24 -1
View File
@@ -65,6 +65,10 @@
"settingsLanguage": "اللغة",
"settingsLanguageArabic": "العربية",
"settingsLanguageEnglish": "English",
"settingsSectionGeneral": "عام",
"settingsSectionSupport": "الدعم والمعلومات",
"settingsSupportLead": "أسئلة شائعة وطرق التواصل",
"settingsShareLead": "شارك التطبيق مع أصدقائك",
"rideWhereTo": "إلى أين؟",
"rideOrigin": "من",
"rideDestination": "إلى",
@@ -131,6 +135,8 @@
"rateSubmit": "إرسال التقييم",
"rateSkip": "لاحقاً",
"rateAlready": "سبق أن قيّمت هذه الرحلة",
"tripCall": "اتصال",
"tripMessage": "رسالة",
"tripFareTitle": "تفاصيل الأجرة",
"tripFare": "الأجرة",
"tripEarnings": "أرباحك",
@@ -169,9 +175,14 @@
"menuEarnings": "أرباحي",
"menuSettings": "الإعدادات",
"menuSupport": "المساعدة",
"menuNotifications": "الإشعارات",
"notificationsEmpty": "لا إشعارات بعد — سنُعلمك هنا بكل ما يخصّ رحلاتك",
"notificationsClear": "مسح الكل",
"menuAbout": "عن التطبيق",
"menuLogout": "تسجيل الخروج",
"menuLogoutConfirm": "هل تريد الخروج من حسابك؟",
"paymentCash": "نقداً",
"paymentWallet": "من المحفظة",
"historyTitle": "رحلاتي",
"historyEmpty": "لا رحلات بعد — اطلب أول رحلة لك",
"historyStatusCompleted": "مكتملة",
@@ -224,12 +235,24 @@
"menuNotifications": "الإشعارات",
"menuPrivacy": "سياسة الخصوصية",
"menuRateApp": "قيّم التطبيق",
"shareMessage": "جرّب تطبيق Tripz — استعمل كودي {code} واحصل على خصم أول رحلة",
"shareMessage": "جرّب تطبيق Tripz — استعمل كودي {code} واحصل على خصم أول رحلة\n{link}",
"shareMessagePlain": "جرّب تطبيق Tripz\n{link}",
"menuRiderApp": "حمّل تطبيق الراكب",
"menuDriverApp": "سجّل كسائق",
"@shareMessage": {
"placeholders": {
"code": {
"type": "String"
},
"link": {
"type": "String"
}
}
},
"@shareMessagePlain": {
"placeholders": {
"link": {
"type": "String"
}
}
},
+24 -1
View File
@@ -65,6 +65,10 @@
"settingsLanguage": "Language",
"settingsLanguageArabic": "العربية",
"settingsLanguageEnglish": "English",
"settingsSectionGeneral": "General",
"settingsSectionSupport": "Support & info",
"settingsSupportLead": "FAQ and ways to reach us",
"settingsShareLead": "Share the app with your friends",
"rideWhereTo": "Where to?",
"rideOrigin": "From",
"rideDestination": "To",
@@ -131,6 +135,8 @@
"rateSubmit": "Submit rating",
"rateSkip": "Later",
"rateAlready": "You already rated this trip",
"tripCall": "Call",
"tripMessage": "Message",
"tripFareTitle": "Fare breakdown",
"tripFare": "Fare",
"tripEarnings": "Your earnings",
@@ -169,9 +175,14 @@
"menuEarnings": "Earnings",
"menuSettings": "Settings",
"menuSupport": "Help",
"menuNotifications": "Notifications",
"notificationsEmpty": "No notifications yet — we'll keep you posted about your trips here",
"notificationsClear": "Clear all",
"menuAbout": "About",
"menuLogout": "Sign out",
"menuLogoutConfirm": "Sign out of your account?",
"paymentCash": "Cash",
"paymentWallet": "Wallet",
"historyTitle": "My trips",
"historyEmpty": "No trips yet — request your first ride",
"historyStatusCompleted": "Completed",
@@ -224,12 +235,24 @@
"menuNotifications": "Notifications",
"menuPrivacy": "Privacy policy",
"menuRateApp": "Rate the app",
"shareMessage": "Try Tripz — use my code {code} for a discount on your first ride",
"shareMessage": "Try Tripz — use my code {code} for a discount on your first ride\n{link}",
"shareMessagePlain": "Try Tripz\n{link}",
"menuRiderApp": "Get the rider app",
"menuDriverApp": "Become a driver",
"@shareMessage": {
"placeholders": {
"code": {
"type": "String"
},
"link": {
"type": "String"
}
}
},
"@shareMessagePlain": {
"placeholders": {
"link": {
"type": "String"
}
}
},
@@ -0,0 +1,104 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
/// إشعار وصل إلى الجهاز — يُحفظ محلياً لأن الخادم لا يسرد شيئاً.
class AppNotification {
const AppNotification({
required this.title,
required this.body,
required this.receivedAt,
this.read = false,
});
final String title;
final String body;
final DateTime receivedAt;
final bool read;
AppNotification copyWith({bool? read}) => AppNotification(
title: title,
body: body,
receivedAt: receivedAt,
read: read ?? this.read,
);
Map<String, dynamic> toJson() => {
't': title,
'b': body,
'at': receivedAt.toIso8601String(),
'r': read,
};
factory AppNotification.fromJson(Map<String, dynamic> json) =>
AppNotification(
title: json['t'] as String? ?? '',
body: json['b'] as String? ?? '',
receivedAt:
DateTime.tryParse(json['at']?.toString() ?? '') ?? DateTime.now(),
read: json['r'] as bool? ?? false,
);
}
/// صندوق الإشعارات — **محلّي بالكامل وعن قصد**.
///
/// الخادم يوفّر `POST /notifications/token` فقط ولا نقطة سرد
/// (`backend-archive/src/modules/notifications`, docs/38 §11). فبدل شاشة
/// فارغة تعتمد على نقطة غير موجودة، نحفظ ما يصل فعلاً عبر FCM بينما التطبيق
/// يعمل. سيرو يعرض `NotificationPage` في الدراور
/// (`map_menu_widget.dart:354`) — هذا مقابلها الصادق.
///
/// **حدّ معروف:** ما يصل والتطبيق مغلق تماماً لا يُلتقط — لا `background
/// handler` مسجَّلاً. حين تُضاف نقطة سرد على الخادم، يُستبدل هذا المصدر
/// بها ويبقى نفس عرض الشاشة.
class NotificationStore {
static const _key = 'notifications_inbox';
/// سقف الاحتفاظ — صندوق بلا حدّ يتضخّم بلا فائدة.
static const _max = 50;
List<AppNotification> _items = const [];
List<AppNotification> get items => _items;
int get unreadCount => _items.where((n) => !n.read).length;
Future<void> load() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(_key);
if (raw == null || raw.isEmpty) return;
try {
final list = jsonDecode(raw) as List<dynamic>;
_items = list
.whereType<Map<String, dynamic>>()
.map(AppNotification.fromJson)
.toList(growable: false);
} catch (_) {
// تخزين تالف لا يمنع فتح الشاشة — يُهمل ويُكتب فوقه عند أول إشعار.
_items = const [];
}
}
Future<void> add(AppNotification notification) async {
_items = [notification, ..._items].take(_max).toList(growable: false);
await _persist();
}
Future<void> markAllRead() async {
if (_items.every((n) => n.read)) return;
_items = _items.map((n) => n.copyWith(read: true)).toList(growable: false);
await _persist();
}
Future<void> clear() async {
_items = const [];
await _persist();
}
Future<void> _persist() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
_key,
jsonEncode(_items.map((n) => n.toJson()).toList(growable: false)),
);
}
}
@@ -4,20 +4,23 @@ import 'package:flutter/foundation.dart';
import '../api/api_client.dart';
import '../api/api_exception.dart';
import 'notification_store.dart';
/// إشعارات FCM — **الطبقة الوحيدة التي تعرف Firebase**.
///
/// عقد الخادم نقطة واحدة: `POST /notifications/token { token, platform }`
/// (docs/38 §11). لا نقطة سرد إشعارات، فلا شاشة «صندوق إشعارات» تُبنى على
/// وهم — ما يصل يصل من النظام.
/// (docs/38 §11). لا نقطة **سرد** إشعارات، فالصندوق محلّي: ما يصل أثناء عمل
/// التطبيق يُحفظ في [NotificationStore] ويُعرض في `NotificationsPage` —
/// انظر تعليق ذلك الصنف لحدود هذا الاختيار.
///
/// الإعداد الأصلي جاهز مسبقاً: `google-services.json` و
/// `GoogleService-Info.plist` منقولان مع الطبقة الأصلية، و`build.gradle`
/// يطبّق `com.google.gms.google-services` أصلاً.
class PushService {
PushService(this._api);
PushService(this._api, this._store);
final ApiClient _api;
final NotificationStore _store;
/// يُنادى **بعد** الدخول: التسجيل يربط الجهاز بالمستخدم، فقبل الجلسة
/// لا معنى له.
@@ -35,11 +38,30 @@ class PushService {
// الرمز يُدوَّر من Google دورياً — بلا هذا الاشتراك تتوقّف الإشعارات
// بصمت بعد أسابيع.
messaging.onTokenRefresh.listen(_send);
// ما يصل والتطبيق مفتوح، وما فُتح التطبيق بالضغط عليه.
FirebaseMessaging.onMessage.listen(_capture);
FirebaseMessaging.onMessageOpenedApp.listen(_capture);
final initial = await messaging.getInitialMessage();
if (initial != null) await _capture(initial);
} catch (_) {
// غياب الإشعارات لا يمنع استعمال التطبيق — لا يُسقط الإقلاع.
}
}
/// إشعار بلا عنوان ولا نصّ (رسالة بيانات صرفة) لا يُعرض — سطر فارغ في
/// الصندوق أسوأ من لا شيء.
Future<void> _capture(RemoteMessage message) async {
final title = message.notification?.title ?? '';
final body = message.notification?.body ?? '';
if (title.isEmpty && body.isEmpty) return;
await _store.add(AppNotification(
title: title,
body: body,
receivedAt: message.sentTime ?? DateTime.now(),
));
}
Future<void> _send(String token) async {
try {
await _api.post<Map<String, dynamic>>(
+6
View File
@@ -8,6 +8,7 @@ import '../features/chat/view/chat_page.dart';
import '../features/profile/view/profile_edit_page.dart';
import '../features/profile/view/profile_setup_page.dart';
import '../features/rating/data/rating_repository.dart';
import '../features/notifications/view/notifications_page.dart';
import '../features/rewards/view/rewards_page.dart';
import '../features/rating/view/rating_page.dart';
import '../features/settings/view/settings_page.dart';
@@ -34,6 +35,7 @@ class Routes {
static const support = '/support';
static const rewards = '/rewards';
static const chat = '/chat';
static const notifications = '/notifications';
}
/// التوجيه يُشتقّ من `SessionCubit` وحده — لا `Navigator.push` بعد الدخول
@@ -120,6 +122,10 @@ final appRouter = GoRouter(
path: Routes.support,
builder: (context, state) => const SupportPage(),
),
GoRoute(
path: Routes.notifications,
builder: (context, state) => const NotificationsPage(),
),
GoRoute(
path: Routes.home,
builder: (context, state) => const RidePage(),
+37
View File
@@ -0,0 +1,37 @@
import 'package:flutter/foundation.dart';
import 'build_config.dart';
import 'config.dart';
/// اختيار رابط المتجر الصحيح — **بالمنصّة والدور معاً**.
///
/// كان الخطأ (مثبت 2026-08-05): رابط واحد ثابت `https://tripz.app` لكل
/// شيء — فـ«قيّم التطبيق» لا يفتح صفحة تقييم، و«شارك التطبيق» يرسل رسالة
/// بلا رابط أصلاً، و«سجّل كسائق» يفتح صفحة تسويقية لا تطبيق السائق.
///
/// أربع حالات لا واحدة: {آيفون، أندرويد} × {راكب، سائق}. والدور يأتي من
/// [AppConfig.appRole] الذي يميّز البناءين أصلاً.
class StoreLinks {
const StoreLinks._();
static bool get _isIos => defaultTargetPlatform == TargetPlatform.iOS;
static bool get _isDriverBuild => AppConfig.appRole == 'driver';
/// رابط تطبيق الراكب على منصّة هذا الجهاز.
static String get rider =>
_isIos ? BuildConfig.riderIosUrl : BuildConfig.riderAndroidUrl;
/// رابط تطبيق السائق على منصّة هذا الجهاز.
static String get driver =>
_isIos ? BuildConfig.driverIosUrl : BuildConfig.driverAndroidUrl;
/// **هذا** التطبيق في متجر هذا الجهاز — لـ«قيّم التطبيق» ولرابط المشاركة.
///
/// السائق يشارك رابط السائق، والراكب رابط الراكب: مشاركةُ راكبٍ لرابط
/// السائق تُنزِّل المدعوَّ تطبيقاً خاطئاً.
static String get thisApp => _isDriverBuild ? driver : rider;
/// تطبيق الطرف الآخر — «سجّل كسائق» في تطبيق الراكب، والعكس.
static String get otherApp => _isDriverBuild ? rider : driver;
}
+55 -10
View File
@@ -8,10 +8,13 @@ import '../build_config.dart';
import '../config.dart';
import '../design/tokens.dart';
import '../design/tripz_colors.dart';
import '../di.dart';
import '../l10n/l10n.dart';
import '../notifications/notification_store.dart';
import '../router.dart';
import '../session/session_cubit.dart';
import '../session/session_state.dart';
import '../store_links.dart';
import '../tenant/feature_gate.dart';
/// الدراور — نقطة تطبيق **طبقتَي الميزات** معاً (docs/23 §5 + docs/38 §10):
@@ -43,6 +46,14 @@ class AppDrawer extends StatelessWidget {
label: l10n.menuProfile,
route: Routes.profileEdit,
),
_Item(
icon: Icons.notifications_none_rounded,
label: l10n.menuNotifications,
route: Routes.notifications,
// العدّاد يُقرأ عند بناء الدراور — والدراور يُبنى عند كل
// فتح، فلا حاجة لتيّار يراقب مخزناً في الذاكرة.
badge: sl<NotificationStore>().unreadCount,
),
_Item(
icon: Icons.route_outlined,
label: l10n.menuTrips,
@@ -72,28 +83,36 @@ class AppDrawer extends StatelessWidget {
route: Routes.support,
),
// مداخل محليّة بلا نقطة خادم — تُنفَّذ في الجهاز مباشرة.
//
// الروابط من `StoreLinks` لا من ثابتٍ واحد: هذا البناء
// يشارك **رابط نفسه** (سائق يشارك رابط السائق)، ويفتح
// متجر منصّته هو.
_Action(
icon: Icons.share_outlined,
label: l10n.menuShare,
onTap: () => SharePlus.instance
.share(ShareParams(text: l10n.shareMessage(''))),
onTap: () => SharePlus.instance.share(
ShareParams(
text: l10n.shareMessagePlain(StoreLinks.thisApp),
),
),
),
_Action(
icon: Icons.star_outline_rounded,
label: l10n.menuRateApp,
onTap: () => _open(BuildConfig.storeUrl),
onTap: () => _open(StoreLinks.thisApp),
),
_Action(
icon: Icons.shield_outlined,
label: l10n.menuPrivacy,
onTap: () => _open(BuildConfig.privacyUrl),
),
if (!isDriver)
_Action(
icon: Icons.local_taxi_outlined,
label: l10n.menuDriverApp,
onTap: () => _open(BuildConfig.driverAppUrl),
),
_Action(
icon: isDriver
? Icons.person_outline_rounded
: Icons.local_taxi_outlined,
label: isDriver ? l10n.menuRiderApp : l10n.menuDriverApp,
onTap: () => _open(StoreLinks.otherApp),
),
],
),
),
@@ -220,17 +239,43 @@ class _Action extends StatelessWidget {
}
class _Item extends StatelessWidget {
const _Item({required this.icon, required this.label, required this.route});
const _Item({
required this.icon,
required this.label,
required this.route,
this.badge = 0,
});
final IconData icon;
final String label;
final String route;
/// عدّاد غير المقروء — يُخفى عند الصفر.
final int badge;
@override
Widget build(BuildContext context) {
return ListTile(
leading: Icon(icon),
title: Text(label),
trailing: badge == 0
? null
: Container(
padding: const EdgeInsets.symmetric(
horizontal: Space.xs,
vertical: Space.xxs / 2,
),
decoration: BoxDecoration(
color: context.tripzColors.danger,
borderRadius: Radii.field_,
),
child: Text(
'$badge',
style: context.texts.labelSmall?.copyWith(
color: context.colors.onPrimary,
),
),
),
onTap: () {
Navigator.of(context).pop();
context.push(route);