Food: single-source tracking, mode exclusivity, in-app navigation
This commit is contained in:
@@ -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 '../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<FoodOrderTrackingPage> {
|
||||
],
|
||||
),
|
||||
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')
|
||||
|
||||
@@ -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