Food: single-source tracking, mode exclusivity, in-app navigation
This commit is contained in:
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
// food/courier/location.php — بثّ موقع السائق أثناء مهمة توصيل نشطة
|
||||||
|
//
|
||||||
|
// خصوصية: الموقع يُقبل فقط ما دام الطلب في courier_assigned/picked_up، ويُخزَّن
|
||||||
|
// في Redis بعمر 90 ثانية لا في قاعدة البيانات. بمجرد التسليم يرفض الخادم أي
|
||||||
|
// تحديث، وينتهي آخر موقع تلقائياً — فلا يتحول التتبّع إلى مراقبة للسائق بعد
|
||||||
|
// انتهاء عمله، ولا يبقى أثر دائم لتحركاته في وحدة الطعام.
|
||||||
|
require_once __DIR__ . '/../connect_courier.php';
|
||||||
|
|
||||||
|
$orderId = filterRequest('order_id', 'int');
|
||||||
|
$lat = filterRequest('lat');
|
||||||
|
$lng = filterRequest('lng');
|
||||||
|
|
||||||
|
if (!$orderId || $lat === null || $lng === null) {
|
||||||
|
jsonError('order_id, lat and lng are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
$lat = (float)$lat;
|
||||||
|
$lng = (float)$lng;
|
||||||
|
if ($lat < -90 || $lat > 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']]);
|
||||||
@@ -508,7 +508,22 @@ function foodFindNearbyCouriers(float $lat, float $lng, float $radiusKm = 5, int
|
|||||||
$optedIn = $redis->sMembers('food:couriers:opted_in');
|
$optedIn = $redis->sMembers('food:couriers:opted_in');
|
||||||
if (!$optedIn) return [];
|
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
|
function foodOfferOrderToCourier(int $orderId, string $courierId): void
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
// food/order/courier_location.php — آخر موقع معروف لسائق طلبي (للراكب)
|
||||||
|
//
|
||||||
|
// مسار سحب (polling): تطبيق الراكب لا يحمل عميل سوكيت للطعام حالياً، وهذا
|
||||||
|
// المصدر يكفي لخريطة تتبّع سلسة مع تحديث كل بضع ثوانٍ.
|
||||||
|
// الموقع نفسه ينتهي من Redis خلال 90 ثانية، فبعد التسليم — أو بعد توقف
|
||||||
|
// السائق عن البثّ — تُرجع الواجهة null بلا أي أثر متبقٍ.
|
||||||
|
require_once __DIR__ . '/../connect_app.php';
|
||||||
|
|
||||||
|
$orderId = filterRequest('order_id', 'int');
|
||||||
|
if (!$orderId) jsonError('order_id is required');
|
||||||
|
|
||||||
|
$order = foodAssertOrderOwnership($orderId, 'customer', $food_passenger_id);
|
||||||
|
|
||||||
|
// لا نكشف موقع السائق إلا خلال نافذة التوصيل — لا قبل الإسناد ولا بعد التسليم
|
||||||
|
if (!in_array($order['status'], ['courier_assigned', 'picked_up'], true)) {
|
||||||
|
jsonSuccess(['status' => $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,
|
||||||
|
]);
|
||||||
@@ -144,6 +144,13 @@ $io->on('workerStart', function () use ($io, $INTERNAL_KEY, $INTERNAL_PORT) {
|
|||||||
$io->to('courier_food_' . $courierId)->emit('food_delivery_offer', $payload);
|
$io->to('courier_food_' . $courierId)->emit('food_delivery_offer', $payload);
|
||||||
socket_log("[HTTP_SUCCESS] courier_offer pushed to courier #$courierId", $payload);
|
socket_log("[HTTP_SUCCESS] courier_offer pushed to courier #$courierId", $payload);
|
||||||
$connection->send('OK');
|
$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') {
|
} elseif ($action === 'courier_call') {
|
||||||
// مكالمة مقنّعة من الزبون إلى السائق — نمرّر session_id فقط،
|
// مكالمة مقنّعة من الزبون إلى السائق — نمرّر session_id فقط،
|
||||||
// لا رقم هاتف ولا هوية حقيقية لأي طرف.
|
// لا رقم هاتف ولا هوية حقيقية لأي طرف.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import '../../main.dart';
|
|||||||
import '../../print.dart';
|
import '../../print.dart';
|
||||||
import '../../views/food_delivery/food_offer_page.dart';
|
import '../../views/food_delivery/food_offer_page.dart';
|
||||||
import '../../views/widgets/error_snakbar.dart';
|
import '../../views/widgets/error_snakbar.dart';
|
||||||
|
import '../functions/location_controller.dart';
|
||||||
import '../voice_call_controller.dart';
|
import '../voice_call_controller.dart';
|
||||||
import 'food_delivery_models.dart';
|
import 'food_delivery_models.dart';
|
||||||
import 'food_delivery_service.dart';
|
import 'food_delivery_service.dart';
|
||||||
@@ -235,6 +236,26 @@ class FoodDeliveryController extends GetxController {
|
|||||||
await _fetchPendingOffers();
|
await _fetchPendingOffers();
|
||||||
}
|
}
|
||||||
await fetchActiveTasks(silent: true);
|
await fetchActiveTasks(silent: true);
|
||||||
|
await _broadcastLocation();
|
||||||
|
}
|
||||||
|
|
||||||
|
// موقع السائق يُبثّ فقط ما دامت هناك مهمة نشطة — لا شيء يُرسل قبل الإسناد
|
||||||
|
// ولا بعد التسليم، والخادم يرفضه في الحالتين أصلاً كطبقة ثانية.
|
||||||
|
Future<void> _broadcastLocation() async {
|
||||||
|
if (activeTasks.isEmpty) return;
|
||||||
|
if (!Get.isRegistered<LocationController>()) return;
|
||||||
|
|
||||||
|
final loc = Get.find<LocationController>();
|
||||||
|
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<void> _fetchPendingOffers() async {
|
Future<void> _fetchPendingOffers() async {
|
||||||
@@ -253,9 +274,42 @@ class FoodDeliveryController extends GetxController {
|
|||||||
final res = await FoodDeliveryService.getActiveTasks();
|
final res = await FoodDeliveryService.getActiveTasks();
|
||||||
isLoadingTasks = false;
|
isLoadingTasks = false;
|
||||||
if (res.success) activeTasks = res.data ?? [];
|
if (res.success) activeTasks = res.data ?? [];
|
||||||
|
_syncRideAvailability();
|
||||||
update();
|
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 isRespondingToOffer(int orderId) => _respondingOfferIds.contains(orderId);
|
||||||
bool isTaskBusy(int orderId) => _busyTaskIds.contains(orderId);
|
bool isTaskBusy(int orderId) => _busyTaskIds.contains(orderId);
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,25 @@ class FoodDeliveryService {
|
|||||||
return FoodDeliveryApiResult(false, null, _errMsg(res));
|
return FoodDeliveryApiResult(false, null, _errMsg(res));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// بثّ موقع السائق للزبون أثناء المهمة. الخادم يرفضه بعد التسليم،
|
||||||
|
/// وينتهي آخر موقع من Redis تلقائياً — لا تتبّع خارج نافذة العمل.
|
||||||
|
static Future<void> 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<FoodDeliveryApiResult<void>> markPickedUp(int orderId) async {
|
static Future<FoodDeliveryApiResult<void>> markPickedUp(int orderId) async {
|
||||||
final res = await CRUD().post(
|
final res = await CRUD().post(
|
||||||
link: '$_base/courier/picked_up.php',
|
link: '$_base/courier/picked_up.php',
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// food_navigation.dart — تشغيل ملاحة سيرو الداخلية لمهام التوصيل
|
||||||
|
//
|
||||||
|
// لماذا النسخة نفسها (بلا tag) لا نسخة مستقلة للتوصيل:
|
||||||
|
// خطّ تحديث الموقع المركزي في LocationController يُغذّي نسخة واحدة فقط
|
||||||
|
// (Get.find<NavigationController> بلا وسم). نسخة موسومة للتوصيل ستُحرم من
|
||||||
|
// تحديثات الموقع فلا تعمل الملاحة أصلاً، وتغذيتها يستلزم تعديل مسار الموقع
|
||||||
|
// وهو المسار الأخطر في التطبيق. فنستعمل النسخة المشتركة، ونعالج التضارب
|
||||||
|
// الوحيد الممكن (سائق في ملاحة رحلة ثم يفتح مهمة توصيل) بسؤاله صراحةً
|
||||||
|
// بدل استبدال مساره صامتاً.
|
||||||
|
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<bool> startInApp({
|
||||||
|
required double lat,
|
||||||
|
required double lng,
|
||||||
|
required String title,
|
||||||
|
}) async {
|
||||||
|
final controller = Get.isRegistered<NavigationController>()
|
||||||
|
? Get.find<NavigationController>()
|
||||||
|
: Get.put(NavigationController());
|
||||||
|
|
||||||
|
if (controller.isNavigating) {
|
||||||
|
final replace = await Get.dialog<bool>(
|
||||||
|
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<void> 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('تعذّر فتح تطبيق الخرائط');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -783,6 +783,11 @@ class LocationController extends GetxController with WidgetsBindingObserver {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// دفع ما تراكم من نقاط المسار فوراً بدل انتظار المؤقّت.
|
||||||
|
/// تستدعيها شاشة الملاحة عند انتهاء الملاحة، بعد أن صار الرفع مركزيّاً هنا
|
||||||
|
/// وحده (كان الملّاح يرفع دفعة موازية إلى نفس الواجهة فيتضاعف الحساب).
|
||||||
|
Future<void> flushTrackBufferNow() => _flushBufferToServer();
|
||||||
|
|
||||||
Future<void> _flushBufferToServer() async {
|
Future<void> _flushBufferToServer() async {
|
||||||
if (_trackBuffer.isEmpty) return;
|
if (_trackBuffer.isEmpty) return;
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'package:siro_driver/constant/box_name.dart';
|
|||||||
import 'package:siro_driver/constant/links.dart';
|
import 'package:siro_driver/constant/links.dart';
|
||||||
import 'package:siro_driver/controller/car_platform_bridge.dart';
|
import 'package:siro_driver/controller/car_platform_bridge.dart';
|
||||||
import 'package:siro_driver/controller/functions/crud.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/functions/tts.dart';
|
||||||
import 'package:siro_driver/controller/home/navigation/decode_polyline_isolate.dart';
|
import 'package:siro_driver/controller/home/navigation/decode_polyline_isolate.dart';
|
||||||
import 'package:siro_driver/env/env.dart';
|
import 'package:siro_driver/env/env.dart';
|
||||||
@@ -39,9 +40,6 @@ class RouteData {
|
|||||||
|
|
||||||
class NavigationController extends GetxController
|
class NavigationController extends GetxController
|
||||||
with GetSingleTickerProviderStateMixin {
|
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 _minMoveToProcess = 2.0;
|
||||||
static const double _offRouteThresholdM = 25.0;
|
static const double _offRouteThresholdM = 25.0;
|
||||||
static const int _offRouteTriggerSeconds = 6;
|
static const int _offRouteTriggerSeconds = 6;
|
||||||
@@ -164,11 +162,6 @@ class NavigationController extends GetxController
|
|||||||
DateTime? _offRouteStartTime;
|
DateTime? _offRouteStartTime;
|
||||||
bool _autoRecalcInProgress = false;
|
bool _autoRecalcInProgress = false;
|
||||||
|
|
||||||
final List<Map<String, dynamic>> _trackBuffer = [];
|
|
||||||
Timer? _recordTimer;
|
|
||||||
Timer? _uploadBatchTimer;
|
|
||||||
LatLng? _lastBufferedLocation;
|
|
||||||
DateTime? _lastBufferedTime;
|
|
||||||
LatLng? _lastDistanceLocation;
|
LatLng? _lastDistanceLocation;
|
||||||
|
|
||||||
List<RouteData> routes = [];
|
List<RouteData> routes = [];
|
||||||
@@ -364,13 +357,11 @@ class NavigationController extends GetxController
|
|||||||
@override
|
@override
|
||||||
void onClose() {
|
void onClose() {
|
||||||
_locationStreamSubscription?.cancel();
|
_locationStreamSubscription?.cancel();
|
||||||
_recordTimer?.cancel();
|
|
||||||
_uploadBatchTimer?.cancel();
|
|
||||||
_debounce?.cancel();
|
_debounce?.cancel();
|
||||||
_animController?.dispose();
|
_animController?.dispose();
|
||||||
mapController = null;
|
mapController = null;
|
||||||
placeDestinationController.dispose();
|
placeDestinationController.dispose();
|
||||||
_flushBufferToServer();
|
_flushTracksViaCentral();
|
||||||
super.onClose();
|
super.onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,7 +466,6 @@ class NavigationController extends GetxController
|
|||||||
if (isStyleLoaded) animateCameraToPosition(myLocation!);
|
if (isStyleLoaded) animateCameraToPosition(myLocation!);
|
||||||
// Start the Location Stream for real-time updates
|
// Start the Location Stream for real-time updates
|
||||||
_startLocationStream();
|
_startLocationStream();
|
||||||
_startBatchTimers();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Log.print("DEBUG: Error getting initial location: $e");
|
Log.print("DEBUG: Error getting initial location: $e");
|
||||||
}
|
}
|
||||||
@@ -616,63 +606,16 @@ class NavigationController extends GetxController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startBatchTimers() {
|
// رفع مسار القيادة (add_batch) مركزيّ في LocationController وحده.
|
||||||
_recordTimer?.cancel();
|
// كان الملّاح يحتفظ بذاكرة ومؤقّتات رفع خاصة به ويُرسل إلى نفس الواجهة
|
||||||
_uploadBatchTimer?.cancel();
|
// ونفس الخادم، فيُكتب مسار السائق الواحد مرتين ما دامت شاشة الملاحة
|
||||||
_recordTimer = Timer.periodic(_recordInterval, (_) => _recordToBuffer());
|
// مفتوحة — ولأن add_batch.php يراكم زمن العمل اليومي من نفس الجدول،
|
||||||
_uploadBatchTimer =
|
// كان ذلك يضاعف ساعات العمل المحسوبة. مصدر الموقع واحد فالرفع واحد.
|
||||||
Timer.periodic(_uploadInterval, (_) => _flushBufferToServer());
|
|
||||||
}
|
|
||||||
|
|
||||||
void _recordToBuffer() {
|
// الرفع مركزيّ في LocationController — نطلب منه دفع ما تراكم فقط
|
||||||
if (myLocation == null ||
|
void _flushTracksViaCentral() {
|
||||||
(myLocation!.latitude == 0 && myLocation!.longitude == 0)) {
|
if (!Get.isRegistered<LocationController>()) return;
|
||||||
return;
|
Get.find<LocationController>().flushTrackBufferNow();
|
||||||
}
|
|
||||||
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<void> _flushBufferToServer() async {
|
|
||||||
if (_trackBuffer.isEmpty) return;
|
|
||||||
final batch = List<Map<String, dynamic>>.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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _updateCarMarker() async {
|
Future<void> _updateCarMarker() async {
|
||||||
@@ -1179,7 +1122,7 @@ class NavigationController extends GetxController
|
|||||||
isNavigating = false;
|
isNavigating = false;
|
||||||
routes = [];
|
routes = [];
|
||||||
CarPlatformBridge.stopNavigation();
|
CarPlatformBridge.stopNavigation();
|
||||||
await _flushBufferToServer();
|
_flushTracksViaCentral();
|
||||||
}
|
}
|
||||||
routeSteps = [];
|
routeSteps = [];
|
||||||
_fullRouteCoordinates = [];
|
_fullRouteCoordinates = [];
|
||||||
@@ -1277,7 +1220,7 @@ class NavigationController extends GetxController
|
|||||||
Get.find<TextToSpeechController>().speakText(currentInstruction);
|
Get.find<TextToSpeechController>().speakText(currentInstruction);
|
||||||
}
|
}
|
||||||
CarPlatformBridge.stopNavigation();
|
CarPlatformBridge.stopNavigation();
|
||||||
_flushBufferToServer();
|
_flushTracksViaCentral();
|
||||||
update();
|
update();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,9 @@ class FoodDeliveryHomePage extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
c.isDeliveryModeEnabled
|
c.isDeliveryModeEnabled
|
||||||
? (c.isSocketConnected
|
? (c.isSocketConnected
|
||||||
? 'متصل — ستصلك عروض التوصيل فوراً'
|
// صريح عمداً: الوضع استعداد لا حجز. أول مهمة تصل —
|
||||||
|
// رحلة كانت أو توصيلاً — تجعله مشغولاً عن الأخرى.
|
||||||
|
? 'متصل — ستصلك عروض التوصيل، وقد تصلك رحلة أيضاً'
|
||||||
: 'إعادة الاتصال… العروض تصل بالتحديث الدوري')
|
: 'إعادة الاتصال… العروض تصل بالتحديث الدوري')
|
||||||
: 'فعّله لاستقبال عروض توصيل الطعام',
|
: 'فعّله لاستقبال عروض توصيل الطعام',
|
||||||
style: AppStyle.subtitle,
|
style: AppStyle.subtitle,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:url_launcher/url_launcher.dart';
|
|||||||
import '../../constant/colors.dart';
|
import '../../constant/colors.dart';
|
||||||
import '../../constant/style.dart';
|
import '../../constant/style.dart';
|
||||||
import '../../controller/food_delivery/food_delivery_controller.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_models.dart';
|
||||||
import '../../controller/food_delivery/food_delivery_service.dart';
|
import '../../controller/food_delivery/food_delivery_service.dart';
|
||||||
import '../widgets/elevated_btn.dart';
|
import '../widgets/elevated_btn.dart';
|
||||||
@@ -41,14 +42,13 @@ class _FoodTaskDetailsPageState extends State<FoodTaskDetailsPage> {
|
|||||||
if (!res.success) mySnackbarWarning(res.message);
|
if (!res.success) mySnackbarWarning(res.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openMaps(double lat, double lng) async {
|
/// الملاحة الداخلية هي الافتراضي: تُبقي السائق داخل التطبيق فيظل موقعه
|
||||||
// نفتح تطبيق الخرائط الافتراضي على الجهاز — لا نضمّن مفاتيح ولا نمرّر
|
/// مرصوداً ويستمر بثّه للزبون. جوجل يبقى خياراً ثانوياً بضغطة مطوّلة.
|
||||||
// بيانات الزبون في الرابط سوى الإحداثيات اللازمة للملاحة.
|
Future<void> _navigate(double lat, double lng, String title) =>
|
||||||
final uri = Uri.parse('https://www.google.com/maps/dir/?api=1&destination=$lat,$lng');
|
FoodNavigation.startInApp(lat: lat, lng: lng, title: title);
|
||||||
if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
|
|
||||||
mySnackbarWarning('تعذّر فتح تطبيق الخرائط');
|
Future<void> _navigateExternal(double lat, double lng) =>
|
||||||
}
|
FoodNavigation.startExternal(lat, lng);
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _call(String phone) async {
|
Future<void> _call(String phone) async {
|
||||||
final uri = Uri(scheme: 'tel', path: phone);
|
final uri = Uri(scheme: 'tel', path: phone);
|
||||||
@@ -89,7 +89,10 @@ class _FoodTaskDetailsPageState extends State<FoodTaskDetailsPage> {
|
|||||||
title: t.merchantNameAr,
|
title: t.merchantNameAr,
|
||||||
subtitle: t.merchantAddress ?? 'مطعم',
|
subtitle: t.merchantAddress ?? 'مطعم',
|
||||||
onNavigate: (t.merchantLat != null && t.merchantLng != null)
|
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,
|
: null,
|
||||||
onCall: d.merchantPhone == null ? null : () => _call(d.merchantPhone!),
|
onCall: d.merchantPhone == null ? null : () => _call(d.merchantPhone!),
|
||||||
),
|
),
|
||||||
@@ -99,7 +102,12 @@ class _FoodTaskDetailsPageState extends State<FoodTaskDetailsPage> {
|
|||||||
color: AppColor.greenColor,
|
color: AppColor.greenColor,
|
||||||
title: 'عنوان الزبون',
|
title: 'عنوان الزبون',
|
||||||
subtitle: t.deliveryAddress ?? 'غير متاح',
|
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<FoodDeliveryController>().callCustomer(t.id),
|
onCall: () => Get.find<FoodDeliveryController>().callCustomer(t.id),
|
||||||
@@ -196,6 +204,7 @@ class _FoodTaskDetailsPageState extends State<FoodTaskDetailsPage> {
|
|||||||
required String title,
|
required String title,
|
||||||
required String subtitle,
|
required String subtitle,
|
||||||
VoidCallback? onNavigate,
|
VoidCallback? onNavigate,
|
||||||
|
VoidCallback? onNavigateExternal,
|
||||||
VoidCallback? onCall,
|
VoidCallback? onCall,
|
||||||
IconData callIcon = Icons.call_rounded,
|
IconData callIcon = Icons.call_rounded,
|
||||||
String callTooltip = 'اتصال',
|
String callTooltip = 'اتصال',
|
||||||
@@ -232,8 +241,10 @@ class _FoodTaskDetailsPageState extends State<FoodTaskDetailsPage> {
|
|||||||
if (onNavigate != null)
|
if (onNavigate != null)
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: onNavigate,
|
onPressed: onNavigate,
|
||||||
|
// ضغطة مطوّلة = ملاحة جوجل، لمن اعتاد عليها
|
||||||
|
onLongPress: onNavigateExternal,
|
||||||
icon: Icon(Icons.navigation_rounded, color: AppColor.accentColor),
|
icon: Icon(Icons.navigation_rounded, color: AppColor.accentColor),
|
||||||
tooltip: 'الملاحة',
|
tooltip: 'ملاحة سيرو (اضغط مطوّلاً لجوجل)',
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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<Marker> 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<void> _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<String, dynamic>.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 = <Marker>{};
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import '../../controller/voice_call_controller.dart';
|
|||||||
import '../../controller/food/food_models.dart';
|
import '../../controller/food/food_models.dart';
|
||||||
import '../widgets/my_scafold.dart';
|
import '../widgets/my_scafold.dart';
|
||||||
import 'food_home_page.dart';
|
import 'food_home_page.dart';
|
||||||
|
import 'food_tracking_map_page.dart';
|
||||||
|
|
||||||
class FoodOrderTrackingPage extends StatefulWidget {
|
class FoodOrderTrackingPage extends StatefulWidget {
|
||||||
final int orderId;
|
final int orderId;
|
||||||
@@ -130,6 +131,25 @@ class _FoodOrderTrackingPageState extends State<FoodOrderTrackingPage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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')
|
if (order.status == 'courier_assigned' || order.status == 'picked_up')
|
||||||
|
|||||||
@@ -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<FoodTrackingController>(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user