Food: single-source tracking, mode exclusivity, in-app navigation
This commit is contained in:
@@ -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<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 {
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -98,6 +98,25 @@ class FoodDeliveryService {
|
||||
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 {
|
||||
final res = await CRUD().post(
|
||||
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 {
|
||||
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/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<Map<String, dynamic>> _trackBuffer = [];
|
||||
Timer? _recordTimer;
|
||||
Timer? _uploadBatchTimer;
|
||||
LatLng? _lastBufferedLocation;
|
||||
DateTime? _lastBufferedTime;
|
||||
LatLng? _lastDistanceLocation;
|
||||
|
||||
List<RouteData> 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<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);
|
||||
}
|
||||
// الرفع مركزيّ في LocationController — نطلب منه دفع ما تراكم فقط
|
||||
void _flushTracksViaCentral() {
|
||||
if (!Get.isRegistered<LocationController>()) return;
|
||||
Get.find<LocationController>().flushTrackBufferNow();
|
||||
}
|
||||
|
||||
Future<void> _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<TextToSpeechController>().speakText(currentInstruction);
|
||||
}
|
||||
CarPlatformBridge.stopNavigation();
|
||||
_flushBufferToServer();
|
||||
_flushTracksViaCentral();
|
||||
update();
|
||||
}
|
||||
|
||||
|
||||
@@ -100,7 +100,9 @@ class FoodDeliveryHomePage extends StatelessWidget {
|
||||
Text(
|
||||
c.isDeliveryModeEnabled
|
||||
? (c.isSocketConnected
|
||||
? 'متصل — ستصلك عروض التوصيل فوراً'
|
||||
// صريح عمداً: الوضع استعداد لا حجز. أول مهمة تصل —
|
||||
// رحلة كانت أو توصيلاً — تجعله مشغولاً عن الأخرى.
|
||||
? 'متصل — ستصلك عروض التوصيل، وقد تصلك رحلة أيضاً'
|
||||
: 'إعادة الاتصال… العروض تصل بالتحديث الدوري')
|
||||
: 'فعّله لاستقبال عروض توصيل الطعام',
|
||||
style: AppStyle.subtitle,
|
||||
|
||||
@@ -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<FoodTaskDetailsPage> {
|
||||
if (!res.success) mySnackbarWarning(res.message);
|
||||
}
|
||||
|
||||
Future<void> _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<void> _navigate(double lat, double lng, String title) =>
|
||||
FoodNavigation.startInApp(lat: lat, lng: lng, title: title);
|
||||
|
||||
Future<void> _navigateExternal(double lat, double lng) =>
|
||||
FoodNavigation.startExternal(lat, lng);
|
||||
|
||||
Future<void> _call(String phone) async {
|
||||
final uri = Uri(scheme: 'tel', path: phone);
|
||||
@@ -89,7 +89,10 @@ class _FoodTaskDetailsPageState extends State<FoodTaskDetailsPage> {
|
||||
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<FoodTaskDetailsPage> {
|
||||
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<FoodDeliveryController>().callCustomer(t.id),
|
||||
@@ -196,6 +204,7 @@ class _FoodTaskDetailsPageState extends State<FoodTaskDetailsPage> {
|
||||
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<FoodTaskDetailsPage> {
|
||||
if (onNavigate != null)
|
||||
IconButton(
|
||||
onPressed: onNavigate,
|
||||
// ضغطة مطوّلة = ملاحة جوجل، لمن اعتاد عليها
|
||||
onLongPress: onNavigateExternal,
|
||||
icon: Icon(Icons.navigation_rounded, color: AppColor.accentColor),
|
||||
tooltip: 'الملاحة',
|
||||
tooltip: 'ملاحة سيرو (اضغط مطوّلاً لجوجل)',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user