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,52 @@
import '../../../core/api/antlaq_api.dart';
import 'models/geo_point.dart';
import 'models/place.dart';
import 'models/route_info.dart';
/// كل الخرائط من map-saas مباشرة (قرار 2026-07-20). نقاط `/maps/*` في باك إند
/// تريبز **لا تُستعمل** — إحداها (`geocode`) معطوبة على المنشور أصلاً.
class MapsRepository {
MapsRepository(this._api);
final AntlaqApi _api;
Future<List<Place>> search(String query, {GeoPoint? near}) async {
if (query.trim().isEmpty) return const [];
final res = await _api.get<List<dynamic>>(
'/geocoding/search',
query: {
'q': query.trim(),
if (near != null) 'lat': near.lat,
if (near != null) 'lng': near.lng,
},
);
return res
.whereType<Map<String, dynamic>>()
.map(Place.fromJson)
.toList(growable: false);
}
/// يرجّع **مصفوفة** مرتّبة بالأقرب — الأول هو المطلوب. تفكيكها ككائن مفرد
/// كان باگ سيرو الذي أُصلح 2026-07-20.
Future<Place?> reverse(GeoPoint point) async {
final res = await _api.get<List<dynamic>>(
'/geocoding/reverse',
query: {'lat': point.lat, 'lng': point.lng},
);
final first = res.whereType<Map<String, dynamic>>().firstOrNull;
return first == null ? null : Place.fromJson(first);
}
Future<RouteInfo> route(GeoPoint from, GeoPoint to) async {
final res = await _api.get<Map<String, dynamic>>(
'/maps/route',
query: {
'fromLat': from.lat,
'fromLng': from.lng,
'toLat': to.lat,
'toLng': to.lng,
},
);
return RouteInfo.fromJson(res);
}
}
@@ -0,0 +1,42 @@
import 'package:equatable/equatable.dart';
/// تسعيرة مسبقة من `POST /tariff/quote`.
///
/// ⚠️ مصيدتان مثبتتان (docs/38 §7):
/// 1. هذه النقطة **camelCase وحدها** بخلاف كل الـAPI.
/// 2. بالمعاملات الخطأ ترجّع **200 بقيم `null` صامتة** لا خطأً — لذلك
/// [isUsable] شرطٌ قبل عرض أي رقم للمستخدم.
class FareQuote extends Equatable {
const FareQuote({
required this.total,
required this.currency,
this.subtotal,
this.surgeMultiplier = 1,
this.window = '',
});
final double? total;
final String currency;
final double? subtotal;
final double surgeMultiplier;
final String window;
factory FareQuote.fromJson(Map<String, dynamic> json) {
final quote = (json['quote'] as Map<String, dynamic>?) ?? const {};
return FareQuote(
total: (quote['total'] as num?)?.toDouble(),
currency: quote['currency'] as String? ?? '',
subtotal: (quote['subtotal'] as num?)?.toDouble(),
surgeMultiplier: (quote['surgeMultiplier'] as num?)?.toDouble() ?? 1,
window: quote['window'] as String? ?? '',
);
}
bool get isUsable => total != null && total! > 0;
/// تسعير مرتفع — يُعرض للمستخدم صراحةً قبل التأكيد لا بعده.
bool get hasSurge => surgeMultiplier > 1;
@override
List<Object?> get props => [total, currency, subtotal, surgeMultiplier];
}
@@ -0,0 +1,22 @@
import 'package:equatable/equatable.dart';
/// إحداثية في طبقة النطاق — **مستقلة عن حزمة الخريطة** عمداً.
///
/// الكيوبت والمستودع لا يعرفان `intaleq_maps`؛ التحويل يقع في الواجهة وحدها.
/// تغيير محرّك الخريطة لاحقاً لا يلمس منطقاً.
class GeoPoint extends Equatable {
const GeoPoint(this.lat, this.lng);
final double lat;
final double lng;
factory GeoPoint.fromJson(Map<String, dynamic> json) => GeoPoint(
(json['lat'] as num).toDouble(),
(json['lng'] as num).toDouble(),
);
Map<String, dynamic> toJson() => {'lat': lat, 'lng': lng};
@override
List<Object?> get props => [lat, lng];
}
@@ -0,0 +1,43 @@
import 'package:equatable/equatable.dart';
import 'geo_point.dart';
/// مكان من map-saas. حقول الاسم `name`/`name_ar`/`address` — **لا
/// `formattedAddress`** (عقد مثبت بـcurl، راجع قرار الخرائط 2026-07-20).
class Place extends Equatable {
const Place({
required this.id,
required this.name,
required this.point,
this.address = '',
this.category = '',
});
final String id;
final String name;
final GeoPoint point;
final String address;
final String category;
factory Place.fromJson(Map<String, dynamic> json) {
return Place(
id: json['id']?.toString() ?? '',
// العربي أولاً — الواجهة عربية افتراضاً، والإنجليزي احتياط.
name: (json['name_ar'] as String?)?.trim().isNotEmpty == true
? json['name_ar'] as String
: (json['name'] as String? ?? ''),
address: json['address'] as String? ?? '',
category: json['category'] as String? ?? '',
point: GeoPoint(
(json['latitude'] as num).toDouble(),
(json['longitude'] as num).toDouble(),
),
);
}
/// ما يُعرض في حقل العنوان: الاسم، وإن غاب فالعنوان.
String get label => name.isNotEmpty ? name : address;
@override
List<Object?> get props => [id, name, point, address];
}
@@ -0,0 +1,47 @@
import 'package:equatable/equatable.dart';
/// نوع الرحلة كما يرجّعه `GET /ride-types` (شكل مُتحقَّق حيّاً، docs/38 §7).
/// **شاشة الاختيار تُبنى من هنا** — لا قائمة مكتوبة في التطبيق.
class RideType extends Equatable {
const RideType({
required this.id,
required this.code,
required this.nameAr,
required this.nameEn,
required this.vehicleKind,
required this.womenOnly,
required this.roundTripSupported,
required this.sort,
this.icon,
});
final String id;
final String code;
final String nameAr;
final String nameEn;
final String vehicleKind;
final bool womenOnly;
final bool roundTripSupported;
final int sort;
final String? icon;
factory RideType.fromJson(Map<String, dynamic> json) {
return RideType(
id: json['id'] as String,
code: json['code'] as String,
nameAr: json['name_ar'] as String? ?? '',
nameEn: json['name_en'] as String? ?? '',
vehicleKind: json['vehicle_kind'] as String? ?? 'car',
womenOnly: json['women_only'] as bool? ?? false,
roundTripSupported: json['round_trip_supported'] as bool? ?? false,
sort: (json['sort'] as num?)?.toInt() ?? 0,
icon: json['icon'] as String?,
);
}
String nameFor(String languageCode) =>
languageCode == 'ar' ? nameAr : (nameEn.isEmpty ? nameAr : nameEn);
@override
List<Object?> get props => [id, code, sort];
}
@@ -0,0 +1,33 @@
import 'package:equatable/equatable.dart';
/// مسار من map-saas. المسار نفسه **encoded polyline** في حقل `points`،
/// ويُفكّ في الواجهة لا هنا (طبقة النطاق لا تعرف حزمة الخريطة).
class RouteInfo extends Equatable {
const RouteInfo({
required this.distanceMeters,
required this.durationSeconds,
required this.encodedPoints,
this.name = '',
});
final double distanceMeters;
final int durationSeconds;
final String encodedPoints;
final String name;
factory RouteInfo.fromJson(Map<String, dynamic> json) {
return RouteInfo(
distanceMeters: (json['distance'] as num?)?.toDouble() ?? 0,
durationSeconds: (json['duration'] as num?)?.round() ?? 0,
encodedPoints: json['points'] as String? ?? '',
name: json['routeName'] as String? ?? '',
);
}
/// التعرفة تُطلب بالكيلومترات والدقائق (docs/38 §7).
double get distanceKm => distanceMeters / 1000;
double get durationMin => durationSeconds / 60;
@override
List<Object?> get props => [distanceMeters, durationSeconds, encodedPoints];
}
@@ -0,0 +1,125 @@
import 'package:equatable/equatable.dart';
import 'geo_point.dart';
/// حالات الرحلة كما يعرّفها الخادم (docs/38 §4).
enum TripStatus {
searching,
assigned,
driverArriving,
driverArrived,
inProgress,
completed,
paid,
cancelled,
expired,
noDrivers,
unknown;
static TripStatus parse(String? raw) => switch (raw) {
'searching' => TripStatus.searching,
'assigned' => TripStatus.assigned,
'driver_arriving' => TripStatus.driverArriving,
'driver_arrived' => TripStatus.driverArrived,
'in_progress' => TripStatus.inProgress,
'completed' => TripStatus.completed,
'paid' => TripStatus.paid,
'cancelled' => TripStatus.cancelled,
'expired' => TripStatus.expired,
'no_drivers' => TripStatus.noDrivers,
_ => TripStatus.unknown,
};
String get wire => switch (this) {
TripStatus.driverArriving => 'driver_arriving',
TripStatus.driverArrived => 'driver_arrived',
TripStatus.inProgress => 'in_progress',
TripStatus.noDrivers => 'no_drivers',
_ => name,
};
/// رحلة جارية تُستأنف عند فتح التطبيق.
bool get isLive => const {
TripStatus.searching,
TripStatus.assigned,
TripStatus.driverArriving,
TripStatus.driverArrived,
TripStatus.inProgress,
}.contains(this);
bool get isOver => const {
TripStatus.completed,
TripStatus.paid,
TripStatus.cancelled,
TripStatus.expired,
TripStatus.noDrivers,
}.contains(this);
}
class Trip extends Equatable {
const Trip({
required this.id,
required this.status,
this.origin,
this.destination,
this.quotedFare,
this.priceForPassenger,
this.priceForDriver,
this.currency = '',
this.driverName,
this.driverPhone,
this.vehiclePlate,
this.vehicleModel,
this.driverRating,
});
final String id;
final TripStatus status;
final GeoPoint? origin;
final GeoPoint? destination;
/// أجرة مقفولة عند الطلب.
final String? quotedFare;
/// **حقلان منفصلان** لا حقل واحد — الفرق هو العمولة (docs/38 §4).
final String? priceForPassenger;
final String? priceForDriver;
final String currency;
final String? driverName;
final String? driverPhone;
final String? vehiclePlate;
final String? vehicleModel;
final String? driverRating;
factory Trip.fromJson(Map<String, dynamic> json) {
GeoPoint? point(String key) {
final v = json[key];
return v is Map<String, dynamic> ? GeoPoint.fromJson(v) : null;
}
final driver = json['driver'] as Map<String, dynamic>?;
return Trip(
id: json['id'] as String,
status: TripStatus.parse(json['status'] as String?),
origin: point('origin'),
destination: point('destination'),
// كل المبالغ نصوص (docs/38 §12.2) — تُحفظ كما وصلت ولا تُحوَّل هنا.
quotedFare: json['quoted_fare']?.toString(),
priceForPassenger: json['price_for_passenger']?.toString(),
priceForDriver: json['price_for_driver']?.toString(),
currency: json['currency'] as String? ?? '',
driverName: driver?['name'] as String? ?? json['driver_name'] as String?,
driverPhone:
driver?['phone'] as String? ?? json['driver_phone'] as String?,
vehiclePlate:
driver?['vehicle_plate'] as String? ?? json['vehicle_plate'] as String?,
vehicleModel:
driver?['vehicle_model'] as String? ?? json['vehicle_model'] as String?,
driverRating: driver?['rating']?.toString(),
);
}
@override
List<Object?> get props => [id, status, priceForPassenger, driverName];
}
@@ -0,0 +1,128 @@
import '../../../core/api/api_client.dart';
import 'models/fare_quote.dart';
import 'models/geo_point.dart';
import 'models/ride_type.dart';
import 'models/trip.dart';
class TripRepository {
TripRepository(this._api);
final ApiClient _api;
Future<List<RideType>> rideTypes() async {
final res = await _api.get<List<dynamic>>('/ride-types');
return res
.whereType<Map<String, dynamic>>()
.map(RideType.fromJson)
.where((t) => true)
.toList(growable: false)
..sort((a, b) => a.sort.compareTo(b.sort));
}
/// ⚠️ camelCase وحدها في كل الـAPI، وتحتاج **مسافة ومدة محسوبتين مسبقاً**
/// من `/maps/route` — إرسال الإحداثيات يرجّع 200 بقيم `null` صامتة
/// (docs/38 §7).
Future<FareQuote> quote({
required String city,
required String serviceClass,
required double distanceKm,
required double durationMin,
}) async {
final res = await _api.post<Map<String, dynamic>>(
'/tariff/quote',
body: {
'city': city,
'serviceClass': serviceClass,
'distanceKm': distanceKm,
'durationMin': durationMin,
},
);
return FareQuote.fromJson(res);
}
/// يرجّع الرحلة وعدد السائقين الذين عُرضت عليهم — الصفر يعني «لا سائقين
/// قريبين» فوراً، بلا انتظار مهلة.
Future<({Trip trip, int offeredDrivers})> request({
required GeoPoint origin,
required GeoPoint destination,
required String serviceClass,
String paymentMethod = 'wallet',
List<GeoPoint> stops = const [],
String? couponCode,
bool isRoundTrip = false,
}) async {
final res = await _api.post<Map<String, dynamic>>('/trips', body: {
'origin': origin.toJson(),
'destination': destination.toJson(),
'service_class': serviceClass,
'payment_method': paymentMethod,
'is_round_trip': isRoundTrip,
if (stops.isNotEmpty)
'stops': stops.map((s) => s.toJson()).toList(growable: false),
'coupon_code': ?couponCode,
});
return (
trip: Trip.fromJson(res['trip'] as Map<String, dynamic>),
offeredDrivers: (res['offeredDrivers'] as num?)?.toInt() ?? 0,
);
}
Future<Trip> get(String tripId) async {
final res = await _api.get<Map<String, dynamic>>('/trips/$tripId');
return Trip.fromJson(res);
}
Future<List<Trip>> mine() async {
final res = await _api.get<List<dynamic>>('/trips/mine');
return res
.whereType<Map<String, dynamic>>()
.map(Trip.fromJson)
.toList(growable: false);
}
Future<void> cancel(String tripId) =>
_api.post<Map<String, dynamic>>('/trips/$tripId/cancel');
// ── طرف السائق ─────────────────────────────────────────────────────────
Future<List<Trip>> available() async {
final res = await _api.get<List<dynamic>>('/trips/available');
return res
.whereType<Map<String, dynamic>>()
.map(Trip.fromJson)
.toList(growable: false);
}
Future<void> accept(String tripId) =>
_api.post<Map<String, dynamic>>('/trips/$tripId/accept');
Future<void> setOnline(bool online) => _api.patch<Map<String, dynamic>>(
'/drivers/status',
body: {'online': online},
);
/// نبضة الموقع إلى Redis — أساس المطابقة. مرشّح المسافة في
/// `LocationService` يمنع إغراقها.
Future<void> pushLocation(GeoPoint point, {double? heading, double? speed}) =>
_api.post<Map<String, dynamic>>('/drivers/location', body: {
...point.toJson(),
'heading': ?heading,
'speed': ?speed,
});
/// الرصيد التشغيلي. للراكب يرجّع 403 `Not a driver` — لا يُنادى إلا من
/// تطبيق السائق (docs/38 §5).
Future<({String balance, bool blocked})> credit() async {
final res = await _api.get<Map<String, dynamic>>('/credit');
return (
balance: res['balance']?.toString() ?? '0',
blocked: res['blocked'] as bool? ?? false,
);
}
Future<void> updateStatus(String tripId, TripStatus status) =>
_api.patch<Map<String, dynamic>>(
'/trips/$tripId/status',
body: {'status': status.wire},
);
}