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:
co-authored by
Claude Opus 5
parent
c52338f3bb
commit
1156299d09
@@ -0,0 +1,372 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../core/api/api_exception.dart';
|
||||
import '../../../core/config.dart';
|
||||
import '../../../core/location/location_service.dart';
|
||||
import '../../../core/realtime/realtime_service.dart';
|
||||
import '../data/maps_repository.dart';
|
||||
import '../data/models/geo_point.dart';
|
||||
import '../data/models/place.dart';
|
||||
import '../data/models/ride_type.dart';
|
||||
import '../data/models/trip.dart';
|
||||
import '../data/trip_repository.dart';
|
||||
import 'ride_state.dart';
|
||||
|
||||
/// شاشة الراكب كاملة: التخطيط ← التسعيرة ← الطلب ← البحث ← الرحلة الجارية.
|
||||
///
|
||||
/// لا حساب أجرة ولا قرار مطابقة هنا — الخادم مصدر الحقيقة (docs/23 §0.2).
|
||||
/// دور الكيوبت جمع المدخلات وعرض ما يُعطى.
|
||||
class RideCubit extends Cubit<RideState> {
|
||||
RideCubit({
|
||||
required TripRepository trips,
|
||||
required MapsRepository maps,
|
||||
required LocationService location,
|
||||
required RealtimeService realtime,
|
||||
}) : _trips = trips,
|
||||
_maps = maps,
|
||||
_location = location,
|
||||
_realtime = realtime,
|
||||
super(const RideState());
|
||||
|
||||
final TripRepository _trips;
|
||||
final MapsRepository _maps;
|
||||
final LocationService _location;
|
||||
final RealtimeService _realtime;
|
||||
|
||||
StreamSubscription<RealtimeEvent>? _events;
|
||||
Timer? _searchTimeout;
|
||||
Timer? _searchPoll;
|
||||
Timer? _debounce;
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_events?.cancel();
|
||||
_searchTimeout?.cancel();
|
||||
_searchPoll?.cancel();
|
||||
_debounce?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
// ── الإقلاع ────────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> init() async {
|
||||
_events = _realtime.events.listen(_onRealtime);
|
||||
await _realtime.connect();
|
||||
|
||||
// الكاميرا تتوسّط الموقع **فوراً**، ولا تنتظر العنوان — انتظارها كان
|
||||
// باگ سيرو المُصلَح 2026-07-20.
|
||||
unawaited(_locateMe());
|
||||
unawaited(_loadRideTypes());
|
||||
unawaited(_resumeLiveTrip());
|
||||
}
|
||||
|
||||
Future<void> _locateMe() async {
|
||||
final pos = await _location.current();
|
||||
if (pos == null || isClosed) return;
|
||||
final point = GeoPoint(pos.lat, pos.lng);
|
||||
emit(state.copyWith(origin: point, cameraTarget: point));
|
||||
|
||||
final place = await _maps.reverse(point);
|
||||
if (isClosed || place == null) return;
|
||||
emit(state.copyWith(originLabel: place.label));
|
||||
}
|
||||
|
||||
Future<void> _loadRideTypes() async {
|
||||
try {
|
||||
final types = await _trips.rideTypes();
|
||||
if (isClosed || types.isEmpty) return;
|
||||
emit(state.copyWith(
|
||||
rideTypes: types,
|
||||
selectedRideType: state.selectedRideType ?? types.first,
|
||||
));
|
||||
} on ApiException {
|
||||
// قائمة الأنواع ليست حاجزاً للخريطة — تُعاد المحاولة عند التأكيد.
|
||||
}
|
||||
}
|
||||
|
||||
/// رحلة جارية من جلسة سابقة (التطبيق أُغلق أثناءها) تُستأنف بلا تدخّل.
|
||||
Future<void> _resumeLiveTrip() async {
|
||||
try {
|
||||
final live = (await _trips.mine()).where((t) => t.status.isLive);
|
||||
if (isClosed || live.isEmpty) return;
|
||||
_attachTrip(live.first);
|
||||
} on ApiException {
|
||||
// لا شيء يُستأنف — تجربة عادية لا خطأ يُعرض.
|
||||
}
|
||||
}
|
||||
|
||||
// ── التخطيط ────────────────────────────────────────────────────────────
|
||||
|
||||
void openPlanner() => emit(state.copyWith(phase: RidePhase.planning));
|
||||
|
||||
void collapsePlanner() => emit(state.copyWith(phase: RidePhase.idle));
|
||||
|
||||
void selectRideType(RideType type) {
|
||||
emit(state.copyWith(selectedRideType: type));
|
||||
unawaited(_priceIt());
|
||||
}
|
||||
|
||||
/// بحث نصّي عن مكان، بمهلة ارتداد: كل ضغطة زر لا تُطلق نداءً.
|
||||
void searchPlaces(String query) {
|
||||
_debounce?.cancel();
|
||||
if (query.trim().length < 2) {
|
||||
emit(state.copyWith(searchResults: const [], searching: false));
|
||||
return;
|
||||
}
|
||||
emit(state.copyWith(searching: true));
|
||||
_debounce = Timer(const Duration(milliseconds: 350), () async {
|
||||
try {
|
||||
final results = await _maps.search(query, near: state.origin);
|
||||
if (!isClosed) {
|
||||
emit(state.copyWith(searchResults: results, searching: false));
|
||||
}
|
||||
} on ApiException {
|
||||
if (!isClosed) {
|
||||
emit(state.copyWith(searchResults: const [], searching: false));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> pickPlace(Place place, PickTarget target) async {
|
||||
emit(state.copyWith(
|
||||
origin: target == PickTarget.origin ? place.point : null,
|
||||
originLabel: target == PickTarget.origin ? place.label : null,
|
||||
destination: target == PickTarget.destination ? place.point : null,
|
||||
destinationLabel:
|
||||
target == PickTarget.destination ? place.label : null,
|
||||
searchResults: const [],
|
||||
cameraTarget: place.point,
|
||||
));
|
||||
await _routeAndPrice();
|
||||
}
|
||||
|
||||
/// اختيار من الخريطة: المستخدم يحرّك **الخريطة** لا الدبّوس (docs/39 §3).
|
||||
void startMapPick(PickTarget target) => emit(state.copyWith(
|
||||
phase: RidePhase.pickingOnMap,
|
||||
pickTarget: target,
|
||||
));
|
||||
|
||||
Future<void> confirmMapPick(GeoPoint point) async {
|
||||
final isOrigin = state.pickTarget == PickTarget.origin;
|
||||
emit(state.copyWith(
|
||||
phase: RidePhase.planning,
|
||||
origin: isOrigin ? point : null,
|
||||
destination: isOrigin ? null : point,
|
||||
));
|
||||
|
||||
final place = await _maps.reverse(point);
|
||||
if (isClosed) return;
|
||||
emit(state.copyWith(
|
||||
originLabel: isOrigin ? (place?.label ?? '') : null,
|
||||
destinationLabel: isOrigin ? null : (place?.label ?? ''),
|
||||
));
|
||||
await _routeAndPrice();
|
||||
}
|
||||
|
||||
void addStop(GeoPoint stop) =>
|
||||
emit(state.copyWith(stops: [...state.stops, stop]));
|
||||
|
||||
void removeStop(int index) {
|
||||
final next = [...state.stops]..removeAt(index);
|
||||
emit(state.copyWith(stops: next));
|
||||
}
|
||||
|
||||
void clearDestination() => emit(state.copyWith(
|
||||
clearDestination: true,
|
||||
clearRoute: true,
|
||||
phase: RidePhase.planning,
|
||||
));
|
||||
|
||||
// ── المسار والتسعيرة ───────────────────────────────────────────────────
|
||||
|
||||
Future<void> _routeAndPrice() async {
|
||||
final from = state.origin;
|
||||
final to = state.destination;
|
||||
if (from == null || to == null) return;
|
||||
|
||||
emit(state.copyWith(busy: true, error: RideError.none));
|
||||
try {
|
||||
final route = await _maps.route(from, to);
|
||||
if (isClosed) return;
|
||||
emit(state.copyWith(route: route, phase: RidePhase.confirming));
|
||||
await _priceIt();
|
||||
} on ApiException {
|
||||
if (!isClosed) {
|
||||
emit(state.copyWith(busy: false, error: RideError.routeFailed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// التعرفة تحتاج مسافة ومدة محسوبتين — لا إحداثيات (docs/38 §7).
|
||||
Future<void> _priceIt() async {
|
||||
final route = state.route;
|
||||
final type = state.selectedRideType;
|
||||
if (route == null || type == null) return;
|
||||
|
||||
emit(state.copyWith(busy: true));
|
||||
try {
|
||||
final quote = await _trips.quote(
|
||||
city: '',
|
||||
serviceClass: type.code,
|
||||
distanceKm: route.distanceKm,
|
||||
durationMin: route.durationMin,
|
||||
);
|
||||
if (isClosed) return;
|
||||
// 200 بقيم `null` ليس نجاحاً — لا يُعرض رقم غير صالح للمستخدم.
|
||||
emit(state.copyWith(
|
||||
quote: quote,
|
||||
busy: false,
|
||||
error: quote.isUsable ? RideError.none : RideError.quoteFailed,
|
||||
));
|
||||
} on ApiException {
|
||||
if (!isClosed) {
|
||||
emit(state.copyWith(busy: false, error: RideError.quoteFailed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── الطلب والبحث ───────────────────────────────────────────────────────
|
||||
|
||||
Future<void> requestTrip() async {
|
||||
final from = state.origin;
|
||||
final to = state.destination;
|
||||
final type = state.selectedRideType;
|
||||
if (from == null || to == null || type == null) return;
|
||||
|
||||
emit(state.copyWith(busy: true, error: RideError.none));
|
||||
try {
|
||||
final res = await _trips.request(
|
||||
origin: from,
|
||||
destination: to,
|
||||
serviceClass: type.code,
|
||||
stops: state.stops,
|
||||
);
|
||||
if (isClosed) return;
|
||||
|
||||
// صفر سائقين = جواب نهائي فوري، لا داعي لانتظار مهلة كاملة.
|
||||
if (res.offeredDrivers == 0) {
|
||||
emit(state.copyWith(
|
||||
busy: false,
|
||||
error: RideError.noDrivers,
|
||||
phase: RidePhase.confirming,
|
||||
));
|
||||
unawaited(_trips.cancel(res.trip.id));
|
||||
return;
|
||||
}
|
||||
|
||||
_attachTrip(res.trip);
|
||||
_startSearchGuards(res.trip.id);
|
||||
} on ApiException {
|
||||
if (!isClosed) {
|
||||
emit(state.copyWith(busy: false, error: RideError.requestFailed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// حارسان أثناء البحث:
|
||||
/// 1. **مهلة محليّة** — الخادم قد لا يُطلق `expired`/`no_drivers` أبداً
|
||||
/// (ثغرة R1، docs/38 §4)، فبلا هذه المهلة تدور الشاشة إلى الأبد.
|
||||
/// 2. **استطلاع دوري** — يلتقط القبول لو ضاع حدث `trip:update`.
|
||||
void _startSearchGuards(String tripId) {
|
||||
_searchTimeout?.cancel();
|
||||
_searchPoll?.cancel();
|
||||
|
||||
_searchTimeout = Timer(AppConfig.searchingTimeout, () async {
|
||||
if (isClosed || state.phase != RidePhase.searching) return;
|
||||
await cancelTrip();
|
||||
if (!isClosed) emit(state.copyWith(error: RideError.noDrivers));
|
||||
});
|
||||
|
||||
_searchPoll = Timer.periodic(const Duration(seconds: 5), (_) async {
|
||||
if (isClosed || state.phase != RidePhase.searching) return;
|
||||
try {
|
||||
_attachTrip(await _trips.get(tripId));
|
||||
} on ApiException {
|
||||
// الاستطلاع شبكة أمان لا مصدر حقيقة — فشلُه لا يُعرض.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> cancelTrip() async {
|
||||
final trip = state.trip;
|
||||
if (trip == null) return;
|
||||
_stopSearchGuards();
|
||||
try {
|
||||
await _trips.cancel(trip.id);
|
||||
} on ApiException {
|
||||
// حتى لو فشل الإلغاء على الخادم، المستخدم خرج من الشاشة — وحالة
|
||||
// الرحلة تُصحَّح عند الاستئناف التالي.
|
||||
}
|
||||
if (!isClosed) _resetToIdle();
|
||||
}
|
||||
|
||||
void _stopSearchGuards() {
|
||||
_searchTimeout?.cancel();
|
||||
_searchPoll?.cancel();
|
||||
_searchTimeout = null;
|
||||
_searchPoll = null;
|
||||
}
|
||||
|
||||
void _resetToIdle() {
|
||||
emit(state.copyWith(
|
||||
phase: RidePhase.idle,
|
||||
clearTrip: true,
|
||||
clearRoute: true,
|
||||
clearDestination: true,
|
||||
busy: false,
|
||||
stops: const [],
|
||||
));
|
||||
}
|
||||
|
||||
// ── الواقع اللحظي ──────────────────────────────────────────────────────
|
||||
|
||||
void _attachTrip(Trip trip) {
|
||||
// الانضمام قبل أي انتقال حالة، وإلا فاتت الأحداث (docs/38 §9).
|
||||
_realtime.joinTrip(trip.id);
|
||||
|
||||
if (trip.status.isOver) {
|
||||
_stopSearchGuards();
|
||||
emit(state.copyWith(
|
||||
trip: trip,
|
||||
busy: false,
|
||||
phase: RidePhase.idle,
|
||||
error: trip.status == TripStatus.noDrivers ||
|
||||
trip.status == TripStatus.expired
|
||||
? RideError.noDrivers
|
||||
: RideError.none,
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if (trip.status != TripStatus.searching) _stopSearchGuards();
|
||||
|
||||
emit(state.copyWith(
|
||||
trip: trip,
|
||||
busy: false,
|
||||
phase: trip.status == TripStatus.searching
|
||||
? RidePhase.searching
|
||||
: RidePhase.active,
|
||||
));
|
||||
}
|
||||
|
||||
void _onRealtime(RealtimeEvent event) {
|
||||
switch (event.name) {
|
||||
case RealtimeEvent.tripUpdate:
|
||||
final id = event.data['id'] ?? event.data['tripId'];
|
||||
if (id != null && id != state.trip?.id) return;
|
||||
_attachTrip(Trip.fromJson(event.data));
|
||||
|
||||
case RealtimeEvent.driverLocation:
|
||||
final lat = event.data['lat'];
|
||||
final lng = event.data['lng'];
|
||||
if (lat is num && lng is num) {
|
||||
emit(state.copyWith(
|
||||
driverLocation: GeoPoint(lat.toDouble(), lng.toDouble()),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../data/models/fare_quote.dart';
|
||||
import '../data/models/geo_point.dart';
|
||||
import '../data/models/place.dart';
|
||||
import '../data/models/ride_type.dart';
|
||||
import '../data/models/route_info.dart';
|
||||
import '../data/models/trip.dart';
|
||||
|
||||
/// مراحل شاشة الراكب. المرحلة تحدّد ما يُعرض على الخريطة وما في الورقة
|
||||
/// السفلية — «الشاشة مهمة واحدة» (docs/26 §8.1).
|
||||
enum RidePhase {
|
||||
/// الخريطة والبطاقة المطوية «إلى أين؟».
|
||||
idle,
|
||||
|
||||
/// المخطّط الموسّع: المصدر والوجهة والمحطات.
|
||||
planning,
|
||||
|
||||
/// المستخدم يحرّك الخريطة والدبّوس ثابت في المنتصف.
|
||||
pickingOnMap,
|
||||
|
||||
/// المسار والتسعيرة معروضان بانتظار التأكيد.
|
||||
confirming,
|
||||
|
||||
/// البحث عن سائق.
|
||||
searching,
|
||||
|
||||
/// رحلة جارية.
|
||||
active,
|
||||
}
|
||||
|
||||
/// أيّ حقلٍ يلتقط اختيار الخريطة.
|
||||
enum PickTarget { origin, destination }
|
||||
|
||||
enum RideError { none, noDrivers, quoteFailed, routeFailed, requestFailed }
|
||||
|
||||
class RideState extends Equatable {
|
||||
const RideState({
|
||||
this.phase = RidePhase.idle,
|
||||
this.origin,
|
||||
this.destination,
|
||||
this.originLabel = '',
|
||||
this.destinationLabel = '',
|
||||
this.stops = const [],
|
||||
this.rideTypes = const [],
|
||||
this.selectedRideType,
|
||||
this.route,
|
||||
this.quote,
|
||||
this.trip,
|
||||
this.driverLocation,
|
||||
this.searchResults = const [],
|
||||
this.searching = false,
|
||||
this.busy = false,
|
||||
this.error = RideError.none,
|
||||
this.pickTarget = PickTarget.destination,
|
||||
this.cameraTarget,
|
||||
});
|
||||
|
||||
final RidePhase phase;
|
||||
|
||||
final GeoPoint? origin;
|
||||
final GeoPoint? destination;
|
||||
final String originLabel;
|
||||
final String destinationLabel;
|
||||
final List<GeoPoint> stops;
|
||||
|
||||
final List<RideType> rideTypes;
|
||||
final RideType? selectedRideType;
|
||||
|
||||
final RouteInfo? route;
|
||||
final FareQuote? quote;
|
||||
|
||||
final Trip? trip;
|
||||
|
||||
/// موقع السائق الحيّ من `driver:location`.
|
||||
final GeoPoint? driverLocation;
|
||||
|
||||
final List<Place> searchResults;
|
||||
|
||||
/// بحث نصّي جارٍ — منفصل عن [busy] كي لا يقفل مؤشّرُ البحثِ الشاشةَ كلها.
|
||||
final bool searching;
|
||||
final bool busy;
|
||||
|
||||
final RideError error;
|
||||
final PickTarget pickTarget;
|
||||
|
||||
/// وجهة الكاميرا حين يقودها المنطق لا المستخدم.
|
||||
final GeoPoint? cameraTarget;
|
||||
|
||||
bool get canConfirm =>
|
||||
origin != null &&
|
||||
destination != null &&
|
||||
selectedRideType != null &&
|
||||
quote?.isUsable == true;
|
||||
|
||||
RideState copyWith({
|
||||
RidePhase? phase,
|
||||
GeoPoint? origin,
|
||||
GeoPoint? destination,
|
||||
String? originLabel,
|
||||
String? destinationLabel,
|
||||
List<GeoPoint>? stops,
|
||||
List<RideType>? rideTypes,
|
||||
RideType? selectedRideType,
|
||||
RouteInfo? route,
|
||||
FareQuote? quote,
|
||||
Trip? trip,
|
||||
GeoPoint? driverLocation,
|
||||
List<Place>? searchResults,
|
||||
bool? searching,
|
||||
bool? busy,
|
||||
RideError? error,
|
||||
PickTarget? pickTarget,
|
||||
GeoPoint? cameraTarget,
|
||||
bool clearDestination = false,
|
||||
bool clearRoute = false,
|
||||
bool clearTrip = false,
|
||||
bool clearCamera = false,
|
||||
}) {
|
||||
return RideState(
|
||||
phase: phase ?? this.phase,
|
||||
origin: origin ?? this.origin,
|
||||
destination: clearDestination ? null : (destination ?? this.destination),
|
||||
originLabel: originLabel ?? this.originLabel,
|
||||
destinationLabel: clearDestination
|
||||
? ''
|
||||
: (destinationLabel ?? this.destinationLabel),
|
||||
stops: stops ?? this.stops,
|
||||
rideTypes: rideTypes ?? this.rideTypes,
|
||||
selectedRideType: selectedRideType ?? this.selectedRideType,
|
||||
route: clearRoute ? null : (route ?? this.route),
|
||||
quote: clearRoute ? null : (quote ?? this.quote),
|
||||
trip: clearTrip ? null : (trip ?? this.trip),
|
||||
driverLocation: driverLocation ?? this.driverLocation,
|
||||
searchResults: searchResults ?? this.searchResults,
|
||||
searching: searching ?? this.searching,
|
||||
busy: busy ?? this.busy,
|
||||
error: error ?? this.error,
|
||||
pickTarget: pickTarget ?? this.pickTarget,
|
||||
cameraTarget: clearCamera ? null : (cameraTarget ?? this.cameraTarget),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
phase,
|
||||
origin,
|
||||
destination,
|
||||
originLabel,
|
||||
destinationLabel,
|
||||
stops,
|
||||
rideTypes,
|
||||
selectedRideType,
|
||||
route,
|
||||
quote,
|
||||
trip,
|
||||
driverLocation,
|
||||
searchResults,
|
||||
searching,
|
||||
busy,
|
||||
error,
|
||||
pickTarget,
|
||||
cameraTarget,
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user