feat(apps): الخريطة وطلب الرحلة ودورة السائق — المرحلة 4

## حسم تعارض الخرائط (docs/38 §7)
لصالح القرار الأحدث 2026-07-20: كل الخرائط — البلاطات والبحث والعكسي
والمسار — مباشرة إلى map-saas بترويسة x-api-key، بلا مرور بباك إند تريبز.
نقاط /maps/* في الباك إند لا تُستعمل، وإحداها (geocode) معطوبة على المنشور.
العقد تُحقّق حيّاً بـcurl قبل كتابة سطر.

## النواة المشتركة
- core/api/antlaq_api.dart: عميل منفصل بلا AuthInterceptor — توكن تريبز لا
  شأن لخادم الخرائط به، وإرساله إليه تسريب بلا مقابل
- core/realtime: Socket.IO واحد (trip:update · driver:location · trip:offer
  · trip:offer_taken)
- core/location: نقطة الموقع الوحيدة بمرشّح 25 متراً
- features/trip/data: سبعة نماذج + MapsRepository + TripRepository متطابق
  حرفياً بين التوأمين

## الراكب
RideCubit بستّ مراحل + RidePage وثمانية ويدجت: الخريطة · دبّوس المنتصف ·
المخطّط · الخط الزمني · اختيار النوع · التأكيد · البحث عن سائق · الرحلة.

## السائق
DutyCubit بأربع مراحل + DutyPage: مفتاح الاتصال · العرض بمؤقّت · الرحلة.

## قرارات تحمي الأداء والصحّة
- GeoPoint مستقلّ عن حزمة الخريطة؛ intaleq_maps مستوردة في ملف واحد فقط
  لكل تطبيق. تبديل محرّك الخريطة لا يلمس منطقاً
- الخريطة خارج BlocBuilder الورقة ولها BlocSelector خاص: نبضة موقع كل 25م
  لا تعيد بناء الشاشة
- مهلة بحث محليّة 90ث + استطلاع كل 5ث: الخادم قد لا يُطلق expired/no_drivers
  أبداً (ثغرة R1)، وانتظار حدث قد لا يصل = شاشة بحث أبدية
- offeredDrivers == 0 جواب نهائي فوري بلا انتظار المهلة
- الأجرة لا تُعرض إلا إن كانت صالحة: /tariff/quote يرجّع 200 بقيم null صامتة
- موقع السائق بمسارين عمداً: REST لمطابقة Redis والسوكت لخريطة الراكب

flutter analyze نظيف · الراكب 70 ملف/5,294 سطر · السائق 67 ملف/4,771 سطر.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-08-04 23:33:56 +03:00
co-authored by Claude Opus 5
parent c52338f3bb
commit 1156299d09
59 changed files with 4652 additions and 181 deletions
@@ -0,0 +1,34 @@
import 'package:dio/dio.dart';
import '../config.dart';
import 'api_exception.dart';
/// عميل map-saas — **منفصل تماماً** عن `ApiClient`.
///
/// عمداً بلا `AuthInterceptor`: توكن تريبز لا شأن لخادم الخرائط، وإرساله
/// إليه تسريبٌ بلا مقابل. المصادقة هنا ترويسة `x-api-key` وحدها.
class AntlaqApi {
AntlaqApi._(this._dio);
final Dio _dio;
factory AntlaqApi.create() {
return AntlaqApi._(Dio(BaseOptions(
baseUrl: AppConfig.mapBaseUrl,
connectTimeout: AppConfig.connectTimeout,
receiveTimeout: AppConfig.receiveTimeout,
// المفتاح ترويسةً لا استعلاماً: map-saas يرفض `api_key` في الـquery
// بـ400 `property api_key should not exist`.
headers: {'x-api-key': AppConfig.mapApiKey},
)));
}
Future<T> get<T>(String path, {Map<String, dynamic>? query}) async {
try {
final res = await _dio.get<T>(path, queryParameters: query);
return res.data as T;
} on DioException catch (e) {
throw ApiException.from(e);
}
}
}
+29
View File
@@ -21,6 +21,35 @@ class AppConfig {
/// (docs/38 §1). **يُثبَّت عند البناء ولا يتغيّر في وقت التشغيل أبداً.**
static const String appRole = 'rider';
// ── الخرائط ──────────────────────────────────────────────────────────
// قرار المالك 2026-07-20: كل الخرائط — البلاطات والجيوكودنغ والمسار —
// **مباشرة** إلى map-saas بلا مرور بباك إند تريبز. map-saas مِلك المالك،
// والمفتاح يُقيَّد ببصمة التطبيق (نفس نموذج Google Maps SDK). هذا يُلغي
// نقاط `/maps/*` في باك إند تريبز، وهو ما حسم التعارض المسجّل في
// docs/38 §7 — ومنها `/maps/geocode` المعطوب على الخادم المنشور.
static const String mapBaseUrl = String.fromEnvironment(
'MAP_BASE_URL',
defaultValue: 'https://map-saas.intaleqapp.com/api',
);
/// المفتاح ترويسة **`x-api-key`** لا معامل استعلام — map-saas يرفض
/// المفتاح في الـquery بـ400. الحالي مفتاح سيرو حتى يُصدر المالك مفتاح
/// تريبز المقيّد بالبصمة.
static const String mapApiKey = String.fromEnvironment(
'MAP_API_KEY',
defaultValue: 'in_9478b32836d19cff73db3063',
);
/// كل كم متر يُرفع موقع السائق. أقل من ذلك يستنزف البطارية بلا فائدة.
static const int locationFilterMeters = 25;
/// مهلة البحث عن سائق قبل عرض «لا يوجد سائقون».
///
/// **إلزامية**: الخادم قد لا يُطلق `expired`/`no_drivers` أبداً (ثغرة R1،
/// docs/38 §4)، فانتظار حدثٍ قد لا يصل يعني شاشة بحث أبدية.
static const Duration searchingTimeout = Duration(seconds: 90);
/// طول رمز التحقّق كما يولّده الخادم (docs/38 §2).
static const int otpLength = 4;
+20
View File
@@ -7,7 +7,13 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../features/auth/cubit/login_cubit.dart';
import '../features/auth/data/auth_repository.dart';
import '../features/settings/cubit/settings_cubit.dart';
import '../features/trip/cubit/ride_cubit.dart';
import '../features/trip/data/maps_repository.dart';
import '../features/trip/data/trip_repository.dart';
import 'api/antlaq_api.dart';
import 'api/api_client.dart';
import 'location/location_service.dart';
import 'realtime/realtime_service.dart';
import 'session/session_cubit.dart';
import 'storage/token_store.dart';
@@ -44,6 +50,20 @@ Future<void> setupInjector() async {
// تدفّق الدخول قصير العمر: نسخة جديدة لكل دخول، لا نسخة واحدة أبدية.
sl.registerFactory<LoginCubit>(() => LoginCubit(sl(), sl()));
// الخرائط عميل منفصل بلا توكن تريبز — map-saas لا شأن له بجلستنا.
sl.registerSingleton<AntlaqApi>(AntlaqApi.create());
sl.registerSingleton<MapsRepository>(MapsRepository(sl()));
sl.registerSingleton<TripRepository>(TripRepository(sl()));
sl.registerSingleton<LocationService>(LocationService());
sl.registerSingleton<RealtimeService>(RealtimeService(tokens));
sl.registerFactory<RideCubit>(() => RideCubit(
trips: sl(),
maps: sl(),
location: sl(),
realtime: sl(),
));
}
/// بصمة الجهاز (`x-device-id`) — يفرضها الخادم عند تفعيل
+75 -15
View File
@@ -1,32 +1,25 @@
{
"@@locale": "ar",
"appTitle": "Tripz",
"actionRetry": "أعد المحاولة",
"actionCancel": "إلغاء",
"actionContinue": "متابعة",
"actionSave": "حفظ",
"actionOpenSettings": "افتح الإعدادات",
"errorGeneric": "حدث خطأ غير متوقّع",
"errorNetwork": "تعذّر الاتصال بالخادم",
"errorTimeout": "انتهت مهلة الاتصال",
"emptyDefault": "لا يوجد شيء هنا بعد",
"splashRider": "تطبيق الراكب",
"splashDriver": "تطبيق السائق",
"agreementTitle": "شروط الاستخدام",
"agreementLead": "قبل أن نبدأ، اقرأ الشروط ووافق عليها.",
"agreementCheckbox": "قرأت الشروط وأوافق عليها",
"agreementAccept": "أوافق وأتابع",
"permissionTitle": "نحتاج موقعك",
"permissionLead": "الموقع هو ما يجعل الرحلة ممكنة: به نعرف أين أنت وأين السائق.",
"permissionAllow": "السماح بالوصول للموقع",
"permissionDeniedForever": "رفضتَ الإذن نهائياً. افتح الإعدادات وفعّله يدوياً ثم عُد.",
"phoneTitle": "أهلاً بك في Tripz",
"phoneLead": "أدخل رقم هاتفك، ونرسل لك رمز تحقّق.",
"phoneLabel": "رقم الهاتف",
@@ -35,27 +28,35 @@
"phoneErrEmpty": "أدخل رقم هاتفك",
"phoneErrLeadingZero": "أدخل الرقم بلا الصفر في البداية",
"phoneErrShort": "الرقم قصير جداً",
"otpTitle": "أدخل رمز التحقّق",
"otpLead": "أرسلنا رمزاً من أربع خانات إلى {phone}",
"@otpLead": { "placeholders": { "phone": { "type": "String" } } },
"@otpLead": {
"placeholders": {
"phone": {
"type": "String"
}
}
},
"otpVerify": "تحقّق وتابع",
"otpResend": "إعادة إرسال الرمز",
"otpResendIn": "إعادة الإرسال بعد {seconds} ثانية",
"@otpResendIn": { "placeholders": { "seconds": { "type": "int" } } },
"@otpResendIn": {
"placeholders": {
"seconds": {
"type": "int"
}
}
},
"otpErrIncomplete": "أدخل الرمز كاملاً",
"otpErrInvalid": "الرمز غير صحيح أو انتهت صلاحيته",
"otpErrTooManyAttempts": "محاولات كثيرة. اطلب رمزاً جديداً.",
"otpErrRateLimited": "طلبتَ الرمز مرات كثيرة. انتظر قليلاً ثم أعد المحاولة.",
"otpChangeNumber": "تعديل الرقم",
"profileTitle": "أكمل ملفك",
"profileLead": "اسمك يظهر للسائق عند الرحلة.",
"profileNameLabel": "الاسم",
"profileNameErrEmpty": "أدخل اسمك",
"sessionExpired": "انتهت جلستك. سجّل الدخول من جديد.",
"settingsTitle": "الإعدادات",
"settingsTheme": "المظهر",
"settingsThemeSystem": "حسب النظام",
@@ -63,5 +64,64 @@
"settingsThemeDark": "داكن",
"settingsLanguage": "اللغة",
"settingsLanguageArabic": "العربية",
"settingsLanguageEnglish": "English"
}
"settingsLanguageEnglish": "English",
"rideWhereTo": "إلى أين؟",
"rideOrigin": "من",
"rideDestination": "إلى",
"rideSearchHint": "ابحث عن مكان",
"rideAddStop": "إضافة محطة",
"rideStop": "محطة {n}",
"@rideStop": {
"placeholders": {
"n": {
"type": "int"
}
}
},
"ridePickOnMap": "تحديد من الخريطة",
"rideConfirmPoint": "تأكيد هذه النقطة",
"rideConfirmTrip": "اطلب الرحلة",
"rideSearchingTitle": "نبحث لك عن سائق",
"rideSearchingLead": "عادةً أقل من دقيقة",
"rideCancelSearch": "إلغاء البحث",
"rideNoDrivers": "لا يوجد سائقون متاحون الآن. جرّب بعد قليل.",
"rideQuoteFailed": "تعذّر حساب الأجرة",
"rideRouteFailed": "تعذّر حساب المسار",
"rideRequestFailed": "تعذّر إرسال الطلب",
"rideSurge": "الطلب مرتفع الآن",
"rideCancelTrip": "إلغاء الرحلة",
"rideEta": "{min} دقيقة",
"@rideEta": {
"placeholders": {
"min": {
"type": "String"
}
}
},
"rideDistanceKm": "{km} كم",
"@rideDistanceKm": {
"placeholders": {
"km": {
"type": "String"
}
}
},
"tripAssigned": "السائق في الطريق إليك",
"tripDriverArriving": "السائق يقترب",
"tripDriverArrived": "السائق وصل وينتظرك",
"tripInProgress": "في الطريق إلى وجهتك",
"tripCompleted": "وصلت — رحلة موفّقة",
"driverGoOnline": "ابدأ الاستقبال",
"driverGoOffline": "إيقاف الاستقبال",
"driverOnline": "متصل",
"driverOffline": "غير متصل",
"driverNoOffers": "لا طلبات الآن. ابقَ متصلاً.",
"driverOfferTitle": "طلب رحلة جديد",
"driverAccept": "قبول",
"driverReject": "تجاهل",
"driverOfferTaken": "سبقك سائق آخر لهذا الطلب",
"driverArrivedAction": "وصلت لموقع الراكب",
"driverStartTrip": "بدء الرحلة",
"driverEndTrip": "إنهاء الرحلة",
"driverOnTheWay": "في الطريق للراكب"
}
+75 -15
View File
@@ -1,32 +1,25 @@
{
"@@locale": "en",
"appTitle": "Tripz",
"actionRetry": "Try again",
"actionCancel": "Cancel",
"actionContinue": "Continue",
"actionSave": "Save",
"actionOpenSettings": "Open settings",
"errorGeneric": "Something went wrong",
"errorNetwork": "Could not reach the server",
"errorTimeout": "The connection timed out",
"emptyDefault": "Nothing here yet",
"splashRider": "Rider app",
"splashDriver": "Driver app",
"agreementTitle": "Terms of use",
"agreementLead": "Before we start, read the terms and accept them.",
"agreementCheckbox": "I have read and accept the terms",
"agreementAccept": "Accept and continue",
"permissionTitle": "We need your location",
"permissionLead": "Location is what makes a trip possible: it tells us where you are and where your driver is.",
"permissionAllow": "Allow location access",
"permissionDeniedForever": "You denied the permission permanently. Open settings, enable it, then come back.",
"phoneTitle": "Welcome to Tripz",
"phoneLead": "Enter your phone number and we'll send you a verification code.",
"phoneLabel": "Phone number",
@@ -35,27 +28,35 @@
"phoneErrEmpty": "Enter your phone number",
"phoneErrLeadingZero": "Enter the number without the leading zero",
"phoneErrShort": "That number is too short",
"otpTitle": "Enter your code",
"otpLead": "We sent a 4-digit code to {phone}",
"@otpLead": { "placeholders": { "phone": { "type": "String" } } },
"@otpLead": {
"placeholders": {
"phone": {
"type": "String"
}
}
},
"otpVerify": "Verify and continue",
"otpResend": "Resend the code",
"otpResendIn": "Resend in {seconds}s",
"@otpResendIn": { "placeholders": { "seconds": { "type": "int" } } },
"@otpResendIn": {
"placeholders": {
"seconds": {
"type": "int"
}
}
},
"otpErrIncomplete": "Enter the full code",
"otpErrInvalid": "That code is wrong or expired",
"otpErrTooManyAttempts": "Too many attempts. Request a new code.",
"otpErrRateLimited": "You requested the code too many times. Wait a moment and try again.",
"otpChangeNumber": "Change number",
"profileTitle": "Complete your profile",
"profileLead": "Your name is shown to the driver during a trip.",
"profileNameLabel": "Name",
"profileNameErrEmpty": "Enter your name",
"sessionExpired": "Your session ended. Please sign in again.",
"settingsTitle": "Settings",
"settingsTheme": "Appearance",
"settingsThemeSystem": "Follow system",
@@ -63,5 +64,64 @@
"settingsThemeDark": "Dark",
"settingsLanguage": "Language",
"settingsLanguageArabic": "العربية",
"settingsLanguageEnglish": "English"
}
"settingsLanguageEnglish": "English",
"rideWhereTo": "Where to?",
"rideOrigin": "From",
"rideDestination": "To",
"rideSearchHint": "Search for a place",
"rideAddStop": "Add a stop",
"rideStop": "Stop {n}",
"@rideStop": {
"placeholders": {
"n": {
"type": "int"
}
}
},
"ridePickOnMap": "Pick on the map",
"rideConfirmPoint": "Confirm this point",
"rideConfirmTrip": "Request ride",
"rideSearchingTitle": "Finding you a driver",
"rideSearchingLead": "Usually under a minute",
"rideCancelSearch": "Cancel search",
"rideNoDrivers": "No drivers available right now. Try again shortly.",
"rideQuoteFailed": "Could not price this trip",
"rideRouteFailed": "Could not build the route",
"rideRequestFailed": "Could not send the request",
"rideSurge": "Demand is high right now",
"rideCancelTrip": "Cancel ride",
"rideEta": "{min} min",
"@rideEta": {
"placeholders": {
"min": {
"type": "String"
}
}
},
"rideDistanceKm": "{km} km",
"@rideDistanceKm": {
"placeholders": {
"km": {
"type": "String"
}
}
},
"tripAssigned": "Your driver is on the way",
"tripDriverArriving": "Your driver is close",
"tripDriverArrived": "Your driver is waiting",
"tripInProgress": "On the way to your destination",
"tripCompleted": "You've arrived",
"driverGoOnline": "Go online",
"driverGoOffline": "Go offline",
"driverOnline": "Online",
"driverOffline": "Offline",
"driverNoOffers": "No requests yet. Stay online.",
"driverOfferTitle": "New ride request",
"driverAccept": "Accept",
"driverReject": "Dismiss",
"driverOfferTaken": "Another driver took this request",
"driverArrivedAction": "I've arrived",
"driverStartTrip": "Start trip",
"driverEndTrip": "End trip",
"driverOnTheWay": "Heading to rider"
}
@@ -0,0 +1,46 @@
import 'dart:async';
import 'package:geolocator/geolocator.dart';
import '../config.dart';
/// الموقع — نقطة واحدة. لا `Geolocator` منثوراً في الشاشات.
class LocationService {
StreamSubscription<Position>? _sub;
Future<({double lat, double lng})?> current() async {
try {
final p = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
timeLimit: Duration(seconds: 12),
),
);
return (lat: p.latitude, lng: p.longitude);
} catch (_) {
// آخر موقع معروف أفضل من لا شيء: خريطة تفتح على الصفر تجربة مكسورة.
final last = await Geolocator.getLastKnownPosition();
return last == null ? null : (lat: last.latitude, lng: last.longitude);
}
}
/// تدفّق المواقع بمرشّح مسافة — رفع كل متر يستنزف البطارية بلا فائدة.
Stream<Position> watch() {
return Geolocator.getPositionStream(
locationSettings: LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: AppConfig.locationFilterMeters,
),
);
}
void listen(void Function(Position) onPosition) {
_sub?.cancel();
_sub = watch().listen(onPosition);
}
Future<void> stop() async {
await _sub?.cancel();
_sub = null;
}
}
@@ -0,0 +1,94 @@
import 'dart:async';
import 'package:socket_io_client/socket_io_client.dart' as io;
import '../config.dart';
import '../storage/token_store.dart';
/// أحداث الخادم كما هي في `backend-archive/src/realtime` (docs/38 §9).
class RealtimeEvent {
const RealtimeEvent(this.name, this.data);
final String name;
final Map<String, dynamic> data;
static const tripUpdate = 'trip:update';
static const driverLocation = 'driver:location';
static const tripOffer = 'trip:offer';
static const tripOfferTaken = 'trip:offer_taken';
}
/// اتصال Socket.IO واحد للتطبيق كله.
///
/// **الاتصال ليس مصدر الحقيقة**: كل ما يصل عبره يجب أن يكون قابلاً للاستنتاج
/// من REST أيضاً. حدثٌ ضائع (شبكة سيّئة، أو ثغرة R1 التي قد تمنع
/// `expired`/`no_drivers` أصلاً) يجب ألّا يُجمّد شاشة.
class RealtimeService {
RealtimeService(this._tokens);
final TokenStore _tokens;
io.Socket? _socket;
final _events = StreamController<RealtimeEvent>.broadcast();
Stream<RealtimeEvent> get events => _events.stream;
bool get isConnected => _socket?.connected ?? false;
Future<void> connect() async {
if (_socket != null) return;
final token = _tokens.accessToken;
if (token == null) return;
// الأصل بلا `/api` — الـSocket.IO ليس على مسار الـREST (docs/38 §9).
final socket = io.io(
AppConfig.wsBaseUrl,
io.OptionBuilder()
.setTransports(['websocket'])
.setAuth({'token': token})
.enableReconnection()
.build(),
);
for (final name in const [
RealtimeEvent.tripUpdate,
RealtimeEvent.driverLocation,
RealtimeEvent.tripOffer,
RealtimeEvent.tripOfferTaken,
]) {
socket.on(name, (data) {
if (data is Map) {
_events.add(RealtimeEvent(name, Map<String, dynamic>.from(data)));
}
});
}
_socket = socket;
}
/// الانضمام **قبل** أي انتقال حالة، وإلا فاتت الأحداث (docs/38 §9).
void joinTrip(String tripId) => _socket?.emit('trip:join', {'tripId': tripId});
void sendDriverLocation(double lat, double lng, {double? heading}) {
_socket?.emit(RealtimeEvent.driverLocation, {
'lat': lat,
'lng': lng,
'heading': ?heading,
});
}
/// عند تجديد التوكن أو تبديل المستخدم: قطعٌ وإعادة اتصال بالتوكن الجديد.
Future<void> reconnect() async {
disconnect();
await connect();
}
void disconnect() {
_socket?.dispose();
_socket = null;
}
Future<void> dispose() async {
disconnect();
await _events.close();
}
}
+2 -2
View File
@@ -7,7 +7,7 @@ import 'session/session_cubit.dart';
import 'session/session_state.dart';
import '../features/auth/view/login_page.dart';
import '../features/auth/view/profile_page.dart';
import '../features/home/view/home_page.dart';
import '../features/trip/view/ride_page.dart';
import '../features/splash/view/splash_page.dart';
import 'di.dart';
@@ -57,7 +57,7 @@ final appRouter = GoRouter(
),
GoRoute(
path: Routes.home,
builder: (context, state) => const HomePage(),
builder: (context, state) => const RidePage(),
),
],
);