Food delivery driver module + masked calls + TURN
This commit is contained in:
@@ -133,4 +133,7 @@ class BoxName {
|
||||
// توفّر Google Play Services — يحدّد شراسة ملف تتبّع الموقع
|
||||
// (بلا GMS يسقط الباكيج إلى LocationManager الخام: بطارية أعلى، دقة أقل)
|
||||
static const String isGmsAvailable = 'isGmsAvailable';
|
||||
|
||||
// بيانات اعتماد TURN المؤقتة (مكالمات الصوت) — مخزّنة حتى قرب انتهائها
|
||||
static const String turnIceCache = 'turnIceCache';
|
||||
}
|
||||
|
||||
@@ -82,6 +82,23 @@ class AppLink {
|
||||
}
|
||||
}
|
||||
|
||||
/// سوكيت الطعام — قناة مستقلة تماماً عن سوكيت الرحلات (locationSocketUrl).
|
||||
/// عملية منفصلة (food_socket.php) وبورت منفصل 4040، فانقطاع أحدهما لا يُسقط
|
||||
/// الآخر: السائق يظل يستقبل الرحلات وإن سقط سوكيت الطعام والعكس.
|
||||
/// nginx على المضيف يستمع 4040 بالشهادة ويمرّر إلى 14040 داخل دوكر.
|
||||
static String get foodSocketUrl {
|
||||
switch (currentCountry) {
|
||||
case 'Syria':
|
||||
return 'https://food-syria.siromove.com:4040';
|
||||
case 'Egypt':
|
||||
return 'https://food-egypt.siromove.com:4040';
|
||||
case 'Jordan':
|
||||
return 'https://jordan-siro.intaleqapp.com:4040';
|
||||
default:
|
||||
return 'https://jordan-siro.intaleqapp.com:4040';
|
||||
}
|
||||
}
|
||||
|
||||
static String get mapSaasRoute {
|
||||
switch (currentCountry) {
|
||||
case 'Syria':
|
||||
|
||||
@@ -15,8 +15,13 @@ import '../../constant/box_name.dart';
|
||||
import '../../constant/links.dart';
|
||||
import '../../main.dart'; // للوصول لـ box
|
||||
import '../../print.dart';
|
||||
import '../../views/food_delivery/food_delivery_home_page.dart';
|
||||
import '../../views/food_delivery/food_offer_page.dart';
|
||||
import '../../views/food_delivery/food_task_details_page.dart';
|
||||
import '../../views/home/Captin/driver_map_page.dart';
|
||||
import '../../views/home/Captin/orderCaptin/order_request_page.dart';
|
||||
import '../food_delivery/food_delivery_controller.dart';
|
||||
import '../food_delivery/food_notification_service.dart';
|
||||
import '../functions/crud.dart';
|
||||
import '../home/captin/home_captain_controller.dart';
|
||||
|
||||
@@ -213,6 +218,14 @@ class NotificationController extends GetxController {
|
||||
if (payload == null) return;
|
||||
|
||||
final payloadData = jsonDecode(payload) as Map<String, dynamic>;
|
||||
|
||||
// إشعارات وحدة التوصيل لها حمولة وأزرار خاصة بها — نفرزها قبل أي شيء
|
||||
// لأن باقي هذه الدالة يفترض حمولة رحلة (مصفوفة add_ride.php).
|
||||
if (payloadData.containsKey('food_event')) {
|
||||
await _handleFoodNotificationResponse(response, payloadData);
|
||||
return;
|
||||
}
|
||||
|
||||
final rawData = payloadData['data'];
|
||||
|
||||
List<dynamic> listData = [];
|
||||
@@ -248,6 +261,38 @@ class NotificationController extends GetxController {
|
||||
}
|
||||
}
|
||||
|
||||
// ── فرع وحدة التوصيل — مسار مستقل تماماً عن مسار الرحلات أعلاه ──────────
|
||||
Future<void> _handleFoodNotificationResponse(
|
||||
NotificationResponse response, Map<String, dynamic> payloadData) async {
|
||||
final orderId = int.tryParse(payloadData['order_id']?.toString() ?? '') ?? 0;
|
||||
if (orderId == 0) return;
|
||||
|
||||
await FoodNotificationService.instance.cancelOfferNotification();
|
||||
|
||||
// الكنترولر قد لا يكون مسجَّلاً (الضغط على الإشعار والتطبيق مغلق)، فنسجّله
|
||||
// ليعيد بناء حالته من الخادم قبل عرض أي شاشة.
|
||||
final controller = Get.isRegistered<FoodDeliveryController>()
|
||||
? Get.find<FoodDeliveryController>()
|
||||
: Get.put(FoodDeliveryController());
|
||||
|
||||
if (response.actionId == 'FOOD_ACCEPT') {
|
||||
await controller.respondToOffer(orderId, true);
|
||||
Get.to(() => FoodTaskDetailsPage(orderId: orderId));
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.actionId == 'FOOD_REJECT') {
|
||||
await controller.respondToOffer(orderId, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (payloadData['food_event'] == 'offer') {
|
||||
Get.to(() => const FoodOfferPage(), fullscreenDialog: true);
|
||||
} else {
|
||||
Get.to(() => const FoodDeliveryHomePage());
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================================================================
|
||||
// 4. منطق القبول الآمن (Safe Accept Logic)
|
||||
// ==============================================================================
|
||||
|
||||
@@ -1,37 +1,87 @@
|
||||
// food_delivery_controller.dart — حالة تبويب التوصيل (وضع التوصيل، العروض، المهام النشطة)
|
||||
//
|
||||
// مصادر الأحداث ثلاثة، مرتّبة بالأولوية:
|
||||
// 1) سوكيت الطعام المستقل (FoodSocketService) — المسار اللحظي الأساسي.
|
||||
// 2) polling لـ pending_offers.php — يعمل فقط حين ينقطع السوكيت (لا نُحمّل
|
||||
// الخادم نداءً كل 4 ثوانٍ بينما القناة الحية شغّالة).
|
||||
// 3) active.php — مصدر الحقيقة لحالة المهام، يُصحّح أي فارق بعد أي انقطاع.
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:get/get.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
|
||||
import '../../constant/box_name.dart';
|
||||
import '../../constant/links.dart';
|
||||
import '../../main.dart';
|
||||
import '../../print.dart';
|
||||
import '../../views/food_delivery/food_offer_page.dart';
|
||||
import '../../views/widgets/error_snakbar.dart';
|
||||
import '../voice_call_controller.dart';
|
||||
import 'food_delivery_models.dart';
|
||||
import 'food_delivery_service.dart';
|
||||
import 'food_notification_service.dart';
|
||||
import 'food_socket_service.dart';
|
||||
|
||||
class FoodDeliveryController extends GetxController {
|
||||
bool isDeliveryModeEnabled = false;
|
||||
bool isTogglingMode = false;
|
||||
bool isSocketConnected = false;
|
||||
|
||||
List<FoodDeliveryOffer> pendingOffers = [];
|
||||
List<FoodDeliveryTask> activeTasks = [];
|
||||
bool isLoadingTasks = false;
|
||||
|
||||
int todayOrdersCount = 0;
|
||||
int todayEarnings = 0;
|
||||
|
||||
final Set<int> _respondingOfferIds = {};
|
||||
final Set<int> _busyTaskIds = {};
|
||||
// معرّفات عروض عُرضت أو انتهت — يمنع تكرار فتح الشاشة حين يصل نفس العرض
|
||||
// من السوكيت ومن polling معاً.
|
||||
final Set<int> _handledOfferIds = {};
|
||||
|
||||
Timer? _pollTimer;
|
||||
Timer? _countdownTimer;
|
||||
final AudioPlayer _audioPlayer = AudioPlayer();
|
||||
bool _isOfferScreenOpen = false;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
_refreshAll();
|
||||
_pollTimer = Timer.periodic(const Duration(seconds: 4), (_) => _refreshAll());
|
||||
FoodNotificationService.instance.ensureChannels();
|
||||
_bindSocketHandlers();
|
||||
_bootstrap();
|
||||
|
||||
// نبضة خفيفة: المهام دائماً، والعروض فقط إذا كان السوكيت مقطوعاً
|
||||
_pollTimer = Timer.periodic(const Duration(seconds: 8), (_) => _pollTick());
|
||||
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) => _tickOffers());
|
||||
}
|
||||
|
||||
Future<void> _refreshAll() async {
|
||||
if (isDeliveryModeEnabled) {
|
||||
await _fetchPendingOffers();
|
||||
}
|
||||
Future<void> _bootstrap() async {
|
||||
await refreshStatus();
|
||||
await fetchActiveTasks();
|
||||
}
|
||||
|
||||
// ── وضع التوصيل ────────────────────────────────────────────────────────
|
||||
Future<void> refreshStatus() async {
|
||||
final res = await FoodDeliveryService.getStatus();
|
||||
if (!res.success || res.data == null) return;
|
||||
|
||||
final s = res.data!;
|
||||
isDeliveryModeEnabled = s.deliveryModeEnabled;
|
||||
todayOrdersCount = s.todayOrdersCount;
|
||||
todayEarnings = s.todayEarnings;
|
||||
|
||||
// الخادم هو مرجع الوضع — نوصل/نقطع السوكيت تبعاً له لا تبعاً لحالة الشاشة
|
||||
if (isDeliveryModeEnabled) {
|
||||
await FoodSocketService.instance.connect();
|
||||
} else {
|
||||
FoodSocketService.instance.disconnect();
|
||||
pendingOffers.clear();
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
Future<void> toggleDeliveryMode(bool enable) async {
|
||||
isTogglingMode = true;
|
||||
update();
|
||||
@@ -41,23 +91,165 @@ class FoodDeliveryController extends GetxController {
|
||||
|
||||
if (res.success) {
|
||||
isDeliveryModeEnabled = enable;
|
||||
if (!enable) pendingOffers = [];
|
||||
if (enable) {
|
||||
await FoodSocketService.instance.connect();
|
||||
} else {
|
||||
FoodSocketService.instance.disconnect();
|
||||
pendingOffers.clear();
|
||||
FoodNotificationService.instance.cancelOfferNotification();
|
||||
}
|
||||
} else {
|
||||
mySnackbarWarning(res.message);
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
Future<void> _fetchPendingOffers() async {
|
||||
final res = await FoodDeliveryService.getPendingOffers();
|
||||
if (res.success) {
|
||||
pendingOffers = res.data ?? [];
|
||||
// ── السوكيت ────────────────────────────────────────────────────────────
|
||||
void _bindSocketHandlers() {
|
||||
final socket = FoodSocketService.instance;
|
||||
socket.onOffer = (payload) => _handleIncomingOffer(FoodDeliveryOffer.fromJson(payload));
|
||||
socket.onOrderUpdate = _handleOrderUpdate;
|
||||
socket.onIncomingCall = _handleIncomingCall;
|
||||
socket.onConnectionChanged = (connected) {
|
||||
isSocketConnected = connected;
|
||||
// بعد أي إعادة اتصال نسحب الحالة من الخادم — السوكيت ناقل لا مخزن حالة
|
||||
if (connected) {
|
||||
fetchActiveTasks();
|
||||
_fetchPendingOffers();
|
||||
}
|
||||
update();
|
||||
};
|
||||
}
|
||||
|
||||
void _handleOrderUpdate(Map<String, dynamic> payload) {
|
||||
final status = payload['status']?.toString() ?? '';
|
||||
final orderId = int.tryParse(payload['order_id']?.toString() ?? '') ?? 0;
|
||||
|
||||
if (status.startsWith('cancelled') && orderId != 0) {
|
||||
FoodNotificationService.instance.showStatusNotification(
|
||||
orderId: orderId,
|
||||
title: 'أُلغي طلب التوصيل',
|
||||
body: 'الطلب #$orderId أُلغي — لا حاجة لمتابعته',
|
||||
);
|
||||
mySnackbarWarning('أُلغي طلب التوصيل #$orderId');
|
||||
}
|
||||
fetchActiveTasks();
|
||||
}
|
||||
|
||||
// ── المكالمة المقنّعة ───────────────────────────────────────────────────
|
||||
// الزبون بادر بالاتصال: الحمولة لا تحمل رقماً ولا اسماً حقيقياً، فقط
|
||||
// session_id ننضم به لنفس خادم الإشارات المستعمل في مكالمات الرحلات.
|
||||
void _handleIncomingCall(Map<String, dynamic> payload) {
|
||||
final sessionId = payload['session_id']?.toString() ?? '';
|
||||
final orderId = payload['order_id']?.toString() ?? '';
|
||||
if (sessionId.isEmpty || orderId.isEmpty) return;
|
||||
|
||||
// مسجَّل عالمياً بـ lazyPut(fenix) في main — والـ put احتياط لو أُزيل
|
||||
final callController = _voiceCall();
|
||||
|
||||
callController.receiveCall(
|
||||
sessionIdVal: sessionId,
|
||||
remoteNameVal: payload['caller_name']?.toString() ?? 'زبون الطلب',
|
||||
rideIdVal: 'food_$orderId',
|
||||
);
|
||||
}
|
||||
|
||||
/// السائق يتصل بالزبون — لا يرى رقمه ولا يُخزَّن عنده
|
||||
Future<void> callCustomer(int orderId) async {
|
||||
// مسجَّل عالمياً بـ lazyPut(fenix) في main — والـ put احتياط لو أُزيل
|
||||
final callController = _voiceCall();
|
||||
|
||||
await callController.startCall(
|
||||
rideIdVal: 'food_$orderId',
|
||||
driverId: box.read(BoxName.driverID).toString(),
|
||||
passengerId: '', // هوية الزبون لا تصل التطبيق — الخادم يحلّها من الطلب
|
||||
remoteNameVal: 'زبون الطلب',
|
||||
sessionEndpoint: '${AppLink.server}/food/courier/call_customer.php',
|
||||
sessionPayload: {'order_id': orderId.toString()},
|
||||
);
|
||||
}
|
||||
|
||||
VoiceCallController _voiceCall() {
|
||||
try {
|
||||
return Get.find<VoiceCallController>();
|
||||
} catch (_) {
|
||||
return Get.put(VoiceCallController());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchActiveTasks() async {
|
||||
isLoadingTasks = true;
|
||||
// ── العروض ─────────────────────────────────────────────────────────────
|
||||
void _handleIncomingOffer(FoodDeliveryOffer offer) {
|
||||
if (offer.orderId == 0 || offer.isExpired) return;
|
||||
if (_handledOfferIds.contains(offer.orderId)) return;
|
||||
if (pendingOffers.any((o) => o.orderId == offer.orderId)) return;
|
||||
|
||||
_handledOfferIds.add(offer.orderId);
|
||||
pendingOffers.add(offer);
|
||||
update();
|
||||
|
||||
_playOfferSound();
|
||||
|
||||
final isForeground = box.read(BoxName.isAppInForeground) ?? false;
|
||||
if (isForeground == true) {
|
||||
_openOfferScreen();
|
||||
} else {
|
||||
// التطبيق في الخلفية — إشعار ملء شاشة بقناة التوصيل الخاصة
|
||||
FoodNotificationService.instance.showOfferNotification(offer);
|
||||
}
|
||||
}
|
||||
|
||||
void _openOfferScreen() {
|
||||
if (_isOfferScreenOpen) return;
|
||||
_isOfferScreenOpen = true;
|
||||
Get.to(() => const FoodOfferPage(), fullscreenDialog: true)?.whenComplete(() {
|
||||
_isOfferScreenOpen = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _playOfferSound() async {
|
||||
try {
|
||||
await _audioPlayer.setAsset('assets/order1.wav');
|
||||
await _audioPlayer.play();
|
||||
} catch (e) {
|
||||
Log.print('🍔 [FoodDelivery] تعذّر تشغيل نغمة العرض: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// انتهاء المهلة يُدار محلياً للعرض فقط — الخادم يرفض أي قبول متأخر أصلاً
|
||||
void _tickOffers() {
|
||||
if (pendingOffers.isEmpty) return;
|
||||
final before = pendingOffers.length;
|
||||
pendingOffers.removeWhere((o) => o.isExpired);
|
||||
if (pendingOffers.isEmpty && before > 0) {
|
||||
FoodNotificationService.instance.cancelOfferNotification();
|
||||
if (_isOfferScreenOpen && Get.currentRoute.contains('FoodOfferPage')) {
|
||||
Get.back();
|
||||
}
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
Future<void> _pollTick() async {
|
||||
// العروض عبر polling فقط عند انقطاع السوكيت — احتياط لا مسار أساسي
|
||||
if (isDeliveryModeEnabled && !FoodSocketService.instance.isConnected) {
|
||||
await _fetchPendingOffers();
|
||||
}
|
||||
await fetchActiveTasks(silent: true);
|
||||
}
|
||||
|
||||
Future<void> _fetchPendingOffers() async {
|
||||
final res = await FoodDeliveryService.getPendingOffers();
|
||||
if (!res.success) return;
|
||||
for (final offer in res.data ?? <FoodDeliveryOffer>[]) {
|
||||
_handleIncomingOffer(offer);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchActiveTasks({bool silent = false}) async {
|
||||
if (!silent) {
|
||||
isLoadingTasks = true;
|
||||
update();
|
||||
}
|
||||
final res = await FoodDeliveryService.getActiveTasks();
|
||||
isLoadingTasks = false;
|
||||
if (res.success) activeTasks = res.data ?? [];
|
||||
@@ -76,6 +268,7 @@ class FoodDeliveryController extends GetxController {
|
||||
|
||||
_respondingOfferIds.remove(orderId);
|
||||
pendingOffers.removeWhere((o) => o.orderId == orderId);
|
||||
FoodNotificationService.instance.cancelOfferNotification();
|
||||
|
||||
if (!res.success) {
|
||||
mySnackbarWarning(res.message);
|
||||
@@ -83,10 +276,15 @@ class FoodDeliveryController extends GetxController {
|
||||
mySnackbarSuccess('تم قبول طلب التوصيل');
|
||||
}
|
||||
|
||||
if (pendingOffers.isEmpty && _isOfferScreenOpen && Get.currentRoute.contains('FoodOfferPage')) {
|
||||
Get.back();
|
||||
}
|
||||
|
||||
update();
|
||||
await fetchActiveTasks();
|
||||
}
|
||||
|
||||
// ── خطوات المهمة ───────────────────────────────────────────────────────
|
||||
Future<void> markPickedUp(int orderId) async {
|
||||
if (_busyTaskIds.contains(orderId)) return;
|
||||
_busyTaskIds.add(orderId);
|
||||
@@ -115,6 +313,7 @@ class FoodDeliveryController extends GetxController {
|
||||
if (res.success) {
|
||||
mySnackbarSuccess('تم تسليم الطلب بنجاح');
|
||||
await fetchActiveTasks();
|
||||
await refreshStatus(); // تحديث عدّاد أرباح اليوم فوراً بعد التسليم
|
||||
} else {
|
||||
mySnackbarWarning(res.message);
|
||||
}
|
||||
@@ -124,6 +323,10 @@ class FoodDeliveryController extends GetxController {
|
||||
@override
|
||||
void onClose() {
|
||||
_pollTimer?.cancel();
|
||||
_countdownTimer?.cancel();
|
||||
_audioPlayer.dispose();
|
||||
// لا نقطع السوكيت هنا: وضع التوصيل قد يبقى مفعّلاً بعد إغلاق الشاشة،
|
||||
// والقطع يتم فقط عند إطفاء الوضع صراحةً من التبديل.
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// food_delivery_models.dart — نماذج بيانات وحدة التوصيل (جهة السائق)
|
||||
|
||||
int _int(dynamic v) => int.tryParse(v?.toString() ?? '') ?? 0;
|
||||
double? _doubleOrNull(dynamic v) => double.tryParse(v?.toString() ?? '');
|
||||
double _double(dynamic v) => double.tryParse(v?.toString() ?? '') ?? 0;
|
||||
DateTime? _date(dynamic v) => DateTime.tryParse(v?.toString() ?? '');
|
||||
|
||||
class FoodDeliveryTask {
|
||||
final int id;
|
||||
final String status; // courier_assigned | picked_up
|
||||
@@ -9,10 +14,22 @@ class FoodDeliveryTask {
|
||||
final double? merchantLng;
|
||||
final String? merchantAddress;
|
||||
final int deliveryFee;
|
||||
final int itemsTotal;
|
||||
final int serviceFee;
|
||||
final int discount;
|
||||
final int grandTotal;
|
||||
final String paymentMethod; // wallet | cash
|
||||
final int cashToCollect;
|
||||
final int courierOwes;
|
||||
final int itemsCount;
|
||||
final String? customerNote;
|
||||
final String? deliveryAddress;
|
||||
final double deliveryLat;
|
||||
final double deliveryLng;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? readyAt;
|
||||
final DateTime? courierAssignedAt;
|
||||
final DateTime? pickedUpAt;
|
||||
|
||||
FoodDeliveryTask({
|
||||
required this.id,
|
||||
@@ -22,37 +39,73 @@ class FoodDeliveryTask {
|
||||
required this.deliveryFee,
|
||||
required this.deliveryLat,
|
||||
required this.deliveryLng,
|
||||
this.itemsTotal = 0,
|
||||
this.serviceFee = 0,
|
||||
this.discount = 0,
|
||||
this.grandTotal = 0,
|
||||
this.paymentMethod = 'wallet',
|
||||
this.cashToCollect = 0,
|
||||
this.courierOwes = 0,
|
||||
this.itemsCount = 0,
|
||||
this.customerNote,
|
||||
this.merchantLat,
|
||||
this.merchantLng,
|
||||
this.merchantAddress,
|
||||
this.deliveryAddress,
|
||||
this.createdAt,
|
||||
this.readyAt,
|
||||
this.courierAssignedAt,
|
||||
this.pickedUpAt,
|
||||
});
|
||||
|
||||
bool get isPickedUp => status == 'picked_up';
|
||||
bool get isCash => paymentMethod == 'cash';
|
||||
|
||||
factory FoodDeliveryTask.fromJson(Map<String, dynamic> j) => FoodDeliveryTask(
|
||||
id: int.tryParse(j['id'].toString()) ?? 0,
|
||||
id: _int(j['id']),
|
||||
status: j['status']?.toString() ?? '',
|
||||
merchantId: int.tryParse(j['merchant_id'].toString()) ?? 0,
|
||||
merchantId: _int(j['merchant_id']),
|
||||
merchantNameAr: j['merchant_name_ar']?.toString() ?? '',
|
||||
merchantLat: double.tryParse(j['merchant_lat']?.toString() ?? ''),
|
||||
merchantLng: double.tryParse(j['merchant_lng']?.toString() ?? ''),
|
||||
merchantLat: _doubleOrNull(j['merchant_lat']),
|
||||
merchantLng: _doubleOrNull(j['merchant_lng']),
|
||||
merchantAddress: j['merchant_address']?.toString(),
|
||||
deliveryFee: int.tryParse(j['delivery_fee']?.toString() ?? '0') ?? 0,
|
||||
deliveryFee: _int(j['delivery_fee']),
|
||||
itemsTotal: _int(j['items_total']),
|
||||
serviceFee: _int(j['service_fee']),
|
||||
discount: _int(j['discount']),
|
||||
grandTotal: _int(j['grand_total']),
|
||||
paymentMethod: j['payment_method']?.toString() ?? 'wallet',
|
||||
cashToCollect: _int(j['cash_to_collect']),
|
||||
courierOwes: _int(j['courier_owes']),
|
||||
itemsCount: _int(j['items_count']),
|
||||
customerNote: j['customer_note']?.toString(),
|
||||
deliveryAddress: j['delivery_address']?.toString(),
|
||||
deliveryLat: double.tryParse(j['delivery_lat']?.toString() ?? '0') ?? 0,
|
||||
deliveryLng: double.tryParse(j['delivery_lng']?.toString() ?? '0') ?? 0,
|
||||
createdAt: DateTime.tryParse(j['created_at']?.toString() ?? ''),
|
||||
deliveryLat: _double(j['delivery_lat']),
|
||||
deliveryLng: _double(j['delivery_lng']),
|
||||
createdAt: _date(j['created_at']),
|
||||
readyAt: _date(j['ready_at']),
|
||||
courierAssignedAt: _date(j['courier_assigned_at']),
|
||||
pickedUpAt: _date(j['picked_up_at']),
|
||||
);
|
||||
}
|
||||
|
||||
// عرض توصيل وارد — من polling على food/courier/pending_offers.php
|
||||
// عرض توصيل وارد — يصل عبر سوكيت الطعام ('food_delivery_offer') وبنفس الحقول
|
||||
// من courier/pending_offers.php عند انقطاع السوكيت (foodBuildCourierOfferPayload).
|
||||
class FoodDeliveryOffer {
|
||||
final int orderId;
|
||||
final int merchantId;
|
||||
final String merchantNameAr;
|
||||
final String? merchantAddress;
|
||||
final double? merchantLat;
|
||||
final double? merchantLng;
|
||||
final double deliveryLat;
|
||||
final double deliveryLng;
|
||||
final int deliveryFee;
|
||||
final int itemsCount;
|
||||
final String paymentMethod;
|
||||
final int cashToCollect;
|
||||
final int ttlSeconds;
|
||||
final DateTime receivedAt;
|
||||
final DateTime? offeredAt;
|
||||
|
||||
FoodDeliveryOffer({
|
||||
@@ -60,28 +113,206 @@ class FoodDeliveryOffer {
|
||||
required this.merchantId,
|
||||
required this.merchantNameAr,
|
||||
required this.deliveryFee,
|
||||
this.merchantAddress,
|
||||
this.merchantLat,
|
||||
this.merchantLng,
|
||||
this.deliveryLat = 0,
|
||||
this.deliveryLng = 0,
|
||||
this.itemsCount = 0,
|
||||
this.paymentMethod = 'wallet',
|
||||
this.cashToCollect = 0,
|
||||
this.ttlSeconds = 20,
|
||||
DateTime? receivedAt,
|
||||
this.offeredAt,
|
||||
});
|
||||
}) : receivedAt = receivedAt ?? DateTime.now();
|
||||
|
||||
bool get isCash => paymentMethod == 'cash';
|
||||
|
||||
factory FoodDeliveryOffer.fromJson(Map<String, dynamic> j) => FoodDeliveryOffer(
|
||||
orderId: int.tryParse(j['order_id'].toString()) ?? 0,
|
||||
merchantId: int.tryParse(j['merchant_id'].toString()) ?? 0,
|
||||
orderId: _int(j['order_id']),
|
||||
merchantId: _int(j['merchant_id']),
|
||||
merchantNameAr: j['merchant_name_ar']?.toString() ?? '',
|
||||
merchantLat: double.tryParse(j['merchant_lat']?.toString() ?? ''),
|
||||
merchantLng: double.tryParse(j['merchant_lng']?.toString() ?? ''),
|
||||
deliveryFee: int.tryParse(j['delivery_fee']?.toString() ?? '0') ?? 0,
|
||||
offeredAt: DateTime.tryParse(j['offered_at']?.toString() ?? ''),
|
||||
merchantAddress: j['merchant_address']?.toString(),
|
||||
merchantLat: _doubleOrNull(j['merchant_lat']),
|
||||
merchantLng: _doubleOrNull(j['merchant_lng']),
|
||||
deliveryLat: _double(j['delivery_lat']),
|
||||
deliveryLng: _double(j['delivery_lng']),
|
||||
deliveryFee: _int(j['delivery_fee']),
|
||||
itemsCount: _int(j['items_count']),
|
||||
paymentMethod: j['payment_method']?.toString() ?? 'wallet',
|
||||
cashToCollect: _int(j['cash_to_collect']),
|
||||
ttlSeconds: _int(j['offer_ttl_seconds']) == 0 ? 20 : _int(j['offer_ttl_seconds']),
|
||||
offeredAt: _date(j['offered_at']),
|
||||
);
|
||||
|
||||
// مهلة العرض 20 ثانية من الخادم (SET NX EX 20) — عدّاد تقريبي للعرض فقط،
|
||||
// الخادم هو الحكم الفعلي (رفض offer_respond إن انتهت المهلة فعلاً).
|
||||
// العدّاد للعرض فقط — الخادم هو الحكم الفعلي ويرفض offer_respond بعد المهلة.
|
||||
// نبدأ من offered_at إن جاءت (مسار polling)، وإلا من لحظة الاستلام (مسار السوكيت).
|
||||
int get secondsRemaining {
|
||||
if (offeredAt == null) return 20;
|
||||
final elapsed = DateTime.now().difference(offeredAt!).inSeconds;
|
||||
return (20 - elapsed).clamp(0, 20);
|
||||
final start = offeredAt ?? receivedAt;
|
||||
final elapsed = DateTime.now().difference(start).inSeconds;
|
||||
return (ttlSeconds - elapsed).clamp(0, ttlSeconds);
|
||||
}
|
||||
|
||||
bool get isExpired => secondsRemaining <= 0;
|
||||
}
|
||||
|
||||
// صنف داخل الطلب — يراه السائق ليتأكد من محتوى الكيس عند الاستلام من المطعم
|
||||
class FoodOrderItem {
|
||||
final String nameAr;
|
||||
final int quantity;
|
||||
final int unitPrice;
|
||||
final int lineTotal;
|
||||
|
||||
FoodOrderItem({
|
||||
required this.nameAr,
|
||||
required this.quantity,
|
||||
required this.unitPrice,
|
||||
required this.lineTotal,
|
||||
});
|
||||
|
||||
factory FoodOrderItem.fromJson(Map<String, dynamic> j) => FoodOrderItem(
|
||||
nameAr: j['name_ar']?.toString() ?? '',
|
||||
quantity: _int(j['quantity']),
|
||||
unitPrice: _int(j['unit_price']),
|
||||
lineTotal: _int(j['line_total']),
|
||||
);
|
||||
}
|
||||
|
||||
class FoodOrderDetails {
|
||||
final FoodDeliveryTask task;
|
||||
final List<FoodOrderItem> items;
|
||||
final String? merchantPhone;
|
||||
|
||||
FoodOrderDetails({required this.task, required this.items, this.merchantPhone});
|
||||
|
||||
factory FoodOrderDetails.fromJson(Map<String, dynamic> j) => FoodOrderDetails(
|
||||
task: FoodDeliveryTask.fromJson(j),
|
||||
items: (j['items'] is List)
|
||||
? (j['items'] as List)
|
||||
.map((i) => FoodOrderItem.fromJson(Map<String, dynamic>.from(i)))
|
||||
.toList()
|
||||
: <FoodOrderItem>[],
|
||||
merchantPhone: j['merchant_phone']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
class FoodCourierStatus {
|
||||
final bool deliveryModeEnabled;
|
||||
final int activeOrdersCount;
|
||||
final int todayOrdersCount;
|
||||
final int todayEarnings;
|
||||
|
||||
FoodCourierStatus({
|
||||
required this.deliveryModeEnabled,
|
||||
this.activeOrdersCount = 0,
|
||||
this.todayOrdersCount = 0,
|
||||
this.todayEarnings = 0,
|
||||
});
|
||||
|
||||
factory FoodCourierStatus.fromJson(Map<String, dynamic> j) => FoodCourierStatus(
|
||||
deliveryModeEnabled: j['delivery_mode_enabled'] == true ||
|
||||
j['delivery_mode_enabled']?.toString() == '1' ||
|
||||
j['delivery_mode_enabled']?.toString() == 'true',
|
||||
activeOrdersCount: _int(j['active_orders_count']),
|
||||
todayOrdersCount: _int(j['today_orders_count']),
|
||||
todayEarnings: _int(j['today_earnings']),
|
||||
);
|
||||
}
|
||||
|
||||
class FoodEarningsDay {
|
||||
final String day;
|
||||
final int ordersCount;
|
||||
final int earnings;
|
||||
|
||||
FoodEarningsDay({required this.day, required this.ordersCount, required this.earnings});
|
||||
|
||||
factory FoodEarningsDay.fromJson(Map<String, dynamic> j) => FoodEarningsDay(
|
||||
day: j['day']?.toString() ?? '',
|
||||
ordersCount: _int(j['orders_count']),
|
||||
earnings: _int(j['earnings']),
|
||||
);
|
||||
}
|
||||
|
||||
class FoodEarnings {
|
||||
final int todayEarnings;
|
||||
final int todayOrders;
|
||||
final int weekEarnings;
|
||||
final int weekOrders;
|
||||
final int monthEarnings;
|
||||
final int monthOrders;
|
||||
final int totalEarnings;
|
||||
final int totalOrders;
|
||||
final int pendingCashOwed;
|
||||
final List<FoodEarningsDay> daily;
|
||||
|
||||
FoodEarnings({
|
||||
required this.todayEarnings,
|
||||
required this.todayOrders,
|
||||
required this.weekEarnings,
|
||||
required this.weekOrders,
|
||||
required this.monthEarnings,
|
||||
required this.monthOrders,
|
||||
required this.totalEarnings,
|
||||
required this.totalOrders,
|
||||
required this.pendingCashOwed,
|
||||
required this.daily,
|
||||
});
|
||||
|
||||
factory FoodEarnings.fromJson(Map<String, dynamic> j) => FoodEarnings(
|
||||
todayEarnings: _int(j['today_earnings']),
|
||||
todayOrders: _int(j['today_orders']),
|
||||
weekEarnings: _int(j['week_earnings']),
|
||||
weekOrders: _int(j['week_orders']),
|
||||
monthEarnings: _int(j['month_earnings']),
|
||||
monthOrders: _int(j['month_orders']),
|
||||
totalEarnings: _int(j['total_earnings']),
|
||||
totalOrders: _int(j['total_orders']),
|
||||
pendingCashOwed: _int(j['pending_cash_owed']),
|
||||
daily: (j['daily'] is List)
|
||||
? (j['daily'] as List)
|
||||
.map((d) => FoodEarningsDay.fromJson(Map<String, dynamic>.from(d)))
|
||||
.toList()
|
||||
: <FoodEarningsDay>[],
|
||||
);
|
||||
}
|
||||
|
||||
class FoodHistoryEntry {
|
||||
final int id;
|
||||
final String merchantNameAr;
|
||||
final String? merchantAddress;
|
||||
final int deliveryFee;
|
||||
final int grandTotal;
|
||||
final String paymentMethod;
|
||||
final int itemsCount;
|
||||
final int? rating;
|
||||
final int? durationMinutes;
|
||||
final DateTime? deliveredAt;
|
||||
|
||||
FoodHistoryEntry({
|
||||
required this.id,
|
||||
required this.merchantNameAr,
|
||||
required this.deliveryFee,
|
||||
this.merchantAddress,
|
||||
this.grandTotal = 0,
|
||||
this.paymentMethod = 'wallet',
|
||||
this.itemsCount = 0,
|
||||
this.rating,
|
||||
this.durationMinutes,
|
||||
this.deliveredAt,
|
||||
});
|
||||
|
||||
factory FoodHistoryEntry.fromJson(Map<String, dynamic> j) => FoodHistoryEntry(
|
||||
id: _int(j['id']),
|
||||
merchantNameAr: j['merchant_name_ar']?.toString() ?? '',
|
||||
merchantAddress: j['merchant_address']?.toString(),
|
||||
deliveryFee: _int(j['delivery_fee']),
|
||||
grandTotal: _int(j['grand_total']),
|
||||
paymentMethod: j['payment_method']?.toString() ?? 'wallet',
|
||||
itemsCount: _int(j['items_count']),
|
||||
rating: j['rating'] == null ? null : _int(j['rating']),
|
||||
durationMinutes: j['duration_minutes'] == null ? null : _int(j['duration_minutes']),
|
||||
deliveredAt: _date(j['delivered_at']),
|
||||
);
|
||||
}
|
||||
|
||||
const int foodCurrencyDivisor = 1000;
|
||||
|
||||
@@ -22,30 +22,66 @@ class FoodDeliveryService {
|
||||
return FoodDeliveryApiResult(false, null, _errMsg(res));
|
||||
}
|
||||
|
||||
/// حالة السائق عند فتح التبويب — وضع التوصيل مخزَّن في Redis على الخادم،
|
||||
/// فلا يجوز افتراض "مغلق" محلياً وإلا ظهر التبديل مطفأً والعروض تصل فعلاً.
|
||||
static Future<FoodDeliveryApiResult<FoodCourierStatus>> getStatus() async {
|
||||
final res = await CRUD().post(link: '$_base/courier/status.php');
|
||||
final msg = _payload(res);
|
||||
if (msg != null) {
|
||||
return FoodDeliveryApiResult(true, FoodCourierStatus.fromJson(msg), 'ok');
|
||||
}
|
||||
return FoodDeliveryApiResult(false, null, _errMsg(res));
|
||||
}
|
||||
|
||||
static Future<FoodDeliveryApiResult<List<FoodDeliveryTask>>> getActiveTasks() async {
|
||||
final res = await CRUD().post(link: '$_base/courier/active.php');
|
||||
if (res is Map && res['status'] == 'success') {
|
||||
final msg = res['message'];
|
||||
final list = (msg is Map && msg['orders'] is List)
|
||||
? (msg['orders'] as List)
|
||||
.map((o) => FoodDeliveryTask.fromJson(Map<String, dynamic>.from(o)))
|
||||
.toList()
|
||||
: <FoodDeliveryTask>[];
|
||||
return FoodDeliveryApiResult(true, list, 'ok');
|
||||
final msg = _payload(res);
|
||||
if (msg != null) {
|
||||
return FoodDeliveryApiResult(true, _list(msg['orders'], FoodDeliveryTask.fromJson), 'ok');
|
||||
}
|
||||
return FoodDeliveryApiResult(false, null, _errMsg(res));
|
||||
}
|
||||
|
||||
static Future<FoodDeliveryApiResult<List<FoodDeliveryOffer>>> getPendingOffers() async {
|
||||
final res = await CRUD().post(link: '$_base/courier/pending_offers.php');
|
||||
if (res is Map && res['status'] == 'success') {
|
||||
final msg = res['message'];
|
||||
final list = (msg is Map && msg['offers'] is List)
|
||||
? (msg['offers'] as List)
|
||||
.map((o) => FoodDeliveryOffer.fromJson(Map<String, dynamic>.from(o)))
|
||||
.toList()
|
||||
: <FoodDeliveryOffer>[];
|
||||
return FoodDeliveryApiResult(true, list, 'ok');
|
||||
final msg = _payload(res);
|
||||
if (msg != null) {
|
||||
return FoodDeliveryApiResult(true, _list(msg['offers'], FoodDeliveryOffer.fromJson), 'ok');
|
||||
}
|
||||
return FoodDeliveryApiResult(false, null, _errMsg(res));
|
||||
}
|
||||
|
||||
static Future<FoodDeliveryApiResult<FoodOrderDetails>> getOrderDetails(int orderId) async {
|
||||
final res = await CRUD().post(
|
||||
link: '$_base/courier/order_details.php',
|
||||
payload: {'order_id': orderId.toString()},
|
||||
);
|
||||
final msg = _payload(res);
|
||||
if (msg != null && msg['order'] is Map) {
|
||||
return FoodDeliveryApiResult(
|
||||
true, FoodOrderDetails.fromJson(Map<String, dynamic>.from(msg['order'])), 'ok');
|
||||
}
|
||||
return FoodDeliveryApiResult(false, null, _errMsg(res));
|
||||
}
|
||||
|
||||
static Future<FoodDeliveryApiResult<FoodEarnings>> getEarnings() async {
|
||||
final res = await CRUD().post(link: '$_base/courier/earnings.php');
|
||||
final msg = _payload(res);
|
||||
if (msg != null) return FoodDeliveryApiResult(true, FoodEarnings.fromJson(msg), 'ok');
|
||||
return FoodDeliveryApiResult(false, null, _errMsg(res));
|
||||
}
|
||||
|
||||
static Future<FoodDeliveryApiResult<List<FoodHistoryEntry>>> getHistory({
|
||||
int limit = 20,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
final res = await CRUD().post(
|
||||
link: '$_base/courier/history.php',
|
||||
payload: {'limit': limit.toString(), 'offset': offset.toString()},
|
||||
);
|
||||
final msg = _payload(res);
|
||||
if (msg != null) {
|
||||
return FoodDeliveryApiResult(true, _list(msg['orders'], FoodHistoryEntry.fromJson), 'ok');
|
||||
}
|
||||
return FoodDeliveryApiResult(false, null, _errMsg(res));
|
||||
}
|
||||
@@ -55,9 +91,9 @@ class FoodDeliveryService {
|
||||
link: '$_base/courier/offer_respond.php',
|
||||
payload: {'order_id': orderId.toString(), 'response': accept ? 'accept' : 'reject'},
|
||||
);
|
||||
if (res is Map && res['status'] == 'success') {
|
||||
final msg = res['message'];
|
||||
return FoodDeliveryApiResult(true, (msg is Map ? msg['response']?.toString() : null) ?? '', 'ok');
|
||||
final msg = _payload(res);
|
||||
if (msg != null) {
|
||||
return FoodDeliveryApiResult(true, msg['response']?.toString() ?? '', 'ok');
|
||||
}
|
||||
return FoodDeliveryApiResult(false, null, _errMsg(res));
|
||||
}
|
||||
@@ -80,6 +116,19 @@ class FoodDeliveryService {
|
||||
return FoodDeliveryApiResult(false, null, _errMsg(res));
|
||||
}
|
||||
|
||||
// كل ردود backend/food تأتي بغلاف {status, message:{...}} — نستخرج المحتوى مرة واحدة
|
||||
static Map<String, dynamic>? _payload(dynamic res) {
|
||||
if (res is Map && res['status'] == 'success' && res['message'] is Map) {
|
||||
return Map<String, dynamic>.from(res['message']);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<T> _list<T>(dynamic raw, T Function(Map<String, dynamic>) fromJson) {
|
||||
if (raw is! List) return <T>[];
|
||||
return raw.map((e) => fromJson(Map<String, dynamic>.from(e))).toList();
|
||||
}
|
||||
|
||||
static String _errMsg(dynamic res) {
|
||||
if (res == 'no_internet') return 'تحقق من اتصالك بالإنترنت';
|
||||
if (res == 'token_expired') return 'انتهت الجلسة، حاول مجدداً';
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// food_notification_service.dart — إشعارات وحدة التوصيل، منفصلة كلياً عن إشعارات الرحلات
|
||||
//
|
||||
// الفصل مقصود على ثلاثة مستويات:
|
||||
// 1) قناتان أندرويد خاصتان (food_delivery_offer_channel / food_delivery_status_channel)
|
||||
// غير 'high_importance_channel' الخاص بالرحلات — فيقدر السائق يكتم أو يغيّر
|
||||
// نغمة التوصيل من إعدادات النظام دون أن يمسّ تنبيه الرحلات إطلاقاً.
|
||||
// 2) معرّفات إشعارات مستقلة (7001/7002) فلا يستبدل إشعار توصيل إشعارَ رحلة.
|
||||
// 3) أزرار وحمولة خاصة ('FOOD_ACCEPT'/'FOOD_REJECT' + {"food_event": ...})
|
||||
// يلتقطها فرع مستقل في handleNotificationResponse.
|
||||
import 'dart:convert';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
import 'food_delivery_models.dart';
|
||||
|
||||
class FoodNotificationService {
|
||||
FoodNotificationService._();
|
||||
static final FoodNotificationService instance = FoodNotificationService._();
|
||||
|
||||
// نفس نسخة الإضافة المستخدمة في NotificationController (singleton داخل الحزمة)،
|
||||
// لكن بقنوات ومعرّفات خاصة بنا — لا نستدعي initialize هنا إطلاقاً حتى لا
|
||||
// نستبدل معالِج النقر العام الذي هيّأه تطبيق الرحلات عند الإقلاع.
|
||||
final FlutterLocalNotificationsPlugin _plugin = FlutterLocalNotificationsPlugin();
|
||||
|
||||
static const String offerChannelId = 'food_delivery_offer_channel';
|
||||
static const String statusChannelId = 'food_delivery_status_channel';
|
||||
static const int offerNotificationId = 7001;
|
||||
static const int statusNotificationId = 7002;
|
||||
|
||||
bool _channelsReady = false;
|
||||
|
||||
Future<void> ensureChannels() async {
|
||||
if (_channelsReady) return;
|
||||
|
||||
final android = _plugin.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>();
|
||||
if (android == null) {
|
||||
_channelsReady = true; // iOS — لا قنوات
|
||||
return;
|
||||
}
|
||||
|
||||
const offerChannel = AndroidNotificationChannel(
|
||||
offerChannelId,
|
||||
'عروض التوصيل',
|
||||
description: 'تنبيه وصول عرض توصيل طلب طعام جديد',
|
||||
importance: Importance.max,
|
||||
playSound: true,
|
||||
sound: RawResourceAndroidNotificationSound('order1'),
|
||||
enableVibration: true,
|
||||
);
|
||||
|
||||
const statusChannel = AndroidNotificationChannel(
|
||||
statusChannelId,
|
||||
'تحديثات طلبات التوصيل',
|
||||
description: 'تغيّر حالة طلب توصيل قائم (جاهز، ملغى، إلخ)',
|
||||
importance: Importance.high,
|
||||
playSound: true,
|
||||
);
|
||||
|
||||
await android.createNotificationChannel(offerChannel);
|
||||
await android.createNotificationChannel(statusChannel);
|
||||
_channelsReady = true;
|
||||
}
|
||||
|
||||
/// إشعار عرض توصيل — ملء الشاشة (fullScreenIntent) كإشعار المكالمة، لأن مهلة
|
||||
/// العرض 20 ثانية ولا تحتمل أن يمرّ السائق على شريط الإشعارات لاحقاً.
|
||||
Future<void> showOfferNotification(FoodDeliveryOffer offer) async {
|
||||
await ensureChannels();
|
||||
|
||||
final body = '${offer.merchantNameAr}\n'
|
||||
'💰 أجرة التوصيل: ${foodFormatPrice(offer.deliveryFee)}'
|
||||
'${offer.itemsCount > 0 ? ' | 🧾 ${offer.itemsCount} صنف' : ''}'
|
||||
'${offer.cashToCollect > 0 ? '\n💵 تحصيل نقدي: ${foodFormatPrice(offer.cashToCollect)}' : ''}';
|
||||
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
offerChannelId,
|
||||
'عروض التوصيل',
|
||||
importance: Importance.max,
|
||||
priority: Priority.max,
|
||||
fullScreenIntent: true,
|
||||
category: AndroidNotificationCategory.call,
|
||||
visibility: NotificationVisibility.public,
|
||||
timeoutAfter: 20000, // يختفي مع انتهاء مهلة العرض نفسها
|
||||
sound: const RawResourceAndroidNotificationSound('order1'),
|
||||
audioAttributesUsage: AudioAttributesUsage.alarm,
|
||||
color: const Color(0xFFFF6B35),
|
||||
styleInformation: BigTextStyleInformation(
|
||||
body,
|
||||
contentTitle: '🍔 عرض توصيل جديد',
|
||||
summaryText: foodFormatPrice(offer.deliveryFee),
|
||||
),
|
||||
actions: const <AndroidNotificationAction>[
|
||||
AndroidNotificationAction('FOOD_ACCEPT', '✅ قبول',
|
||||
showsUserInterface: true, titleColor: Color(0xFF4CAF50)),
|
||||
AndroidNotificationAction('FOOD_REJECT', '❌ رفض',
|
||||
cancelNotification: true, titleColor: Color(0xFFE53935)),
|
||||
],
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
sound: 'order1.wav',
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
categoryIdentifier: 'FOOD_OFFER_CATEGORY',
|
||||
interruptionLevel: InterruptionLevel.timeSensitive,
|
||||
);
|
||||
|
||||
await _plugin.show(
|
||||
id: offerNotificationId,
|
||||
title: '🍔 عرض توصيل جديد',
|
||||
body: '${offer.merchantNameAr} — ${foodFormatPrice(offer.deliveryFee)}',
|
||||
notificationDetails:
|
||||
NotificationDetails(android: androidDetails, iOS: iosDetails),
|
||||
payload: jsonEncode({
|
||||
'food_event': 'offer',
|
||||
'order_id': offer.orderId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> cancelOfferNotification() async {
|
||||
await _plugin.cancel(id: offerNotificationId);
|
||||
}
|
||||
|
||||
/// تحديث حالة مهمة قائمة (مثلاً إلغاء من النظام) — قناة أهدأ، بلا ملء شاشة
|
||||
Future<void> showStatusNotification({
|
||||
required int orderId,
|
||||
required String title,
|
||||
required String body,
|
||||
}) async {
|
||||
await ensureChannels();
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
statusChannelId,
|
||||
'تحديثات طلبات التوصيل',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
color: Color(0xFFFF6B35),
|
||||
);
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
await _plugin.show(
|
||||
id: statusNotificationId,
|
||||
title: title,
|
||||
body: body,
|
||||
notificationDetails:
|
||||
const NotificationDetails(android: androidDetails, iOS: iosDetails),
|
||||
payload: jsonEncode({'food_event': 'status', 'order_id': orderId}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
// food_socket_service.dart — قناة سوكيت مستقلة تماماً لوحدة التوصيل
|
||||
//
|
||||
// لماذا سوكيت منفصل ولا نركب على سوكيت الرحلات (LocationController.socket)؟
|
||||
// • خادمان منفصلان فعلاً: food_socket.php (4040) مقابل driver_socket (2020)،
|
||||
// وغرفة السائق هناك courier_food_{id} بمصادقة JWT مختلفة الاستعلام.
|
||||
// • عزل الأعطال: سقوط سوكيت الطعام لا يقطع توزيع الرحلات، وهو المسار الحرج.
|
||||
// • دورة حياة مختلفة: نتصل فقط أثناء «وضع التوصيل» وننفصل عند إطفائه،
|
||||
// بينما سوكيت الرحلات يبقى حياً طوال مناوبة السائق.
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:socket_io_client/socket_io_client.dart' as IO;
|
||||
|
||||
import '../../constant/box_name.dart';
|
||||
import '../../constant/links.dart';
|
||||
import '../../main.dart';
|
||||
import '../../print.dart';
|
||||
|
||||
typedef FoodOfferHandler = void Function(Map<String, dynamic> payload);
|
||||
typedef FoodOrderUpdateHandler = void Function(Map<String, dynamic> payload);
|
||||
|
||||
class FoodSocketService {
|
||||
FoodSocketService._();
|
||||
static final FoodSocketService instance = FoodSocketService._();
|
||||
|
||||
IO.Socket? _socket;
|
||||
bool _isConnecting = false;
|
||||
Timer? _heartbeat;
|
||||
|
||||
FoodOfferHandler? onOffer;
|
||||
FoodOrderUpdateHandler? onOrderUpdate;
|
||||
FoodOrderUpdateHandler? onIncomingCall;
|
||||
void Function(bool connected)? onConnectionChanged;
|
||||
|
||||
bool get isConnected => _socket?.connected == true;
|
||||
|
||||
Future<void> connect() async {
|
||||
if (isConnected || _isConnecting) return;
|
||||
|
||||
final driverId = box.read(BoxName.driverID)?.toString() ?? '';
|
||||
final token = box.read(BoxName.tokenDriver)?.toString() ?? '';
|
||||
if (driverId.isEmpty || token.isEmpty) {
|
||||
Log.print('🍔 [FoodSocket] لا يوجد driverID/token — تخطّي الاتصال');
|
||||
return;
|
||||
}
|
||||
|
||||
_isConnecting = true;
|
||||
_disposeSocket();
|
||||
|
||||
try {
|
||||
_socket = IO.io(
|
||||
AppLink.foodSocketUrl,
|
||||
IO.OptionBuilder()
|
||||
.setTransports(['websocket'])
|
||||
// food_socket.php يتحقق: role=driver ويطابق user_id داخل الـ JWT مع id
|
||||
.setQuery({
|
||||
'role': 'driver',
|
||||
'id': driverId,
|
||||
'jwt': token,
|
||||
'platform': Platform.isIOS ? 'ios' : 'android',
|
||||
})
|
||||
.enableForceNew()
|
||||
.enableReconnection()
|
||||
.setReconnectionDelay(2000)
|
||||
.build(),
|
||||
);
|
||||
|
||||
_bindListeners();
|
||||
_socket!.connect();
|
||||
} catch (e) {
|
||||
_isConnecting = false;
|
||||
Log.print('❌ [FoodSocket] فشل التهيئة: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _bindListeners() {
|
||||
final s = _socket;
|
||||
if (s == null) return;
|
||||
|
||||
s.onConnect((_) {
|
||||
_isConnecting = false;
|
||||
Log.print('✅ [FoodSocket] متصل — غرفة courier_food');
|
||||
onConnectionChanged?.call(true);
|
||||
_startHeartbeat();
|
||||
});
|
||||
|
||||
s.onDisconnect((_) {
|
||||
Log.print('⚠️ [FoodSocket] انقطع الاتصال');
|
||||
onConnectionChanged?.call(false);
|
||||
_stopHeartbeat();
|
||||
});
|
||||
|
||||
s.onConnectError((e) {
|
||||
_isConnecting = false;
|
||||
Log.print('❌ [FoodSocket] خطأ اتصال: $e');
|
||||
onConnectionChanged?.call(false);
|
||||
});
|
||||
|
||||
s.onError((e) => Log.print('❌ [FoodSocket] خطأ: $e'));
|
||||
|
||||
// عرض توصيل جديد — الحمولة كاملة من foodBuildCourierOfferPayload،
|
||||
// فشاشة العرض تُبنى فوراً بلا نداء HTTP إضافي (المهلة 20 ثانية فقط).
|
||||
s.on('food_delivery_offer', (data) {
|
||||
final map = _asMap(data);
|
||||
if (map == null) return;
|
||||
Log.print('🍔 [FoodSocket] عرض توصيل #${map['order_id']}');
|
||||
onOffer?.call(map);
|
||||
});
|
||||
|
||||
// مكالمة واردة من الزبون عبر القناة المقنّعة — لا رقم هاتف في الحمولة،
|
||||
// فقط session_id ينضم به الطرفان لخادم الإشارات.
|
||||
s.on('food_incoming_call', (data) {
|
||||
final map = _asMap(data);
|
||||
if (map == null) return;
|
||||
Log.print('🍔 [FoodSocket] مكالمة واردة على الطلب #${map['order_id']}');
|
||||
onIncomingCall?.call(map);
|
||||
});
|
||||
|
||||
// تحديث حالة طلب قائم (المطعم ألغى، النظام ألغى، إلخ)
|
||||
s.on('food_order_update', (data) {
|
||||
final map = _asMap(data);
|
||||
if (map == null) return;
|
||||
Log.print('🍔 [FoodSocket] تحديث طلب #${map['order_id']} → ${map['status']}');
|
||||
onOrderUpdate?.call(map);
|
||||
});
|
||||
}
|
||||
|
||||
// السوكيت ناقل إشعار لا مخزن حالة — أي حمولة غير مفهومة تُهمَل بصمت
|
||||
// ويصحّح التطبيق نفسه من courier/active.php.
|
||||
Map<String, dynamic>? _asMap(dynamic data) {
|
||||
try {
|
||||
if (data is Map) return Map<String, dynamic>.from(data);
|
||||
if (data is List && data.isNotEmpty && data.first is Map) {
|
||||
return Map<String, dynamic>.from(data.first as Map);
|
||||
}
|
||||
} catch (e) {
|
||||
Log.print('❌ [FoodSocket] حمولة غير صالحة: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void _startHeartbeat() {
|
||||
_stopHeartbeat();
|
||||
_heartbeat = Timer.periodic(const Duration(seconds: 25), (_) {
|
||||
if (isConnected) _socket!.emit('heartbeat', {});
|
||||
});
|
||||
}
|
||||
|
||||
void _stopHeartbeat() {
|
||||
_heartbeat?.cancel();
|
||||
_heartbeat = null;
|
||||
}
|
||||
|
||||
void _disposeSocket() {
|
||||
if (_socket == null) return;
|
||||
_socket!.clearListeners();
|
||||
_socket!.dispose();
|
||||
_socket = null;
|
||||
}
|
||||
|
||||
void disconnect() {
|
||||
_stopHeartbeat();
|
||||
_isConnecting = false;
|
||||
_socket?.disconnect();
|
||||
_disposeSocket();
|
||||
onConnectionChanged?.call(false);
|
||||
Log.print('🍔 [FoodSocket] تم قطع الاتصال (وضع التوصيل مطفأ)');
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import '../../constant/links.dart';
|
||||
import '../../main.dart';
|
||||
import '../../print.dart';
|
||||
import '../../services/signaling_service.dart';
|
||||
import '../../services/turn_credentials_service.dart';
|
||||
import '../../views/widgets/voice_call_bottom_sheet.dart';
|
||||
import 'functions/crud.dart';
|
||||
|
||||
@@ -229,11 +230,16 @@ class VoiceCallController extends GetxController with WidgetsBindingObserver {
|
||||
|
||||
// EN: Initiates an outgoing call.
|
||||
// AR: يبدأ مكالمة صادرة.
|
||||
/// [sessionEndpoint] و [sessionPayload] يسمحان لوحدات أخرى (توصيل الطعام)
|
||||
/// بإعادة استخدام نفس قناة WebRTC المقنّعة بواجهة إنشاء جلسة خاصة بها،
|
||||
/// بدل تكرار منطق الإشارات والصوت. القيمة الافتراضية هي مكالمة الرحلة.
|
||||
Future<void> startCall({
|
||||
required String rideIdVal,
|
||||
required String driverId,
|
||||
required String passengerId,
|
||||
required String remoteNameVal,
|
||||
String? sessionEndpoint,
|
||||
Map<String, String>? sessionPayload,
|
||||
}) async {
|
||||
if (state.value != VoiceCallState.idle) return;
|
||||
|
||||
@@ -268,8 +274,9 @@ class VoiceCallController extends GetxController with WidgetsBindingObserver {
|
||||
// 2. EN: Call PHP Backend to create Node.js session & notify Passenger via FCM.
|
||||
// AR: استدعاء واجهة PHP لإنشاء الجلسة على Node.js وإشعار الراكب عبر FCM.
|
||||
final response = await CRUD().post(
|
||||
link: "${AppLink.server}/ride/call/driver/create_call_session.php",
|
||||
payload: {'ride_id': rideIdVal},
|
||||
link: sessionEndpoint ??
|
||||
"${AppLink.server}/ride/call/driver/create_call_session.php",
|
||||
payload: sessionPayload ?? {'ride_id': rideIdVal},
|
||||
);
|
||||
|
||||
if (response == null ||
|
||||
@@ -281,8 +288,16 @@ class VoiceCallController extends GetxController with WidgetsBindingObserver {
|
||||
return;
|
||||
}
|
||||
|
||||
final data = response['data'];
|
||||
sessionId.value = data['session_id'];
|
||||
// واجهات الرحلات تُعيد الجلسة في 'data'، وواجهات وحدة الطعام تُعيدها في
|
||||
// 'message' (غلاف jsonSuccess الموحّد) — نقبل الشكلين.
|
||||
final data = response['data'] ?? response['message'];
|
||||
if (data is! Map || data['session_id'] == null) {
|
||||
errorMessage.value =
|
||||
"Failed to initiate call session. Please try again.".tr;
|
||||
_endCallInternal("session_creation_failed");
|
||||
return;
|
||||
}
|
||||
sessionId.value = data['session_id'].toString();
|
||||
|
||||
// 3. EN: Connect to WebRTC signaling server / AR: الاتصال بخادم الإشارات
|
||||
await _signaling.connect(sessionId.value, currentUserId);
|
||||
@@ -492,8 +507,16 @@ class VoiceCallController extends GetxController with WidgetsBindingObserver {
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// EN: Fallback STUN servers / AR: خوام STUN الاحتياطية
|
||||
}
|
||||
|
||||
// خوادم TURN من الباك إند — بيانات اعتماد مؤقتة. بلا TURN تفشل المكالمة
|
||||
// صامتةً حين يكون الطرفان خلف CGNAT (شبكة الجوال)، وهي الحالة الغالبة.
|
||||
// نُضيفها قبل خوادم الإشارات: STUN يبقى أولاً في الترتيب داخل القائمة.
|
||||
final turnServers = await TurnCredentialsService.getIceServers();
|
||||
iceServers.addAll(turnServers);
|
||||
|
||||
if (iceServers.isEmpty) {
|
||||
// EN: Fallback STUN servers / AR: خوادم STUN الاحتياطية
|
||||
iceServers.addAll([
|
||||
{"urls": "stun:stun.l.google.com:19302"},
|
||||
{"urls": "stun:stun1.l.google.com:19302"},
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// turn_credentials_service.dart — جلب بيانات اعتماد TURN المؤقتة وتخزينها
|
||||
//
|
||||
// خادم الإشارات (Node) يُعيد STUN فقط، وSTUN وحده لا يكفي حين يكون الطرفان
|
||||
// خلف CGNAT — وهي الحالة الغالبة لسائق وزبون على بيانات الجوال. نجلب TURN
|
||||
// من الباك إند ونضمّه إلى قائمة ICE قبل إنشاء الاتصال.
|
||||
//
|
||||
// البيانات مؤقتة (HMAC بمهلة) لا كلمة مرور ثابتة داخل التطبيق، ونُخزّنها
|
||||
// محلياً حتى قرب انتهائها فلا نُثقل بنداء شبكة قبل كل مكالمة.
|
||||
import 'dart:convert';
|
||||
|
||||
import '../constant/box_name.dart';
|
||||
import '../constant/links.dart';
|
||||
import '../controller/functions/crud.dart';
|
||||
import '../main.dart';
|
||||
import '../print.dart';
|
||||
|
||||
class TurnCredentialsService {
|
||||
static const String _cacheKey = BoxName.turnIceCache;
|
||||
|
||||
/// خوادم ICE جاهزة لتمريرها إلى createPeerConnection.
|
||||
/// تعيد قائمة فارغة عند أي فشل — والمُنادي يكمل بـ STUN كما كان سابقاً.
|
||||
static Future<List<Map<String, dynamic>>> getIceServers() async {
|
||||
final cached = _readCache();
|
||||
if (cached != null) return cached;
|
||||
|
||||
try {
|
||||
final res = await CRUD().post(
|
||||
link: '${AppLink.server}/ride/call/turn_credentials.php',
|
||||
);
|
||||
|
||||
if (res is! Map || res['status'] != 'success') return [];
|
||||
|
||||
// غلاف jsonSuccess يضع الحمولة في message
|
||||
final payload = res['message'];
|
||||
if (payload is! Map || payload['ice_servers'] is! List) return [];
|
||||
|
||||
final servers = (payload['ice_servers'] as List)
|
||||
.whereType<Map>()
|
||||
.map((s) => Map<String, dynamic>.from(s))
|
||||
.toList();
|
||||
|
||||
final ttl = int.tryParse(payload['ttl']?.toString() ?? '0') ?? 0;
|
||||
if (ttl > 0 && servers.isNotEmpty) {
|
||||
// ننتهي قبل الخادم بخمس دقائق تفادياً لسباق انتهاء الصلاحية أثناء مكالمة
|
||||
final expiresAt = DateTime.now().add(Duration(seconds: ttl - 300));
|
||||
box.write(_cacheKey, jsonEncode({
|
||||
'expires_at': expiresAt.toIso8601String(),
|
||||
'servers': servers,
|
||||
}));
|
||||
}
|
||||
|
||||
return servers;
|
||||
} catch (e) {
|
||||
Log.print('⚠️ [TURN] تعذّر جلب بيانات الاعتماد: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static List<Map<String, dynamic>>? _readCache() {
|
||||
try {
|
||||
final raw = box.read(_cacheKey);
|
||||
if (raw == null) return null;
|
||||
|
||||
final data = jsonDecode(raw.toString());
|
||||
final expiresAt = DateTime.tryParse(data['expires_at']?.toString() ?? '');
|
||||
if (expiresAt == null || DateTime.now().isAfter(expiresAt)) return null;
|
||||
|
||||
final servers = (data['servers'] as List)
|
||||
.whereType<Map>()
|
||||
.map((s) => Map<String, dynamic>.from(s))
|
||||
.toList();
|
||||
return servers.isEmpty ? null : servers;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static void clearCache() => box.remove(_cacheKey);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
// food_delivery_earnings_page.dart — أرباح التوصيل وسجل الطلبات المسلَّمة
|
||||
// (منفصلة عن أرباح الرحلات: المصدر food_orders.delivery_fee لا محفظة الرحلات)
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../constant/colors.dart';
|
||||
import '../../constant/style.dart';
|
||||
import '../../controller/food_delivery/food_delivery_models.dart';
|
||||
import '../../controller/food_delivery/food_delivery_service.dart';
|
||||
import '../widgets/my_scafold.dart';
|
||||
|
||||
class FoodDeliveryEarningsPage extends StatefulWidget {
|
||||
const FoodDeliveryEarningsPage({super.key});
|
||||
|
||||
@override
|
||||
State<FoodDeliveryEarningsPage> createState() => _FoodDeliveryEarningsPageState();
|
||||
}
|
||||
|
||||
class _FoodDeliveryEarningsPageState extends State<FoodDeliveryEarningsPage> {
|
||||
FoodEarnings? earnings;
|
||||
List<FoodHistoryEntry> history = [];
|
||||
bool isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final results = await Future.wait([
|
||||
FoodDeliveryService.getEarnings(),
|
||||
FoodDeliveryService.getHistory(limit: 30),
|
||||
]);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
earnings = (results[0] as FoodDeliveryApiResult<FoodEarnings>).data;
|
||||
history = (results[1] as FoodDeliveryApiResult<List<FoodHistoryEntry>>).data ?? [];
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MyScafolld(
|
||||
title: 'أرباح التوصيل',
|
||||
isleading: true,
|
||||
body: [
|
||||
if (isLoading)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else
|
||||
RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (earnings != null) ..._summary(earnings!),
|
||||
const SizedBox(height: 20),
|
||||
Text('سجل التوصيلات',
|
||||
style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
if (history.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||
child: Center(
|
||||
child: Text('لم تنفّذ أي طلب توصيل بعد', style: AppStyle.subtitle)),
|
||||
)
|
||||
else
|
||||
...history.map(_historyTile),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _summary(FoodEarnings e) {
|
||||
return [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _statTile('اليوم', e.todayEarnings, e.todayOrders, AppColor.greenColor)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: _statTile('آخر 7 أيام', e.weekEarnings, e.weekOrders, AppColor.accentColor)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _statTile('آخر 30 يوم', e.monthEarnings, e.monthOrders, AppColor.primaryColor)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: _statTile('الإجمالي', e.totalEarnings, e.totalOrders, AppColor.grayColor)),
|
||||
],
|
||||
),
|
||||
if (e.pendingCashOwed > 0) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF29900).withOpacity(0.12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: const Color(0xFFF29900)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.account_balance_rounded, color: Color(0xFFF29900)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'مبالغ نقدية بذمّتك للتسوية: ${foodFormatPrice(e.pendingCashOwed)}',
|
||||
style: AppStyle.title,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
if (e.daily.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
Text('تفصيل آخر 7 أيام', style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
...e.daily.map(
|
||||
(d) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(d.day, style: AppStyle.subtitle),
|
||||
Text('${d.ordersCount} طلب', style: AppStyle.subtitle),
|
||||
Text(foodFormatPrice(d.earnings),
|
||||
style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
Widget _statTile(String label, int amount, int orders, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.cardColor,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColor.borderColor),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: AppStyle.subtitle),
|
||||
const SizedBox(height: 6),
|
||||
Text(foodFormatPrice(amount),
|
||||
style: AppStyle.title.copyWith(fontWeight: FontWeight.bold, color: color)),
|
||||
Text('$orders طلب', style: AppStyle.subtitle),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _historyTile(FoodHistoryEntry h) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.cardColor,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColor.borderColor),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.moped_rounded, color: AppColor.accentColor),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(h.merchantNameAr,
|
||||
style: AppStyle.title.copyWith(fontWeight: FontWeight.bold),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
Text(
|
||||
[
|
||||
if (h.deliveredAt != null)
|
||||
'${h.deliveredAt!.year}/${h.deliveredAt!.month}/${h.deliveredAt!.day}',
|
||||
if (h.durationMinutes != null) '${h.durationMinutes} دقيقة',
|
||||
'${h.itemsCount} صنف',
|
||||
h.paymentMethod == 'cash' ? 'نقداً' : 'محفظة',
|
||||
].join(' • '),
|
||||
style: AppStyle.subtitle,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(foodFormatPrice(h.deliveryFee),
|
||||
style: AppStyle.title.copyWith(
|
||||
fontWeight: FontWeight.bold, color: AppColor.greenColor)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
// food_delivery_home_page.dart — تبويب التوصيل: تفعيل وضع التوصيل، عروض واردة، مهام نشطة
|
||||
// food_delivery_home_page.dart — تبويب التوصيل: وضع التوصيل، أرباح اليوم، مهام نشطة
|
||||
// شاشة العرض الوارد مستقلة (FoodOfferPage) وتُفتح تلقائياً من الكنترولر عند وصول
|
||||
// عرض من سوكيت الطعام — هذه الشاشة للإدارة والمتابعة فقط.
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
@@ -8,6 +10,9 @@ import '../../controller/food_delivery/food_delivery_controller.dart';
|
||||
import '../../controller/food_delivery/food_delivery_models.dart';
|
||||
import '../widgets/elevated_btn.dart';
|
||||
import '../widgets/my_scafold.dart';
|
||||
import 'food_delivery_earnings_page.dart';
|
||||
import 'food_offer_page.dart';
|
||||
import 'food_task_details_page.dart';
|
||||
|
||||
class FoodDeliveryHomePage extends StatelessWidget {
|
||||
const FoodDeliveryHomePage({super.key});
|
||||
@@ -18,16 +23,25 @@ class FoodDeliveryHomePage extends StatelessWidget {
|
||||
|
||||
return GetBuilder<FoodDeliveryController>(
|
||||
builder: (c) => MyScafolld(
|
||||
title: 'Delivery'.tr,
|
||||
title: 'التوصيل',
|
||||
isleading: true,
|
||||
action: IconButton(
|
||||
icon: Icon(Icons.account_balance_wallet_rounded, color: AppColor.accentColor),
|
||||
tooltip: 'أرباح التوصيل',
|
||||
onPressed: () => Get.to(() => const FoodDeliveryEarningsPage()),
|
||||
),
|
||||
body: [
|
||||
Column(
|
||||
children: [
|
||||
_modeToggleBar(c),
|
||||
if (c.pendingOffers.isNotEmpty) _offersSection(c),
|
||||
_todayStrip(c),
|
||||
if (c.pendingOffers.isNotEmpty) _offersBanner(c),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: c.fetchActiveTasks,
|
||||
onRefresh: () async {
|
||||
await c.refreshStatus();
|
||||
await c.fetchActiveTasks();
|
||||
},
|
||||
child: c.isLoadingTasks && c.activeTasks.isEmpty
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: c.activeTasks.isEmpty
|
||||
@@ -48,12 +62,14 @@ class FoodDeliveryHomePage extends StatelessWidget {
|
||||
|
||||
Widget _modeToggleBar(FoodDeliveryController c) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
margin: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.cardColor,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColor.borderColor),
|
||||
border: Border.all(
|
||||
color: c.isDeliveryModeEnabled ? AppColor.greenColor : AppColor.borderColor,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -66,11 +82,27 @@ class FoodDeliveryHomePage extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Delivery Mode'.tr, style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
|
||||
Row(
|
||||
children: [
|
||||
Text('وضع التوصيل',
|
||||
style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
|
||||
if (c.isDeliveryModeEnabled) ...[
|
||||
const SizedBox(width: 8),
|
||||
// مؤشّر قناة الطعام وحدها — انقطاعها لا يعني انقطاع الرحلات
|
||||
Icon(
|
||||
c.isSocketConnected ? Icons.wifi_rounded : Icons.wifi_off_rounded,
|
||||
size: 14,
|
||||
color: c.isSocketConnected ? AppColor.greenColor : AppColor.grayColor,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
Text(
|
||||
c.isDeliveryModeEnabled
|
||||
? 'You will receive delivery offers'.tr
|
||||
: 'Turn on to receive delivery offers'.tr,
|
||||
? (c.isSocketConnected
|
||||
? 'متصل — ستصلك عروض التوصيل فوراً'
|
||||
: 'إعادة الاتصال… العروض تصل بالتحديث الدوري')
|
||||
: 'فعّله لاستقبال عروض توصيل الطعام',
|
||||
style: AppStyle.subtitle,
|
||||
),
|
||||
],
|
||||
@@ -88,80 +120,62 @@ class FoodDeliveryHomePage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _offersSection(FoodDeliveryController c) {
|
||||
return SizedBox(
|
||||
height: 150,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
itemCount: c.pendingOffers.length,
|
||||
itemBuilder: (_, i) => _offerCard(c, c.pendingOffers[i]),
|
||||
Widget _todayStrip(FoodDeliveryController c) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.cardColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColor.borderColor),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_miniStat('توصيلات اليوم', '${c.todayOrdersCount}'),
|
||||
Container(width: 1, height: 28, color: AppColor.borderColor),
|
||||
_miniStat('أرباح اليوم', foodFormatPrice(c.todayEarnings)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _offerCard(FoodDeliveryController c, FoodDeliveryOffer offer) {
|
||||
final isResponding = c.isRespondingToOffer(offer.orderId);
|
||||
return Container(
|
||||
width: 260,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.primaryColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.15), blurRadius: 12, offset: const Offset(0, 4))],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.notifications_active_rounded, color: Colors.white, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text('New Delivery Offer'.tr,
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
|
||||
Widget _miniStat(String label, String value) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(label, style: AppStyle.subtitle),
|
||||
Text(value, style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// شريط تنبيه فقط — القرار الفعلي يتم في شاشة العرض المستقلة
|
||||
Widget _offersBanner(FoodDeliveryController c) {
|
||||
final count = c.pendingOffers.length;
|
||||
return GestureDetector(
|
||||
onTap: () => Get.to(() => const FoodOfferPage(), fullscreenDialog: true),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(colors: [Color(0xFFFF6B35), Color(0xFFF7931E)]),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.notifications_active_rounded, color: Colors.white),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
count > 1 ? 'لديك $count عروض توصيل بانتظار ردّك' : 'لديك عرض توصيل جديد',
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15),
|
||||
),
|
||||
Text('${offer.secondsRemaining}s', style: const TextStyle(color: Colors.amber, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(offer.merchantNameAr,
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15),
|
||||
maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
Text(foodFormatPrice(offer.deliveryFee),
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13)),
|
||||
const Spacer(),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 36,
|
||||
child: isResponding
|
||||
? const Center(child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)))
|
||||
: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(side: const BorderSide(color: Colors.white54)),
|
||||
onPressed: () => c.respondToOffer(offer.orderId, false),
|
||||
child: Text('Reject'.tr, style: const TextStyle(color: Colors.white70, fontSize: 12)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 36,
|
||||
child: isResponding
|
||||
? const SizedBox.shrink()
|
||||
: MyElevatedButton(
|
||||
title: 'Accept'.tr,
|
||||
kolor: AppColor.greenColor,
|
||||
onPressed: () => c.respondToOffer(offer.orderId, true),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Text('${c.pendingOffers.first.secondsRemaining}s',
|
||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -172,13 +186,20 @@ class FoodDeliveryHomePage extends StatelessWidget {
|
||||
const SizedBox(height: 100),
|
||||
Icon(Icons.moped_outlined, size: 72, color: AppColor.grayColor),
|
||||
const SizedBox(height: 16),
|
||||
Center(child: Text('No delivery tasks right now'.tr, style: AppStyle.title)),
|
||||
Center(
|
||||
child: Text(
|
||||
c.isDeliveryModeEnabled
|
||||
? 'لا توجد مهام توصيل حالياً — بانتظار العروض'
|
||||
: 'فعّل وضع التوصيل لاستقبال الطلبات',
|
||||
style: AppStyle.title,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _taskCard(FoodDeliveryController c, FoodDeliveryTask task) {
|
||||
final isPickedUp = task.status == 'picked_up';
|
||||
final isBusy = c.isTaskBusy(task.id);
|
||||
|
||||
return Card(
|
||||
@@ -187,47 +208,71 @@ class FoodDeliveryHomePage extends StatelessWidget {
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
side: BorderSide(color: isPickedUp ? AppColor.greenColor : AppColor.borderColor, width: isPickedUp ? 1.5 : 1),
|
||||
side: BorderSide(
|
||||
color: task.isPickedUp ? AppColor.greenColor : AppColor.borderColor,
|
||||
width: task.isPickedUp ? 1.5 : 1,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(isPickedUp ? Icons.location_on_rounded : Icons.storefront_rounded,
|
||||
color: AppColor.accentColor),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
isPickedUp ? (task.deliveryAddress ?? '') : task.merchantNameAr,
|
||||
style: AppStyle.title.copyWith(fontWeight: FontWeight.bold),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Text(foodFormatPrice(task.deliveryFee), style: AppStyle.subtitle),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
isPickedUp ? 'Deliver to customer'.tr : (task.merchantAddress ?? ''),
|
||||
style: AppStyle.subtitle,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 44,
|
||||
child: isBusy
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: MyElevatedButton(
|
||||
title: isPickedUp ? 'Mark Delivered'.tr : 'Picked Up from Restaurant'.tr,
|
||||
kolor: isPickedUp ? AppColor.greenColor : AppColor.accentColor,
|
||||
onPressed: () => isPickedUp ? c.markDelivered(task.id) : c.markPickedUp(task.id),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: () => Get.to(() => FoodTaskDetailsPage(orderId: task.id)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(task.isPickedUp ? Icons.location_on_rounded : Icons.storefront_rounded,
|
||||
color: AppColor.accentColor),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
task.isPickedUp
|
||||
? (task.deliveryAddress ?? 'عنوان الزبون')
|
||||
: task.merchantNameAr,
|
||||
style: AppStyle.title.copyWith(fontWeight: FontWeight.bold),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(foodFormatPrice(task.deliveryFee), style: AppStyle.subtitle),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
task.isPickedUp
|
||||
? 'في الطريق للزبون — ${task.itemsCount} صنف'
|
||||
: (task.merchantAddress ?? 'استلم الطلب من المطعم'),
|
||||
style: AppStyle.subtitle,
|
||||
),
|
||||
if (task.isCash) ...[
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.payments_rounded, size: 16, color: Color(0xFFF29900)),
|
||||
const SizedBox(width: 6),
|
||||
Text('تحصيل نقدي: ${foodFormatPrice(task.cashToCollect)}',
|
||||
style: AppStyle.subtitle.copyWith(color: const Color(0xFFF29900))),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 44,
|
||||
child: isBusy
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: MyElevatedButton(
|
||||
title: task.isPickedUp ? 'تأكيد التسليم' : 'استلمت الطلب من المطعم',
|
||||
kolor: task.isPickedUp ? AppColor.greenColor : AppColor.accentColor,
|
||||
onPressed: () => task.isPickedUp
|
||||
? c.markDelivered(task.id)
|
||||
: c.markPickedUp(task.id),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
// food_offer_page.dart — شاشة استقبال عرض التوصيل (مستقلة عن شاشة عرض الرحلة)
|
||||
//
|
||||
// شاشة كاملة مقصودة لا بطاقة صغيرة: مهلة العرض 20 ثانية، والسائق يحتاج يقرأ
|
||||
// المطعم والأجرة وهل التحصيل نقدي قبل أن يقرر. تفتح تلقائياً عند وصول العرض
|
||||
// من سوكيت الطعام، وتُغلق نفسها إذا انتهت مهلة كل العروض.
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../../constant/colors.dart';
|
||||
import '../../controller/food_delivery/food_delivery_controller.dart';
|
||||
import '../../controller/food_delivery/food_delivery_models.dart';
|
||||
import '../../controller/functions/location_controller.dart';
|
||||
|
||||
class FoodOfferPage extends StatelessWidget {
|
||||
const FoodOfferPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetBuilder<FoodDeliveryController>(
|
||||
builder: (c) {
|
||||
if (c.pendingOffers.isEmpty) return const _NoOfferView();
|
||||
|
||||
final offer = c.pendingOffers.first;
|
||||
final isResponding = c.isRespondingToOffer(offer.orderId);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF12171F),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_Header(offer: offer, queued: c.pendingOffers.length),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
_FeeBadge(offer: offer),
|
||||
const SizedBox(height: 20),
|
||||
_RouteCard(offer: offer),
|
||||
const SizedBox(height: 14),
|
||||
_MetaRow(offer: offer),
|
||||
if (offer.isCash) ...[
|
||||
const SizedBox(height: 14),
|
||||
_CashWarning(amount: offer.cashToCollect),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_Actions(
|
||||
isResponding: isResponding,
|
||||
onAccept: () => c.respondToOffer(offer.orderId, true),
|
||||
onReject: () => c.respondToOffer(offer.orderId, false),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NoOfferView extends StatelessWidget {
|
||||
const _NoOfferView();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF12171F),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.timer_off_rounded, size: 64, color: Colors.white38),
|
||||
const SizedBox(height: 12),
|
||||
const Text('انتهت مهلة العرض',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 16)),
|
||||
const SizedBox(height: 20),
|
||||
TextButton(onPressed: Get.back, child: const Text('رجوع')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Header extends StatelessWidget {
|
||||
final FoodDeliveryOffer offer;
|
||||
final int queued;
|
||||
const _Header({required this.offer, required this.queued});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final remaining = offer.secondsRemaining;
|
||||
final progress = offer.ttlSeconds == 0 ? 0.0 : remaining / offer.ttlSeconds;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 62,
|
||||
height: 62,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 62,
|
||||
height: 62,
|
||||
child: CircularProgressIndicator(
|
||||
value: progress,
|
||||
strokeWidth: 5,
|
||||
backgroundColor: Colors.white12,
|
||||
valueColor: AlwaysStoppedAnimation(
|
||||
remaining <= 5 ? const Color(0xFFE53935) : const Color(0xFFFF6B35),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text('$remaining',
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontWeight: FontWeight.bold, fontSize: 20)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('🍔 عرض توصيل طلب طعام',
|
||||
style: TextStyle(
|
||||
color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
Text(
|
||||
queued > 1 ? 'لديك $queued عروض بالانتظار' : 'وافق قبل انتهاء المهلة',
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FeeBadge extends StatelessWidget {
|
||||
final FoodDeliveryOffer offer;
|
||||
const _FeeBadge({required this.offer});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFFFF6B35), Color(0xFFF7931E)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text('أجرة التوصيل',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 13)),
|
||||
const SizedBox(height: 4),
|
||||
Text(foodFormatPrice(offer.deliveryFee),
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontSize: 30, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RouteCard extends StatelessWidget {
|
||||
final FoodDeliveryOffer offer;
|
||||
const _RouteCard({required this.offer});
|
||||
|
||||
// مسافات تقريبية بخط مستقيم — كافية لقرار القبول خلال ثوانٍ، ولا نستدعي
|
||||
// خدمة مسارات هنا حتى لا نضيف زمناً على مهلة عرض قصيرة أصلاً.
|
||||
double? _distanceKm(double? aLat, double? aLng, double bLat, double bLng) {
|
||||
if (aLat == null || aLng == null || bLat == 0 || bLng == 0) return null;
|
||||
return Geolocator.distanceBetween(aLat, aLng, bLat, bLng) / 1000;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
double? driverLat;
|
||||
double? driverLng;
|
||||
if (Get.isRegistered<LocationController>()) {
|
||||
final loc = Get.find<LocationController>().myLocation;
|
||||
if (loc.latitude != 0) {
|
||||
driverLat = loc.latitude;
|
||||
driverLng = loc.longitude;
|
||||
}
|
||||
}
|
||||
|
||||
final toMerchant =
|
||||
_distanceKm(driverLat, driverLng, offer.merchantLat ?? 0, offer.merchantLng ?? 0);
|
||||
final merchantToCustomer =
|
||||
_distanceKm(offer.merchantLat, offer.merchantLng, offer.deliveryLat, offer.deliveryLng);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.06),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.white12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_RouteRow(
|
||||
icon: Icons.storefront_rounded,
|
||||
iconColor: const Color(0xFFFF6B35),
|
||||
title: offer.merchantNameAr,
|
||||
subtitle: offer.merchantAddress ?? 'الاستلام من المطعم',
|
||||
trailing: toMerchant == null ? null : '${toMerchant.toStringAsFixed(1)} كم',
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 18),
|
||||
Container(width: 2, height: 22, color: Colors.white24),
|
||||
],
|
||||
),
|
||||
),
|
||||
_RouteRow(
|
||||
icon: Icons.location_on_rounded,
|
||||
iconColor: const Color(0xFF4CAF50),
|
||||
title: 'التسليم للزبون',
|
||||
// العنوان النصّي لا يصل في العرض — يظهر بعد القبول فقط (حماية بيانات الزبون)
|
||||
subtitle: 'يظهر العنوان الكامل بعد قبول الطلب',
|
||||
trailing: merchantToCustomer == null
|
||||
? null
|
||||
: '${merchantToCustomer.toStringAsFixed(1)} كم',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RouteRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String? trailing;
|
||||
|
||||
const _RouteRow({
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, color: iconColor, size: 24),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontSize: 15, fontWeight: FontWeight.bold),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
Text(subtitle,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null)
|
||||
Text(trailing!,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70, fontSize: 13, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetaRow extends StatelessWidget {
|
||||
final FoodDeliveryOffer offer;
|
||||
const _MetaRow({required this.offer});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MetaTile(
|
||||
icon: Icons.shopping_bag_rounded,
|
||||
label: 'عدد الأصناف',
|
||||
value: offer.itemsCount > 0 ? '${offer.itemsCount}' : '—',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _MetaTile(
|
||||
icon: offer.isCash ? Icons.payments_rounded : Icons.account_balance_wallet_rounded,
|
||||
label: 'طريقة الدفع',
|
||||
value: offer.isCash ? 'نقداً' : 'محفظة',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetaTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
const _MetaTile({required this.icon, required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.06),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(icon, color: Colors.white70, size: 20),
|
||||
const SizedBox(height: 6),
|
||||
Text(label, style: const TextStyle(color: Colors.white54, fontSize: 11)),
|
||||
Text(value,
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontSize: 15, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CashWarning extends StatelessWidget {
|
||||
final int amount;
|
||||
const _CashWarning({required this.amount});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF29900).withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: const Color(0xFFF29900).withOpacity(0.5)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_rounded, color: Color(0xFFF29900)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'طلب نقدي — ستحصّل ${foodFormatPrice(amount)} من الزبون عند التسليم',
|
||||
style: const TextStyle(color: Color(0xFFFFD08A), fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Actions extends StatelessWidget {
|
||||
final bool isResponding;
|
||||
final VoidCallback onAccept;
|
||||
final VoidCallback onReject;
|
||||
|
||||
const _Actions({
|
||||
required this.isResponding,
|
||||
required this.onAccept,
|
||||
required this.onReject,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isResponding) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: CircularProgressIndicator(color: Color(0xFFFF6B35))),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: const BorderSide(color: Colors.white24),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
onPressed: onReject,
|
||||
child: const Text('رفض',
|
||||
style: TextStyle(color: Colors.white70, fontSize: 16)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColor.greenColor,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
onPressed: onAccept,
|
||||
child: const Text('قبول الطلب',
|
||||
style: TextStyle(
|
||||
color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
// food_task_details_page.dart — شاشة مهمة التوصيل: الأصناف، المبالغ، الملاحة،
|
||||
// الاتصال بالمطعم، ومبلغ التحصيل النقدي، وزر الخطوة التالية (استلمت/سلّمت).
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
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_delivery_models.dart';
|
||||
import '../../controller/food_delivery/food_delivery_service.dart';
|
||||
import '../widgets/elevated_btn.dart';
|
||||
import '../widgets/error_snakbar.dart';
|
||||
import '../widgets/my_scafold.dart';
|
||||
|
||||
class FoodTaskDetailsPage extends StatefulWidget {
|
||||
final int orderId;
|
||||
const FoodTaskDetailsPage({super.key, required this.orderId});
|
||||
|
||||
@override
|
||||
State<FoodTaskDetailsPage> createState() => _FoodTaskDetailsPageState();
|
||||
}
|
||||
|
||||
class _FoodTaskDetailsPageState extends State<FoodTaskDetailsPage> {
|
||||
FoodOrderDetails? details;
|
||||
bool isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final res = await FoodDeliveryService.getOrderDetails(widget.orderId);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
details = res.data;
|
||||
});
|
||||
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> _call(String phone) async {
|
||||
final uri = Uri(scheme: 'tel', path: phone);
|
||||
if (!await launchUrl(uri)) mySnackbarWarning('تعذّر إجراء الاتصال');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MyScafolld(
|
||||
title: 'مهمة التوصيل',
|
||||
isleading: true,
|
||||
body: [
|
||||
if (isLoading)
|
||||
const Center(child: CircularProgressIndicator())
|
||||
else if (details == null)
|
||||
Center(child: Text('تعذّر تحميل تفاصيل الطلب', style: AppStyle.title))
|
||||
else
|
||||
RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: _content(details!),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _content(FoodOrderDetails d) {
|
||||
final t = d.task;
|
||||
return [
|
||||
_statusStepper(t),
|
||||
const SizedBox(height: 16),
|
||||
if (t.isCash) ...[_cashBanner(t), const SizedBox(height: 16)],
|
||||
_locationCard(
|
||||
icon: Icons.storefront_rounded,
|
||||
color: AppColor.accentColor,
|
||||
title: t.merchantNameAr,
|
||||
subtitle: t.merchantAddress ?? 'مطعم',
|
||||
onNavigate: (t.merchantLat != null && t.merchantLng != null)
|
||||
? () => _openMaps(t.merchantLat!, t.merchantLng!)
|
||||
: null,
|
||||
onCall: d.merchantPhone == null ? null : () => _call(d.merchantPhone!),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_locationCard(
|
||||
icon: Icons.location_on_rounded,
|
||||
color: AppColor.greenColor,
|
||||
title: 'عنوان الزبون',
|
||||
subtitle: t.deliveryAddress ?? 'غير متاح',
|
||||
onNavigate: t.deliveryLat == 0 ? null : () => _openMaps(t.deliveryLat, t.deliveryLng),
|
||||
// مكالمة مقنّعة: لا رقم هاتف للزبون يظهر أو يُخزَّن — اتصال صوتي
|
||||
// عبر جلسة مؤقتة، ومتاح فقط ما دامت المهمة نشطة.
|
||||
onCall: () => Get.find<FoodDeliveryController>().callCustomer(t.id),
|
||||
callIcon: Icons.phone_in_talk_rounded,
|
||||
callTooltip: 'اتصال مقنّع بالزبون',
|
||||
),
|
||||
if ((t.customerNote ?? '').isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
_noteCard(t.customerNote!),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
_itemsCard(d.items),
|
||||
const SizedBox(height: 16),
|
||||
_totalsCard(t),
|
||||
const SizedBox(height: 20),
|
||||
_actionButton(t),
|
||||
const SizedBox(height: 24),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _statusStepper(FoodDeliveryTask t) {
|
||||
final steps = [
|
||||
('تم الإسناد', true),
|
||||
('استلام من المطعم', t.isPickedUp),
|
||||
('تسليم للزبون', false),
|
||||
];
|
||||
return Row(
|
||||
children: List.generate(steps.length * 2 - 1, (i) {
|
||||
if (i.isOdd) {
|
||||
final done = steps[(i - 1) ~/ 2].$2;
|
||||
return Expanded(
|
||||
child: Container(height: 2, color: done ? AppColor.greenColor : AppColor.borderColor),
|
||||
);
|
||||
}
|
||||
final step = steps[i ~/ 2];
|
||||
return Column(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 14,
|
||||
backgroundColor: step.$2 ? AppColor.greenColor : AppColor.borderColor,
|
||||
child: Icon(step.$2 ? Icons.check : Icons.circle,
|
||||
size: 14, color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SizedBox(
|
||||
width: 80,
|
||||
child: Text(step.$1,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppStyle.subtitle.copyWith(fontSize: 11)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _cashBanner(FoodDeliveryTask t) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF29900).withOpacity(0.12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: const Color(0xFFF29900)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.payments_rounded, color: Color(0xFFF29900)),
|
||||
const SizedBox(width: 8),
|
||||
Text('تحصيل نقدي',
|
||||
style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('المبلغ المطلوب من الزبون: ${foodFormatPrice(t.cashToCollect)}',
|
||||
style: AppStyle.title),
|
||||
const SizedBox(height: 4),
|
||||
// نفصّل الرقمين صراحةً كي لا يحسب السائق على باب الزبون:
|
||||
// أجرته تبقى معه، والباقي يُسوَّى إدارياً لاحقاً.
|
||||
Text(
|
||||
'تحتفظ بأجرتك ${foodFormatPrice(t.deliveryFee)} — ويبقى ${foodFormatPrice(t.courierOwes)} للتسوية',
|
||||
style: AppStyle.subtitle,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _locationCard({
|
||||
required IconData icon,
|
||||
required Color color,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
VoidCallback? onNavigate,
|
||||
VoidCallback? onCall,
|
||||
IconData callIcon = Icons.call_rounded,
|
||||
String callTooltip = 'اتصال',
|
||||
}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.cardColor,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColor.borderColor),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: color),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: AppStyle.title.copyWith(fontWeight: FontWeight.bold),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis),
|
||||
Text(subtitle, style: AppStyle.subtitle, maxLines: 2),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onCall != null)
|
||||
IconButton(
|
||||
onPressed: onCall,
|
||||
icon: Icon(callIcon, color: AppColor.greenColor),
|
||||
tooltip: callTooltip,
|
||||
),
|
||||
if (onNavigate != null)
|
||||
IconButton(
|
||||
onPressed: onNavigate,
|
||||
icon: Icon(Icons.navigation_rounded, color: AppColor.accentColor),
|
||||
tooltip: 'الملاحة',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _noteCard(String note) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.cardColor,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColor.borderColor),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.sticky_note_2_rounded, color: AppColor.grayColor),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text('ملاحظة الزبون: $note', style: AppStyle.subtitle)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _itemsCard(List<FoodOrderItem> items) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.cardColor,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColor.borderColor),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('محتوى الطلب', style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
if (items.isEmpty)
|
||||
Text('لا توجد تفاصيل أصناف', style: AppStyle.subtitle)
|
||||
else
|
||||
...items.map(
|
||||
(i) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.accentColor.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text('×${i.quantity}',
|
||||
style: AppStyle.subtitle.copyWith(fontWeight: FontWeight.bold)),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(i.nameAr, style: AppStyle.title)),
|
||||
Text(foodFormatPrice(i.lineTotal), style: AppStyle.subtitle),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _totalsCard(FoodDeliveryTask t) {
|
||||
Widget row(String label, int amount, {bool bold = false}) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(label,
|
||||
style: bold
|
||||
? AppStyle.title.copyWith(fontWeight: FontWeight.bold)
|
||||
: AppStyle.subtitle),
|
||||
Text(foodFormatPrice(amount),
|
||||
style: bold
|
||||
? AppStyle.title.copyWith(fontWeight: FontWeight.bold)
|
||||
: AppStyle.subtitle),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColor.cardColor,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColor.borderColor),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
row('قيمة الأصناف', t.itemsTotal),
|
||||
row('أجرة التوصيل (لك)', t.deliveryFee),
|
||||
if (t.serviceFee > 0) row('رسوم الخدمة', t.serviceFee),
|
||||
if (t.discount > 0) row('الخصم', t.discount),
|
||||
const Divider(),
|
||||
row('إجمالي الطلب', t.grandTotal, bold: true),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _actionButton(FoodDeliveryTask t) {
|
||||
final c = Get.find<FoodDeliveryController>();
|
||||
return GetBuilder<FoodDeliveryController>(
|
||||
builder: (_) {
|
||||
if (c.isTaskBusy(t.id)) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
child: MyElevatedButton(
|
||||
title: t.isPickedUp ? 'تأكيد التسليم للزبون' : 'استلمت الطلب من المطعم',
|
||||
kolor: t.isPickedUp ? AppColor.greenColor : AppColor.accentColor,
|
||||
onPressed: () async {
|
||||
if (t.isPickedUp) {
|
||||
await c.markDelivered(t.id);
|
||||
if (mounted) Get.back();
|
||||
} else {
|
||||
await c.markPickedUp(t.id);
|
||||
await _load();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1521,10 +1521,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.19"
|
||||
version: "0.12.18"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1537,10 +1537,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
version: "1.17.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -2125,10 +2125,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
version: "0.7.9"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
Reference in New Issue
Block a user