Update codebase
This commit is contained in:
@@ -12,6 +12,7 @@ import '../data/models/geo_point.dart';
|
||||
import '../data/models/place.dart';
|
||||
import '../data/models/ride_type.dart';
|
||||
import '../data/models/trip.dart';
|
||||
import '../data/recent_places_store.dart';
|
||||
import '../data/trip_repository.dart';
|
||||
import 'ride_state.dart';
|
||||
|
||||
@@ -25,16 +26,19 @@ class RideCubit extends Cubit<RideState> {
|
||||
required MapsRepository maps,
|
||||
required LocationService location,
|
||||
required RealtimeService realtime,
|
||||
required RecentPlacesStore recents,
|
||||
}) : _trips = trips,
|
||||
_maps = maps,
|
||||
_location = location,
|
||||
_realtime = realtime,
|
||||
_recents = recents,
|
||||
super(const RideState());
|
||||
|
||||
final TripRepository _trips;
|
||||
final MapsRepository _maps;
|
||||
final LocationService _location;
|
||||
final RealtimeService _realtime;
|
||||
final RecentPlacesStore _recents;
|
||||
|
||||
StreamSubscription<RealtimeEvent>? _events;
|
||||
Timer? _searchTimeout;
|
||||
@@ -116,8 +120,6 @@ class RideCubit extends Cubit<RideState> {
|
||||
|
||||
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));
|
||||
|
||||
/// تبديل النوع **لا يُطلق نداءً**: كل الأنواع مسعَّرة مسبقاً في [_priceAll].
|
||||
@@ -150,7 +152,12 @@ class RideCubit extends Cubit<RideState> {
|
||||
});
|
||||
}
|
||||
|
||||
/// الأماكن الأخيرة — تُقرأ في الشاشة، وتُحدَّث عند كل اختيار وجهة.
|
||||
List<Place> get recentPlaces => _recents.items;
|
||||
|
||||
Future<void> pickPlace(Place place, PickTarget target) async {
|
||||
// الوجهة وحدها تُحفظ: «الأخيرة» تعني أين ذهبتُ لا من أين انطلقت.
|
||||
if (target == PickTarget.destination) unawaited(_recents.add(place));
|
||||
emit(state.copyWith(
|
||||
origin: target == PickTarget.origin ? place.point : 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) {
|
||||
switch (event.name) {
|
||||
case RealtimeEvent.tripUpdate:
|
||||
@@ -427,10 +457,62 @@ class RideCubit extends Cubit<RideState> {
|
||||
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()),
|
||||
));
|
||||
_onDriverMoved(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.trip,
|
||||
this.driverLocation,
|
||||
this.pickupRoute,
|
||||
this.searchResults = const [],
|
||||
this.searching = false,
|
||||
this.busy = false,
|
||||
@@ -95,6 +96,12 @@ class RideState extends Equatable {
|
||||
/// موقع السائق الحيّ من `driver:location`.
|
||||
final GeoPoint? driverLocation;
|
||||
|
||||
/// مسار **السائق ← الراكب** أثناء الاقتراب، ومنه الوقت والمسافة المعروضان.
|
||||
///
|
||||
/// منفصل عن [route] (مسار الرحلة نفسها) لأنهما يُرسمان معاً بلونين
|
||||
/// مختلفين، ولأن هذا يُعاد حسابه مع حركة السائق بينما ذاك ثابت.
|
||||
final RouteInfo? pickupRoute;
|
||||
|
||||
final List<Place> searchResults;
|
||||
|
||||
/// بحث نصّي جارٍ — منفصل عن [busy] كي لا يقفل مؤشّرُ البحثِ الشاشةَ كلها.
|
||||
@@ -133,6 +140,7 @@ class RideState extends Equatable {
|
||||
Map<String, FareQuote>? quotes,
|
||||
Trip? trip,
|
||||
GeoPoint? driverLocation,
|
||||
RouteInfo? pickupRoute,
|
||||
List<Place>? searchResults,
|
||||
bool? searching,
|
||||
bool? busy,
|
||||
@@ -144,6 +152,7 @@ class RideState extends Equatable {
|
||||
bool clearRoute = false,
|
||||
bool clearTrip = false,
|
||||
bool clearCamera = false,
|
||||
bool clearPickupRoute = false,
|
||||
}) {
|
||||
return RideState(
|
||||
phase: phase ?? this.phase,
|
||||
@@ -161,6 +170,9 @@ class RideState extends Equatable {
|
||||
quotes: clearRoute ? const {} : (quotes ?? this.quotes),
|
||||
trip: clearTrip ? null : (trip ?? this.trip),
|
||||
driverLocation: driverLocation ?? this.driverLocation,
|
||||
pickupRoute: clearPickupRoute || clearRoute
|
||||
? null
|
||||
: (pickupRoute ?? this.pickupRoute),
|
||||
searchResults: searchResults ?? this.searchResults,
|
||||
searching: searching ?? this.searching,
|
||||
busy: busy ?? this.busy,
|
||||
@@ -186,6 +198,7 @@ class RideState extends Equatable {
|
||||
quotes,
|
||||
trip,
|
||||
driverLocation,
|
||||
pickupRoute,
|
||||
searchResults,
|
||||
searching,
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user