diff --git a/backend/food/courier/location.php b/backend/food/courier/location.php new file mode 100644 index 00000000..bb3905a2 --- /dev/null +++ b/backend/food/courier/location.php @@ -0,0 +1,48 @@ + 90 || $lng < -180 || $lng > 180 || ($lat === 0.0 && $lng === 0.0)) { + jsonError('Invalid coordinates'); +} + +$order = foodAssertOrderOwnership($orderId, 'courier', $food_courier_id); + +if (!in_array($order['status'], ['courier_assigned', 'picked_up'], true)) { + jsonError('Location sharing is only allowed while the delivery is active', 403); +} + +$payload = [ + 'order_id' => $orderId, + 'lat' => round($lat, 6), + 'lng' => round($lng, 6), + 'heading' => filterRequest('heading') !== null ? (float)filterRequest('heading') : null, + 'ts' => time(), +]; + +if ($redis) { + // مصدر مسار الاحتياط: تطبيق الراكب يسحبه من order/courier_location.php + $redis->setex("food:order:{$orderId}:courier_pos", 90, json_encode($payload)); +} + +// المسار اللحظي — غرفة الزبون على سوكيت الطعام +foodPushToSocket('courier_location', array_merge($payload, [ + 'passenger_id' => (string)$order['passenger_id'], +])); + +jsonSuccess(['order_id' => $orderId, 'ts' => $payload['ts']]); diff --git a/backend/food/functions.php b/backend/food/functions.php index 22ade719..d9117624 100644 --- a/backend/food/functions.php +++ b/backend/food/functions.php @@ -508,7 +508,22 @@ function foodFindNearbyCouriers(float $lat, float $lng, float $radiusKm = 5, int $optedIn = $redis->sMembers('food:couriers:opted_in'); if (!$optedIn) return []; - return array_values(array_intersect($nearby, $optedIn)); + $candidates = array_values(array_intersect($nearby, $optedIn)); + if (!$candidates) return []; + + // مهمة واحدة في الوقت الواحد: التطبيق يُعلن السائق مشغولاً في نظام الرحلات + // فور إسناد طلب له، فيخرج من geo:drivers:available وحده. لكن ذلك يعتمد على + // وصول تحديث موقع، فقد يتأخر ثوانٍ. هذا الفحص هو الضمانة القاطعة: لا يُعرض + // طلب ثانٍ على سائق يحمل طلباً نشطاً مهما تأخّر تحديث المجموعة. + $placeholders = implode(',', array_fill(0, count($candidates), '?')); + $busySt = Database::get('food')->prepare( + "SELECT DISTINCT courier_id FROM food_orders + WHERE courier_id IN ($placeholders) AND status IN ('courier_assigned','picked_up')" + ); + $busySt->execute($candidates); + $busy = array_column($busySt->fetchAll(), 'courier_id'); + + return array_values(array_diff($candidates, $busy)); } function foodOfferOrderToCourier(int $orderId, string $courierId): void diff --git a/backend/food/order/courier_location.php b/backend/food/order/courier_location.php new file mode 100644 index 00000000..64b927cc --- /dev/null +++ b/backend/food/order/courier_location.php @@ -0,0 +1,49 @@ + $order['status'], 'courier_position' => null]); +} + +$position = null; +if ($redis) { + $raw = $redis->get("food:order:{$orderId}:courier_pos"); + if ($raw) $position = json_decode($raw, true); +} + +// إحداثيات وجهتَي الخريطة — المطعم وعنوان التسليم (كلاهما معروف للزبون أصلاً) +$st = $food_con->prepare( + "SELECT o.delivery_lat, o.delivery_lng, o.delivery_address, + m.name_ar AS merchant_name_ar, m.latitude AS merchant_lat, m.longitude AS merchant_lng + FROM food_orders o JOIN food_merchants m ON m.id = o.merchant_id + WHERE o.id = ? LIMIT 1" +); +$st->execute([$orderId]); +$meta = $st->fetch() ?: []; + +jsonSuccess([ + 'status' => $order['status'], + 'courier_position' => $position, + 'merchant' => $meta ? [ + 'name_ar' => $meta['merchant_name_ar'], + 'lat' => (float)$meta['merchant_lat'], + 'lng' => (float)$meta['merchant_lng'], + ] : null, + 'destination' => $meta ? [ + 'address' => $meta['delivery_address'], + 'lat' => (float)$meta['delivery_lat'], + 'lng' => (float)$meta['delivery_lng'], + ] : null, +]); diff --git a/food_server/food_socket.php b/food_server/food_socket.php index 981e843c..2d0bbd75 100644 --- a/food_server/food_socket.php +++ b/food_server/food_socket.php @@ -144,6 +144,13 @@ $io->on('workerStart', function () use ($io, $INTERNAL_KEY, $INTERNAL_PORT) { $io->to('courier_food_' . $courierId)->emit('food_delivery_offer', $payload); socket_log("[HTTP_SUCCESS] courier_offer pushed to courier #$courierId", $payload); $connection->send('OK'); + } elseif ($action === 'courier_location') { + // موقع السائق أثناء التوصيل — للزبون صاحب الطلب وحده + $passengerId = $payload['passenger_id'] ?? null; + if (!$passengerId) { $connection->send('Error: Missing passenger_id'); return; } + unset($payload['passenger_id']); + $io->to('customer_food_' . $passengerId)->emit('food_courier_location', $payload); + $connection->send('OK'); } elseif ($action === 'courier_call') { // مكالمة مقنّعة من الزبون إلى السائق — نمرّر session_id فقط، // لا رقم هاتف ولا هوية حقيقية لأي طرف. diff --git a/siro_driver/lib/controller/food_delivery/food_delivery_controller.dart b/siro_driver/lib/controller/food_delivery/food_delivery_controller.dart index 8fe92ed4..f019a868 100644 --- a/siro_driver/lib/controller/food_delivery/food_delivery_controller.dart +++ b/siro_driver/lib/controller/food_delivery/food_delivery_controller.dart @@ -16,6 +16,7 @@ import '../../main.dart'; import '../../print.dart'; import '../../views/food_delivery/food_offer_page.dart'; import '../../views/widgets/error_snakbar.dart'; +import '../functions/location_controller.dart'; import '../voice_call_controller.dart'; import 'food_delivery_models.dart'; import 'food_delivery_service.dart'; @@ -235,6 +236,26 @@ class FoodDeliveryController extends GetxController { await _fetchPendingOffers(); } await fetchActiveTasks(silent: true); + await _broadcastLocation(); + } + + // موقع السائق يُبثّ فقط ما دامت هناك مهمة نشطة — لا شيء يُرسل قبل الإسناد + // ولا بعد التسليم، والخادم يرفضه في الحالتين أصلاً كطبقة ثانية. + Future _broadcastLocation() async { + if (activeTasks.isEmpty) return; + if (!Get.isRegistered()) return; + + final loc = Get.find(); + if (loc.myLocation.latitude == 0 && loc.myLocation.longitude == 0) return; + + for (final task in activeTasks) { + await FoodDeliveryService.sendLocation( + orderId: task.id, + lat: loc.myLocation.latitude, + lng: loc.myLocation.longitude, + heading: loc.heading, + ); + } } Future _fetchPendingOffers() async { @@ -253,9 +274,42 @@ class FoodDeliveryController extends GetxController { final res = await FoodDeliveryService.getActiveTasks(); isLoadingTasks = false; if (res.success) activeTasks = res.data ?? []; + _syncRideAvailability(); update(); } + // ── مهمة واحدة في الوقت الواحد ────────────────────────────────────────── + // السائق أثناء التوصيل «مشغول» في نظام الرحلات تماماً كما لو كان في رحلة: + // نكتب statusDriverLocation='on' فينقله driver_socket من geo:drivers:available + // إلى geo:drivers:busy، فيتوقف توزيع الرحلات عليه — ويتوقف معه توزيع طلبات + // طعام إضافية، لأن مطابقة الطعام تتقاطع مع نفس المجموعة المتاحة. + // نعيده متاحاً بعد التسليم، وبشرطين: أن نكون نحن من شغَله، وألا تكون هناك + // رحلة جارية فعلاً (وإلا أفرغنا انشغالاً ليس لنا). + bool _markedBusyForDelivery = false; + + void _syncRideAvailability() { + final hasDelivery = activeTasks.isNotEmpty; + + if (hasDelivery && !_markedBusyForDelivery) { + _markedBusyForDelivery = true; + box.write(BoxName.statusDriverLocation, 'on'); + Log.print('🍔 [FoodDelivery] السائق مشغول بتوصيل — أُوقف استقبال الرحلات'); + return; + } + + if (!hasDelivery && _markedBusyForDelivery) { + _markedBusyForDelivery = false; + final rideStatus = (box.read(BoxName.rideStatus) ?? '').toString(); + final onRide = rideStatus == 'Begin' || rideStatus == 'Apply' || rideStatus == 'Arrived'; + final blocked = box.read(BoxName.statusDriverLocation) == 'blocked'; + + if (!onRide && !blocked) { + box.write(BoxName.statusDriverLocation, 'off'); + Log.print('🍔 [FoodDelivery] انتهى التوصيل — عاد استقبال الرحلات'); + } + } + } + bool isRespondingToOffer(int orderId) => _respondingOfferIds.contains(orderId); bool isTaskBusy(int orderId) => _busyTaskIds.contains(orderId); diff --git a/siro_driver/lib/controller/food_delivery/food_delivery_service.dart b/siro_driver/lib/controller/food_delivery/food_delivery_service.dart index ee4e1c6f..f0c7a187 100644 --- a/siro_driver/lib/controller/food_delivery/food_delivery_service.dart +++ b/siro_driver/lib/controller/food_delivery/food_delivery_service.dart @@ -98,6 +98,25 @@ class FoodDeliveryService { return FoodDeliveryApiResult(false, null, _errMsg(res)); } + /// بثّ موقع السائق للزبون أثناء المهمة. الخادم يرفضه بعد التسليم، + /// وينتهي آخر موقع من Redis تلقائياً — لا تتبّع خارج نافذة العمل. + static Future sendLocation({ + required int orderId, + required double lat, + required double lng, + double? heading, + }) async { + await CRUD().post( + link: '$_base/courier/location.php', + payload: { + 'order_id': orderId.toString(), + 'lat': lat.toString(), + 'lng': lng.toString(), + if (heading != null) 'heading': heading.toStringAsFixed(0), + }, + ); + } + static Future> markPickedUp(int orderId) async { final res = await CRUD().post( link: '$_base/courier/picked_up.php', diff --git a/siro_driver/lib/controller/food_delivery/food_navigation.dart b/siro_driver/lib/controller/food_delivery/food_navigation.dart new file mode 100644 index 00000000..dac1ce36 --- /dev/null +++ b/siro_driver/lib/controller/food_delivery/food_navigation.dart @@ -0,0 +1,62 @@ +// food_navigation.dart — تشغيل ملاحة سيرو الداخلية لمهام التوصيل +// +// لماذا النسخة نفسها (بلا tag) لا نسخة مستقلة للتوصيل: +// خطّ تحديث الموقع المركزي في LocationController يُغذّي نسخة واحدة فقط +// (Get.find بلا وسم). نسخة موسومة للتوصيل ستُحرم من +// تحديثات الموقع فلا تعمل الملاحة أصلاً، وتغذيتها يستلزم تعديل مسار الموقع +// وهو المسار الأخطر في التطبيق. فنستعمل النسخة المشتركة، ونعالج التضارب +// الوحيد الممكن (سائق في ملاحة رحلة ثم يفتح مهمة توصيل) بسؤاله صراحةً +// بدل استبدال مساره صامتاً. +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intaleq_maps/intaleq_maps.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../views/widgets/error_snakbar.dart'; +import '../home/navigation/navigation_controller.dart'; +import '../home/navigation/navigation_view.dart'; + +class FoodNavigation { + /// يفتح ملاحة سيرو إلى [lat]/[lng]. يعيد false إذا ألغى السائق الاستبدال. + static Future startInApp({ + required double lat, + required double lng, + required String title, + }) async { + final controller = Get.isRegistered() + ? Get.find() + : Get.put(NavigationController()); + + if (controller.isNavigating) { + final replace = await Get.dialog( + AlertDialog( + title: const Text('ملاحة نشطة'), + content: Text('لديك ملاحة جارية. هل تستبدلها بالوجهة الجديدة؟\n$title'), + actions: [ + TextButton(onPressed: () => Get.back(result: false), child: const Text('إبقاء الحالية')), + TextButton(onPressed: () => Get.back(result: true), child: const Text('استبدال')), + ], + ), + ); + if (replace != true) return false; + } + + Get.to(() => const NavigationView()); + await controller.startNavigationTo(LatLng(lat, lng), infoWindowTitle: title); + return true; + } + + /// خيار ثانوي لمن يفضّل جوجل — نفس ما يتيحه تدفّق الرحلات. + /// لا نمرّر في الرابط سوى الإحداثيات: لا اسم زبون ولا رقم طلب. + static Future startExternal(double lat, double lng) async { + final nav = Uri.parse('google.navigation:q=$lat,$lng'); + if (await canLaunchUrl(nav)) { + await launchUrl(nav); + return; + } + final web = Uri.parse('https://www.google.com/maps/dir/?api=1&destination=$lat,$lng'); + if (!await launchUrl(web, mode: LaunchMode.externalApplication)) { + mySnackbarWarning('تعذّر فتح تطبيق الخرائط'); + } + } +} diff --git a/siro_driver/lib/controller/functions/location_controller.dart b/siro_driver/lib/controller/functions/location_controller.dart index 2b9b4ae0..5fcb221b 100755 --- a/siro_driver/lib/controller/functions/location_controller.dart +++ b/siro_driver/lib/controller/functions/location_controller.dart @@ -783,6 +783,11 @@ class LocationController extends GetxController with WidgetsBindingObserver { } } + /// دفع ما تراكم من نقاط المسار فوراً بدل انتظار المؤقّت. + /// تستدعيها شاشة الملاحة عند انتهاء الملاحة، بعد أن صار الرفع مركزيّاً هنا + /// وحده (كان الملّاح يرفع دفعة موازية إلى نفس الواجهة فيتضاعف الحساب). + Future flushTrackBufferNow() => _flushBufferToServer(); + Future _flushBufferToServer() async { if (_trackBuffer.isEmpty) return; diff --git a/siro_driver/lib/controller/home/navigation/navigation_controller.dart b/siro_driver/lib/controller/home/navigation/navigation_controller.dart index 94cf6118..21b38707 100644 --- a/siro_driver/lib/controller/home/navigation/navigation_controller.dart +++ b/siro_driver/lib/controller/home/navigation/navigation_controller.dart @@ -13,6 +13,7 @@ import 'package:siro_driver/constant/box_name.dart'; import 'package:siro_driver/constant/links.dart'; import 'package:siro_driver/controller/car_platform_bridge.dart'; import 'package:siro_driver/controller/functions/crud.dart'; +import 'package:siro_driver/controller/functions/location_controller.dart'; import 'package:siro_driver/controller/functions/tts.dart'; import 'package:siro_driver/controller/home/navigation/decode_polyline_isolate.dart'; import 'package:siro_driver/env/env.dart'; @@ -39,9 +40,6 @@ class RouteData { class NavigationController extends GetxController with GetSingleTickerProviderStateMixin { - static const Duration _recordInterval = Duration(seconds: 4); - static const Duration _uploadInterval = Duration(minutes: 2); - static const double _minMoveToRecord = 10.0; static const double _minMoveToProcess = 2.0; static const double _offRouteThresholdM = 25.0; static const int _offRouteTriggerSeconds = 6; @@ -164,11 +162,6 @@ class NavigationController extends GetxController DateTime? _offRouteStartTime; bool _autoRecalcInProgress = false; - final List> _trackBuffer = []; - Timer? _recordTimer; - Timer? _uploadBatchTimer; - LatLng? _lastBufferedLocation; - DateTime? _lastBufferedTime; LatLng? _lastDistanceLocation; List routes = []; @@ -364,13 +357,11 @@ class NavigationController extends GetxController @override void onClose() { _locationStreamSubscription?.cancel(); - _recordTimer?.cancel(); - _uploadBatchTimer?.cancel(); _debounce?.cancel(); _animController?.dispose(); mapController = null; placeDestinationController.dispose(); - _flushBufferToServer(); + _flushTracksViaCentral(); super.onClose(); } @@ -475,7 +466,6 @@ class NavigationController extends GetxController if (isStyleLoaded) animateCameraToPosition(myLocation!); // Start the Location Stream for real-time updates _startLocationStream(); - _startBatchTimers(); } catch (e) { Log.print("DEBUG: Error getting initial location: $e"); } @@ -616,63 +606,16 @@ class NavigationController extends GetxController } } - void _startBatchTimers() { - _recordTimer?.cancel(); - _uploadBatchTimer?.cancel(); - _recordTimer = Timer.periodic(_recordInterval, (_) => _recordToBuffer()); - _uploadBatchTimer = - Timer.periodic(_uploadInterval, (_) => _flushBufferToServer()); - } + // ‏رفع مسار القيادة (add_batch) مركزيّ في LocationController وحده. + // ‏كان الملّاح يحتفظ بذاكرة ومؤقّتات رفع خاصة به ويُرسل إلى نفس الواجهة + // ‏ونفس الخادم، فيُكتب مسار السائق الواحد مرتين ما دامت شاشة الملاحة + // ‏مفتوحة — ولأن add_batch.php يراكم زمن العمل اليومي من نفس الجدول، + // ‏كان ذلك يضاعف ساعات العمل المحسوبة. مصدر الموقع واحد فالرفع واحد. - void _recordToBuffer() { - if (myLocation == null || - (myLocation!.latitude == 0 && myLocation!.longitude == 0)) { - return; - } - final now = DateTime.now(); - final distFromLast = _lastBufferedLocation == null - ? 999.0 - : Geolocator.distanceBetween( - _lastBufferedLocation!.latitude, - _lastBufferedLocation!.longitude, - myLocation!.latitude, - myLocation!.longitude); - final bool moved = distFromLast > _minMoveToRecord && currentSpeed > 0.5; - final bool timeForced = _lastBufferedTime == null || - now.difference(_lastBufferedTime!).inSeconds >= 60; - if (!moved && !timeForced) return; - - _lastBufferedLocation = myLocation; - _lastBufferedTime = now; - - _trackBuffer.add({ - 'lat': double.parse(myLocation!.latitude.toStringAsFixed(6)), - 'lng': double.parse(myLocation!.longitude.toStringAsFixed(6)), - 'spd': double.parse(currentSpeed.toStringAsFixed(1)), - 'head': _smoothedHeading.toStringAsFixed(0), - 'dist': double.parse(totalDistance.toStringAsFixed(1)), - 'ts': now.toIso8601String(), - }); - } - - Future _flushBufferToServer() async { - if (_trackBuffer.isEmpty) return; - final batch = List>.from(_trackBuffer); - _trackBuffer.clear(); - final String passengerId = (box.read(BoxName.passengerID) ?? '').toString(); - - try { - await CRUD().post( - link: '${AppLink.locationServerSide}/add_batch.php', - payload: { - 'driver_id': passengerId, - 'batch_data': jsonEncode(batch), - 'session_dist': totalDistance.toStringAsFixed(1), - }, - ); - } catch (e) { - _trackBuffer.insertAll(0, batch); - } + // ‏الرفع مركزيّ في LocationController — نطلب منه دفع ما تراكم فقط + void _flushTracksViaCentral() { + if (!Get.isRegistered()) return; + Get.find().flushTrackBufferNow(); } Future _updateCarMarker() async { @@ -1179,7 +1122,7 @@ class NavigationController extends GetxController isNavigating = false; routes = []; CarPlatformBridge.stopNavigation(); - await _flushBufferToServer(); + _flushTracksViaCentral(); } routeSteps = []; _fullRouteCoordinates = []; @@ -1277,7 +1220,7 @@ class NavigationController extends GetxController Get.find().speakText(currentInstruction); } CarPlatformBridge.stopNavigation(); - _flushBufferToServer(); + _flushTracksViaCentral(); update(); } diff --git a/siro_driver/lib/views/food_delivery/food_delivery_home_page.dart b/siro_driver/lib/views/food_delivery/food_delivery_home_page.dart index 10a49f67..6b8adff9 100644 --- a/siro_driver/lib/views/food_delivery/food_delivery_home_page.dart +++ b/siro_driver/lib/views/food_delivery/food_delivery_home_page.dart @@ -100,7 +100,9 @@ class FoodDeliveryHomePage extends StatelessWidget { Text( c.isDeliveryModeEnabled ? (c.isSocketConnected - ? 'متصل — ستصلك عروض التوصيل فوراً' + // صريح عمداً: الوضع استعداد لا حجز. أول مهمة تصل — + // رحلة كانت أو توصيلاً — تجعله مشغولاً عن الأخرى. + ? 'متصل — ستصلك عروض التوصيل، وقد تصلك رحلة أيضاً' : 'إعادة الاتصال… العروض تصل بالتحديث الدوري') : 'فعّله لاستقبال عروض توصيل الطعام', style: AppStyle.subtitle, diff --git a/siro_driver/lib/views/food_delivery/food_task_details_page.dart b/siro_driver/lib/views/food_delivery/food_task_details_page.dart index 4e1eea2c..fd14d489 100644 --- a/siro_driver/lib/views/food_delivery/food_task_details_page.dart +++ b/siro_driver/lib/views/food_delivery/food_task_details_page.dart @@ -7,6 +7,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../../constant/colors.dart'; import '../../constant/style.dart'; import '../../controller/food_delivery/food_delivery_controller.dart'; +import '../../controller/food_delivery/food_navigation.dart'; import '../../controller/food_delivery/food_delivery_models.dart'; import '../../controller/food_delivery/food_delivery_service.dart'; import '../widgets/elevated_btn.dart'; @@ -41,14 +42,13 @@ class _FoodTaskDetailsPageState extends State { if (!res.success) mySnackbarWarning(res.message); } - Future _openMaps(double lat, double lng) async { - // نفتح تطبيق الخرائط الافتراضي على الجهاز — لا نضمّن مفاتيح ولا نمرّر - // بيانات الزبون في الرابط سوى الإحداثيات اللازمة للملاحة. - final uri = Uri.parse('https://www.google.com/maps/dir/?api=1&destination=$lat,$lng'); - if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) { - mySnackbarWarning('تعذّر فتح تطبيق الخرائط'); - } - } + /// الملاحة الداخلية هي الافتراضي: تُبقي السائق داخل التطبيق فيظل موقعه + /// مرصوداً ويستمر بثّه للزبون. جوجل يبقى خياراً ثانوياً بضغطة مطوّلة. + Future _navigate(double lat, double lng, String title) => + FoodNavigation.startInApp(lat: lat, lng: lng, title: title); + + Future _navigateExternal(double lat, double lng) => + FoodNavigation.startExternal(lat, lng); Future _call(String phone) async { final uri = Uri(scheme: 'tel', path: phone); @@ -89,7 +89,10 @@ class _FoodTaskDetailsPageState extends State { title: t.merchantNameAr, subtitle: t.merchantAddress ?? 'مطعم', onNavigate: (t.merchantLat != null && t.merchantLng != null) - ? () => _openMaps(t.merchantLat!, t.merchantLng!) + ? () => _navigate(t.merchantLat!, t.merchantLng!, t.merchantNameAr) + : null, + onNavigateExternal: (t.merchantLat != null && t.merchantLng != null) + ? () => _navigateExternal(t.merchantLat!, t.merchantLng!) : null, onCall: d.merchantPhone == null ? null : () => _call(d.merchantPhone!), ), @@ -99,7 +102,12 @@ class _FoodTaskDetailsPageState extends State { color: AppColor.greenColor, title: 'عنوان الزبون', subtitle: t.deliveryAddress ?? 'غير متاح', - onNavigate: t.deliveryLat == 0 ? null : () => _openMaps(t.deliveryLat, t.deliveryLng), + onNavigate: t.deliveryLat == 0 + ? null + : () => _navigate(t.deliveryLat, t.deliveryLng, 'عنوان الزبون'), + onNavigateExternal: t.deliveryLat == 0 + ? null + : () => _navigateExternal(t.deliveryLat, t.deliveryLng), // مكالمة مقنّعة: لا رقم هاتف للزبون يظهر أو يُخزَّن — اتصال صوتي // عبر جلسة مؤقتة، ومتاح فقط ما دامت المهمة نشطة. onCall: () => Get.find().callCustomer(t.id), @@ -196,6 +204,7 @@ class _FoodTaskDetailsPageState extends State { required String title, required String subtitle, VoidCallback? onNavigate, + VoidCallback? onNavigateExternal, VoidCallback? onCall, IconData callIcon = Icons.call_rounded, String callTooltip = 'اتصال', @@ -232,8 +241,10 @@ class _FoodTaskDetailsPageState extends State { if (onNavigate != null) IconButton( onPressed: onNavigate, + // ضغطة مطوّلة = ملاحة جوجل، لمن اعتاد عليها + onLongPress: onNavigateExternal, icon: Icon(Icons.navigation_rounded, color: AppColor.accentColor), - tooltip: 'الملاحة', + tooltip: 'ملاحة سيرو (اضغط مطوّلاً لجوجل)', ), ], ), diff --git a/siro_rider/lib/controller/food/food_tracking_controller.dart b/siro_rider/lib/controller/food/food_tracking_controller.dart new file mode 100644 index 00000000..ad655066 --- /dev/null +++ b/siro_rider/lib/controller/food/food_tracking_controller.dart @@ -0,0 +1,176 @@ +// food_tracking_controller.dart — تتبّع سائق التوصيل على الخريطة (جهة الراكب) +// +// مصدر الموقع: backend/food/order/courier_location.php الذي يقرأ من Redis +// (عمر 90 ثانية). لا سوكيت هنا: تطبيق الراكب لا يحمل عميلاً لسوكيت الطعام، +// والسحب كل 6 ثوانٍ كافٍ لحركة سلسة على الخريطة مع تحريك مُنعّم للعلامة. +// +// التتبّع يتوقف تلقائياً بانتهاء الطلب: الخادم يُرجع null خارج نافذة +// courier_assigned/picked_up، وعندها نُغلق المؤقّت ولا نسأل مجدداً. +import 'dart:async'; +import 'dart:ui' show Offset; + +import 'package:get/get.dart'; +import 'package:intaleq_maps/intaleq_maps.dart'; + +import '../../constant/links.dart'; +import '../functions/crud.dart'; + +class FoodTrackingController extends GetxController { + final int orderId; + FoodTrackingController(this.orderId); + + IntaleqMapController? mapController; + bool isStyleLoaded = false; + bool isLoading = true; + + LatLng? courierPosition; + LatLng? merchantPosition; + LatLng? destinationPosition; + String merchantName = ''; + String? destinationAddress; + String orderStatus = ''; + DateTime? lastFixAt; + + Set markers = {}; + Timer? _timer; + bool _cameraSettled = false; + + bool get isTrackingActive => + orderStatus == 'courier_assigned' || orderStatus == 'picked_up'; + + /// الموقع قديم إن مضى عليه أكثر من دقيقة — نُعلم الراكب بدل إيهامه بحركة حيّة + bool get isStale => + lastFixAt == null || DateTime.now().difference(lastFixAt!).inSeconds > 60; + + @override + void onInit() { + super.onInit(); + _fetch(); + _timer = Timer.periodic(const Duration(seconds: 6), (_) => _fetch()); + } + + void onMapCreated(IntaleqMapController controller) { + mapController = controller; + update(); + } + + void onStyleLoaded() { + isStyleLoaded = true; + _rebuildMarkers(); + _settleCamera(); + } + + Future _fetch() async { + final res = await CRUD().post( + link: '${AppLink.server}/food/order/courier_location.php', + payload: {'order_id': orderId.toString()}, + ); + + isLoading = false; + + if (res is! Map || res['status'] != 'success' || res['message'] is! Map) { + update(); + return; + } + + final data = Map.from(res['message']); + orderStatus = data['status']?.toString() ?? ''; + + final merchant = data['merchant']; + if (merchant is Map) { + merchantName = merchant['name_ar']?.toString() ?? ''; + merchantPosition = _latLng(merchant['lat'], merchant['lng']); + } + + final dest = data['destination']; + if (dest is Map) { + destinationAddress = dest['address']?.toString(); + destinationPosition = _latLng(dest['lat'], dest['lng']); + } + + final pos = data['courier_position']; + if (pos is Map) { + final next = _latLng(pos['lat'], pos['lng']); + if (next != null) { + courierPosition = next; + final ts = int.tryParse(pos['ts']?.toString() ?? ''); + lastFixAt = ts == null + ? DateTime.now() + : DateTime.fromMillisecondsSinceEpoch(ts * 1000); + } + } + + // انتهى الطلب — لا داعي لمواصلة السؤال، ولا لإبقاء علامة السائق + if (!isTrackingActive) { + _timer?.cancel(); + courierPosition = null; + } + + _rebuildMarkers(); + _settleCamera(); + update(); + } + + LatLng? _latLng(dynamic lat, dynamic lng) { + final la = double.tryParse(lat?.toString() ?? ''); + final ln = double.tryParse(lng?.toString() ?? ''); + if (la == null || ln == null || (la == 0 && ln == 0)) return null; + return LatLng(la, ln); + } + + void _rebuildMarkers() { + if (!isStyleLoaded) return; + + final next = {}; + + if (merchantPosition != null) { + next.add(Marker( + markerId: const MarkerId('merchant'), + position: merchantPosition!, + // نستعمل أصولاً موجودة فعلاً في المشروع — لا أيقونات مطعم/منزل مخصصة بعد + icon: InlqBitmap.fromAsset('assets/images/picker.png'), + anchor: const Offset(0.5, 0.5), + )); + } + if (destinationPosition != null) { + next.add(Marker( + markerId: const MarkerId('destination'), + position: destinationPosition!, + icon: InlqBitmap.fromAsset('assets/images/blob.png'), + anchor: const Offset(0.5, 0.5), + )); + } + if (courierPosition != null) { + next.add(Marker( + markerId: const MarkerId('courier'), + position: courierPosition!, + icon: InlqBitmap.fromAsset('assets/images/moto1.png'), + anchor: const Offset(0.5, 0.5), + )); + } + + markers = next; + } + + // نُحرّك الكاميرا مرة واحدة عند أول موقع، ثم نتبع السائق فقط. + // التحريك المستمر على كل تحديث يمنع الراكب من تصفّح الخريطة بيده. + void _settleCamera() { + if (!isStyleLoaded || mapController == null) return; + + final focus = courierPosition ?? merchantPosition ?? destinationPosition; + if (focus == null) return; + + if (!_cameraSettled) { + mapController!.animateCamera(CameraUpdate.newLatLngZoom(focus, 15)); + _cameraSettled = true; + } else if (courierPosition != null) { + mapController!.animateCamera(CameraUpdate.newLatLng(courierPosition!)); + } + } + + @override + void onClose() { + _timer?.cancel(); + super.onClose(); + } +} diff --git a/siro_rider/lib/views/food/food_order_tracking_page.dart b/siro_rider/lib/views/food/food_order_tracking_page.dart index 3353ae84..b2f91cfa 100644 --- a/siro_rider/lib/views/food/food_order_tracking_page.dart +++ b/siro_rider/lib/views/food/food_order_tracking_page.dart @@ -12,6 +12,7 @@ import '../../controller/voice_call_controller.dart'; import '../../controller/food/food_models.dart'; import '../widgets/my_scafold.dart'; import 'food_home_page.dart'; +import 'food_tracking_map_page.dart'; class FoodOrderTrackingPage extends StatefulWidget { final int orderId; @@ -130,6 +131,25 @@ class _FoodOrderTrackingPageState extends State { ], ), const SizedBox(height: 16), + // خريطة التتبّع تظهر فقط بعد إسناد سائق — قبلها لا يوجد ما يُتتبَّع + if (order.status == 'courier_assigned' || order.status == 'picked_up') + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: AppColor.accentColor, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + onPressed: () => Get.to(() => FoodTrackingMapPage(orderId: order.id)), + icon: const Icon(Icons.map_rounded, color: Colors.white), + label: Text( + _isAr ? 'تتبّع السائق على الخريطة' : 'Track courier on map', + style: const TextStyle(color: Colors.white), + ), + ), + ), + const SizedBox(height: 10), // الاتصال بالسائق متاح فقط أثناء التوصيل، وعبر قناة مقنّعة: // لا رقم هاتف يُعرض لأي طرف — جلسة صوتية مؤقتة تُقفل بانتهاء الطلب. if (order.status == 'courier_assigned' || order.status == 'picked_up') diff --git a/siro_rider/lib/views/food/food_tracking_map_page.dart b/siro_rider/lib/views/food/food_tracking_map_page.dart new file mode 100644 index 00000000..56f13930 --- /dev/null +++ b/siro_rider/lib/views/food/food_tracking_map_page.dart @@ -0,0 +1,113 @@ +// food_tracking_map_page.dart — خريطة تتبّع سائق التوصيل (جهة الراكب) +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intaleq_maps/intaleq_maps.dart'; + +import '../../constant/box_name.dart'; +import '../../constant/colors.dart'; +import '../../constant/style.dart'; +import '../../controller/food/food_tracking_controller.dart'; +import '../../env/env.dart'; +import '../../main.dart'; +import '../widgets/my_scafold.dart'; + +class FoodTrackingMapPage extends StatelessWidget { + final int orderId; + const FoodTrackingMapPage({super.key, required this.orderId}); + + bool get _isAr => box.read(BoxName.lang) == 'ar'; + + @override + Widget build(BuildContext context) { + final c = Get.put(FoodTrackingController(orderId), tag: 'food_track_$orderId'); + + return GetBuilder( + tag: 'food_track_$orderId', + builder: (_) => MyScafolld( + title: _isAr ? 'تتبّع السائق' : 'Track courier', + isleading: true, + body: [ + IntaleqMap( + apiKey: Env.mapSaasKey, + onMapCreated: c.onMapCreated, + onStyleLoaded: c.onStyleLoaded, + markers: c.markers, + initialCameraPosition: CameraPosition( + target: c.courierPosition ?? + c.merchantPosition ?? + c.destinationPosition ?? + const LatLng(31.9539, 35.9106), // عمّان — حتى يصل أول موقع + zoom: 14, + ), + ), + if (c.isLoading) + const Positioned.fill(child: Center(child: CircularProgressIndicator())), + Positioned(left: 12, right: 12, bottom: 20, child: _statusCard(c)), + ], + ), + ); + } + + Widget _statusCard(FoodTrackingController c) { + final String message; + final Color color; + + if (!c.isTrackingActive) { + message = _isAr + ? 'انتهى تتبّع هذا الطلب' + : 'Tracking for this order has ended'; + color = AppColor.grayColor; + } else if (c.courierPosition == null) { + message = _isAr + ? 'بانتظار إشارة موقع السائق…' + : 'Waiting for the courier location…'; + color = AppColor.grayColor; + } else if (c.isStale) { + // صراحةً بدل إيهام الراكب بحركة حيّة والعلامة مجمّدة على شاشته + message = _isAr + ? 'إشارة السائق ضعيفة — آخر موقع معروف' + : 'Weak courier signal — last known position'; + color = const Color(0xFFF29900); + } else if (c.orderStatus == 'picked_up') { + message = _isAr ? 'السائق في طريقه إليك' : 'Courier is on the way to you'; + color = AppColor.accentColor; + } else { + message = _isAr + ? 'السائق في طريقه إلى ${c.merchantName}' + : 'Courier heading to ${c.merchantName}'; + color = AppColor.accentColor; + } + + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColor.cardColor, + borderRadius: BorderRadius.circular(14), + boxShadow: [ + BoxShadow(color: Colors.black.withOpacity(0.12), blurRadius: 10, offset: const Offset(0, 3)), + ], + ), + child: Row( + children: [ + Icon(Icons.delivery_dining_rounded, color: color), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(message, style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)), + if (c.destinationAddress != null) + Text( + '${_isAr ? 'التسليم إلى' : 'Delivering to'}: ${c.destinationAddress}', + style: AppStyle.subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + ); + } +}