Update codebase
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
// food_controller.dart — حالة تبويب الطعام (تصفح، سلة، تتبّع الطلب)
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:get/get.dart';
|
||||
import '../../constant/box_name.dart';
|
||||
import '../../main.dart';
|
||||
import '../../views/widgets/error_snakbar.dart';
|
||||
import 'food_models.dart';
|
||||
import 'food_service.dart';
|
||||
|
||||
class FoodController extends GetxController {
|
||||
// ── تصفح ──
|
||||
bool isLoadingMerchants = false;
|
||||
List<FoodMerchant> merchants = [];
|
||||
String city = _defaultCityForCountry();
|
||||
|
||||
// ── تفاصيل مطعم ──
|
||||
bool isLoadingMenu = false;
|
||||
FoodMerchant? selectedMerchant;
|
||||
List<FoodMenuCategory> categories = [];
|
||||
|
||||
// ── السلة (مقيّدة بمطعم واحد فقط) ──
|
||||
int? cartMerchantId;
|
||||
final Map<String, FoodCartLine> _cart = {};
|
||||
List<FoodCartLine> get cartLines => _cart.values.toList();
|
||||
int get cartItemsCount => _cart.values.fold(0, (sum, l) => sum + l.quantity);
|
||||
int get cartTotal => _cart.values.fold(0, (sum, l) => sum + l.lineTotal);
|
||||
|
||||
// ── عرض السعر الحالي ──
|
||||
FoodQuote? currentQuote;
|
||||
bool isQuoting = false;
|
||||
|
||||
// ── الطلب النشط (تتبّع) ──
|
||||
FoodOrder? activeOrder;
|
||||
Timer? _statusPollTimer;
|
||||
|
||||
static String _defaultCityForCountry() {
|
||||
final country = box.read(BoxName.countryCode);
|
||||
switch (country) {
|
||||
case 'SY':
|
||||
return 'دمشق';
|
||||
case 'EG':
|
||||
return 'القاهرة';
|
||||
default:
|
||||
return 'عمان';
|
||||
}
|
||||
}
|
||||
|
||||
void setCity(String newCity) {
|
||||
if (newCity.trim().isEmpty) return;
|
||||
city = newCity.trim();
|
||||
fetchMerchants();
|
||||
}
|
||||
|
||||
Future<void> fetchMerchants({String? category}) async {
|
||||
isLoadingMerchants = true;
|
||||
update();
|
||||
final res = await FoodService.browseMerchants(city: city, category: category);
|
||||
isLoadingMerchants = false;
|
||||
if (res.success) {
|
||||
merchants = res.data ?? [];
|
||||
} else {
|
||||
merchants = [];
|
||||
mySnackbarWarning(res.message);
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
Future<void> searchMerchants(String query) async {
|
||||
if (query.trim().length < 2) return;
|
||||
isLoadingMerchants = true;
|
||||
update();
|
||||
final res = await FoodService.searchMerchants(city: city, query: query.trim());
|
||||
isLoadingMerchants = false;
|
||||
if (res.success) merchants = res.data ?? [];
|
||||
update();
|
||||
}
|
||||
|
||||
Future<void> openMerchant(int merchantId) async {
|
||||
isLoadingMenu = true;
|
||||
selectedMerchant = null;
|
||||
categories = [];
|
||||
update();
|
||||
|
||||
final res = await FoodService.merchantDetails(merchantId);
|
||||
isLoadingMenu = false;
|
||||
if (res.success && res.data != null) {
|
||||
selectedMerchant = res.data!['merchant'] as FoodMerchant;
|
||||
categories = res.data!['categories'] as List<FoodMenuCategory>;
|
||||
} else {
|
||||
mySnackbarWarning(res.message);
|
||||
}
|
||||
update();
|
||||
}
|
||||
|
||||
// ── إدارة السلة ──
|
||||
|
||||
bool addToCart(FoodMenuItem item, {int quantity = 1, Map<int, List<String>>? options}) {
|
||||
final line = FoodCartLine(item: item, quantity: quantity, selectedOptions: options ?? {});
|
||||
final existing = _cart[line.lineKey];
|
||||
if (existing != null) {
|
||||
existing.quantity += quantity;
|
||||
} else {
|
||||
_cart[line.lineKey] = line;
|
||||
}
|
||||
currentQuote = null; // أي تعديل بالسلة يُبطل العرض الموقّع القديم
|
||||
update();
|
||||
return true;
|
||||
}
|
||||
|
||||
// يُستدعى من صفحة المطعم مع فحص أن السلة إما فارغة أو لنفس المطعم
|
||||
bool addToCartForMerchant(int merchantId, FoodMenuItem item,
|
||||
{int quantity = 1, Map<int, List<String>>? options}) {
|
||||
if (_cart.isNotEmpty && cartMerchantId != null && cartMerchantId != merchantId) {
|
||||
mySnackbarWarning(box.read(BoxName.lang) == 'ar'
|
||||
? 'سلتك تحتوي أصنافاً من مطعم آخر. أفرغها أولاً لإضافة من هذا المطعم.'
|
||||
: 'Your cart has items from another restaurant. Clear it first.');
|
||||
return false;
|
||||
}
|
||||
cartMerchantId = merchantId;
|
||||
return addToCart(item, quantity: quantity, options: options);
|
||||
}
|
||||
|
||||
void removeFromCart(String lineKey) {
|
||||
_cart.remove(lineKey);
|
||||
if (_cart.isEmpty) cartMerchantId = null;
|
||||
currentQuote = null;
|
||||
update();
|
||||
}
|
||||
|
||||
void updateQuantity(String lineKey, int quantity) {
|
||||
final line = _cart[lineKey];
|
||||
if (line == null) return;
|
||||
if (quantity <= 0) {
|
||||
removeFromCart(lineKey);
|
||||
return;
|
||||
}
|
||||
line.quantity = quantity;
|
||||
currentQuote = null;
|
||||
update();
|
||||
}
|
||||
|
||||
void clearCart() {
|
||||
_cart.clear();
|
||||
cartMerchantId = null;
|
||||
currentQuote = null;
|
||||
update();
|
||||
}
|
||||
|
||||
Future<bool> refreshQuote() async {
|
||||
if (cartMerchantId == null || cartLines.isEmpty) return false;
|
||||
isQuoting = true;
|
||||
update();
|
||||
|
||||
final res = await FoodService.getQuote(merchantId: cartMerchantId!, lines: cartLines);
|
||||
isQuoting = false;
|
||||
if (res.success && res.data != null) {
|
||||
currentQuote = res.data;
|
||||
update();
|
||||
return true;
|
||||
}
|
||||
currentQuote = null;
|
||||
mySnackbarWarning(res.message);
|
||||
update();
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _placingOrder = false;
|
||||
bool get isPlacingOrder => _placingOrder;
|
||||
|
||||
Future<int?> placeOrder({
|
||||
required String deliveryAddress,
|
||||
required double deliveryLat,
|
||||
required double deliveryLng,
|
||||
required String paymentMethod,
|
||||
String? customerNote,
|
||||
}) async {
|
||||
if (currentQuote == null) {
|
||||
final ok = await refreshQuote();
|
||||
if (!ok || currentQuote == null) return null;
|
||||
}
|
||||
|
||||
_placingOrder = true;
|
||||
update();
|
||||
|
||||
final res = await FoodService.createOrder(
|
||||
quoteToken: currentQuote!.quoteToken,
|
||||
clientOrderUuid: _generateUuidV4(),
|
||||
deliveryAddress: deliveryAddress,
|
||||
deliveryLat: deliveryLat,
|
||||
deliveryLng: deliveryLng,
|
||||
paymentMethod: paymentMethod,
|
||||
customerNote: customerNote,
|
||||
);
|
||||
|
||||
_placingOrder = false;
|
||||
update();
|
||||
|
||||
if (res.success && res.data != null) {
|
||||
clearCart();
|
||||
startTrackingOrder(res.data!);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
mySnackbarWarning(res.message);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── تتبّع الطلب (polling — السوكيت ليس مصدر الحقيقة، انظر ملاحظة backend) ──
|
||||
|
||||
void startTrackingOrder(int orderId) {
|
||||
_statusPollTimer?.cancel();
|
||||
_pollOrderStatus(orderId);
|
||||
_statusPollTimer = Timer.periodic(const Duration(seconds: 5), (_) => _pollOrderStatus(orderId));
|
||||
}
|
||||
|
||||
Future<void> _pollOrderStatus(int orderId) async {
|
||||
final res = await FoodService.getOrderStatus(orderId);
|
||||
if (res.success && res.data != null) {
|
||||
activeOrder = res.data;
|
||||
update();
|
||||
if (activeOrder!.status == 'delivered' || foodOrderIsCancelled(activeOrder!.status)) {
|
||||
_statusPollTimer?.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void stopTracking() {
|
||||
_statusPollTimer?.cancel();
|
||||
activeOrder = null;
|
||||
}
|
||||
|
||||
Future<bool> rateActiveOrder(int rating, {String? comment}) async {
|
||||
if (activeOrder == null) return false;
|
||||
final res = await FoodService.rateOrder(activeOrder!.id, rating, comment: comment);
|
||||
if (res.success) {
|
||||
await _pollOrderStatus(activeOrder!.id);
|
||||
return true;
|
||||
}
|
||||
mySnackbarWarning(res.message);
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> cancelActiveOrder({String? reason}) async {
|
||||
if (activeOrder == null) return false;
|
||||
final res = await FoodService.cancelOrder(activeOrder!.id, reason: reason);
|
||||
if (res.success) {
|
||||
await _pollOrderStatus(activeOrder!.id);
|
||||
return true;
|
||||
}
|
||||
mySnackbarWarning(res.message);
|
||||
return false;
|
||||
}
|
||||
|
||||
static String _generateUuidV4() {
|
||||
final rnd = Random.secure();
|
||||
final bytes = List<int>.generate(16, (_) => rnd.nextInt(256));
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant
|
||||
String hex(int start, int end) =>
|
||||
bytes.sublist(start, end).map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
return '${hex(0, 4)}-${hex(4, 6)}-${hex(6, 8)}-${hex(8, 10)}-${hex(10, 16)}';
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
_statusPollTimer?.cancel();
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
// food_models.dart — نماذج بيانات وحدة الطعام (جهة الراكب)
|
||||
|
||||
class FoodMerchant {
|
||||
final int id;
|
||||
final String nameAr;
|
||||
final String? nameEn;
|
||||
final String? logoUrl;
|
||||
final String? coverUrl;
|
||||
final String? descriptionAr;
|
||||
final String city;
|
||||
final String? address;
|
||||
final String? category;
|
||||
final int minOrderAmount;
|
||||
final int avgPrepMinutes;
|
||||
final double ratingAvg;
|
||||
final int ratingCount;
|
||||
final bool isOpen;
|
||||
|
||||
FoodMerchant({
|
||||
required this.id,
|
||||
required this.nameAr,
|
||||
required this.city,
|
||||
this.nameEn,
|
||||
this.logoUrl,
|
||||
this.coverUrl,
|
||||
this.descriptionAr,
|
||||
this.address,
|
||||
this.category,
|
||||
this.minOrderAmount = 0,
|
||||
this.avgPrepMinutes = 20,
|
||||
this.ratingAvg = 0,
|
||||
this.ratingCount = 0,
|
||||
this.isOpen = true,
|
||||
});
|
||||
|
||||
factory FoodMerchant.fromJson(Map<String, dynamic> j) => FoodMerchant(
|
||||
id: int.tryParse(j['id'].toString()) ?? 0,
|
||||
nameAr: j['name_ar']?.toString() ?? '',
|
||||
nameEn: j['name_en']?.toString(),
|
||||
logoUrl: j['logo_url']?.toString(),
|
||||
coverUrl: j['cover_url']?.toString(),
|
||||
descriptionAr: j['description_ar']?.toString(),
|
||||
city: j['city']?.toString() ?? '',
|
||||
address: j['address']?.toString(),
|
||||
category: j['category']?.toString(),
|
||||
minOrderAmount: int.tryParse(j['min_order_amount']?.toString() ?? '0') ?? 0,
|
||||
avgPrepMinutes: int.tryParse(j['avg_prep_minutes']?.toString() ?? '20') ?? 20,
|
||||
ratingAvg: double.tryParse(j['rating_avg']?.toString() ?? '0') ?? 0,
|
||||
ratingCount: int.tryParse(j['rating_count']?.toString() ?? '0') ?? 0,
|
||||
isOpen: j['is_open'] == true || j['is_open']?.toString() == '1',
|
||||
);
|
||||
}
|
||||
|
||||
class FoodOptionChoice {
|
||||
final String id;
|
||||
final String labelAr;
|
||||
final int price;
|
||||
|
||||
FoodOptionChoice({required this.id, required this.labelAr, required this.price});
|
||||
|
||||
factory FoodOptionChoice.fromJson(Map<String, dynamic> j) => FoodOptionChoice(
|
||||
id: j['id'].toString(),
|
||||
labelAr: j['label_ar']?.toString() ?? '',
|
||||
price: int.tryParse(j['price']?.toString() ?? '0') ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
class FoodItemOptionGroup {
|
||||
final int id;
|
||||
final String groupNameAr;
|
||||
final bool isRequired;
|
||||
final int maxSelect;
|
||||
final List<FoodOptionChoice> choices;
|
||||
|
||||
FoodItemOptionGroup({
|
||||
required this.id,
|
||||
required this.groupNameAr,
|
||||
required this.isRequired,
|
||||
required this.maxSelect,
|
||||
required this.choices,
|
||||
});
|
||||
|
||||
factory FoodItemOptionGroup.fromJson(Map<String, dynamic> j) => FoodItemOptionGroup(
|
||||
id: int.tryParse(j['id'].toString()) ?? 0,
|
||||
groupNameAr: j['group_name_ar']?.toString() ?? '',
|
||||
isRequired: j['is_required']?.toString() == '1' || j['is_required'] == true,
|
||||
maxSelect: int.tryParse(j['max_select']?.toString() ?? '1') ?? 1,
|
||||
choices: (j['choices'] is List)
|
||||
? (j['choices'] as List)
|
||||
.map((c) => FoodOptionChoice.fromJson(Map<String, dynamic>.from(c)))
|
||||
.toList()
|
||||
: <FoodOptionChoice>[],
|
||||
);
|
||||
}
|
||||
|
||||
class FoodMenuItem {
|
||||
final int id;
|
||||
final int categoryId;
|
||||
final String nameAr;
|
||||
final String? nameEn;
|
||||
final String? descriptionAr;
|
||||
final String? imageUrl;
|
||||
final int price;
|
||||
final bool isAvailable;
|
||||
final List<FoodItemOptionGroup> options;
|
||||
|
||||
FoodMenuItem({
|
||||
required this.id,
|
||||
required this.categoryId,
|
||||
required this.nameAr,
|
||||
required this.price,
|
||||
this.nameEn,
|
||||
this.descriptionAr,
|
||||
this.imageUrl,
|
||||
this.isAvailable = true,
|
||||
this.options = const [],
|
||||
});
|
||||
|
||||
factory FoodMenuItem.fromJson(Map<String, dynamic> j) => FoodMenuItem(
|
||||
id: int.tryParse(j['id'].toString()) ?? 0,
|
||||
categoryId: int.tryParse(j['category_id'].toString()) ?? 0,
|
||||
nameAr: j['name_ar']?.toString() ?? '',
|
||||
nameEn: j['name_en']?.toString(),
|
||||
descriptionAr: j['description_ar']?.toString(),
|
||||
imageUrl: j['image_url']?.toString(),
|
||||
price: int.tryParse(j['price']?.toString() ?? '0') ?? 0,
|
||||
isAvailable: j['is_available']?.toString() != '0' && j['is_available'] != false,
|
||||
options: (j['options'] is List)
|
||||
? (j['options'] as List)
|
||||
.map((o) => FoodItemOptionGroup.fromJson(Map<String, dynamic>.from(o)))
|
||||
.toList()
|
||||
: <FoodItemOptionGroup>[],
|
||||
);
|
||||
}
|
||||
|
||||
class FoodMenuCategory {
|
||||
final int id;
|
||||
final String nameAr;
|
||||
final String? nameEn;
|
||||
final List<FoodMenuItem> items;
|
||||
|
||||
FoodMenuCategory({
|
||||
required this.id,
|
||||
required this.nameAr,
|
||||
this.nameEn,
|
||||
this.items = const [],
|
||||
});
|
||||
|
||||
factory FoodMenuCategory.fromJson(Map<String, dynamic> j) => FoodMenuCategory(
|
||||
id: int.tryParse(j['id'].toString()) ?? 0,
|
||||
nameAr: j['name_ar']?.toString() ?? '',
|
||||
nameEn: j['name_en']?.toString(),
|
||||
items: (j['items'] is List)
|
||||
? (j['items'] as List)
|
||||
.map((i) => FoodMenuItem.fromJson(Map<String, dynamic>.from(i)))
|
||||
.toList()
|
||||
: <FoodMenuItem>[],
|
||||
);
|
||||
}
|
||||
|
||||
// ── سطر في السلة (محلي فقط قبل الإرسال للخادم) ──
|
||||
class FoodCartLine {
|
||||
final FoodMenuItem item;
|
||||
int quantity;
|
||||
// key: option_group_id, value: قائمة choice_id المختارة
|
||||
final Map<int, List<String>> selectedOptions;
|
||||
|
||||
FoodCartLine({
|
||||
required this.item,
|
||||
this.quantity = 1,
|
||||
Map<int, List<String>>? selectedOptions,
|
||||
}) : selectedOptions = selectedOptions ?? {};
|
||||
|
||||
int get unitPrice {
|
||||
int total = item.price;
|
||||
for (final group in item.options) {
|
||||
final chosen = selectedOptions[group.id] ?? [];
|
||||
for (final choiceId in chosen) {
|
||||
final choice = group.choices.firstWhere(
|
||||
(c) => c.id == choiceId,
|
||||
orElse: () => FoodOptionChoice(id: '', labelAr: '', price: 0),
|
||||
);
|
||||
total += choice.price;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
int get lineTotal => unitPrice * quantity;
|
||||
|
||||
Map<String, dynamic> toQuotePayload() => {
|
||||
'item_id': item.id,
|
||||
'quantity': quantity,
|
||||
'options': selectedOptions.entries
|
||||
.map((e) => {'option_group_id': e.key, 'choice_ids': e.value})
|
||||
.toList(),
|
||||
};
|
||||
|
||||
// مفتاح تمييز محلي: نفس الصنف بخيارات مختلفة = سطر مختلف في السلة
|
||||
String get lineKey {
|
||||
final optKeys = selectedOptions.entries.map((e) => '${e.key}:${e.value.join(",")}').join('|');
|
||||
return '${item.id}_$optKeys';
|
||||
}
|
||||
}
|
||||
|
||||
class FoodQuote {
|
||||
final String quoteToken;
|
||||
final int itemsTotal;
|
||||
final int deliveryFee;
|
||||
final int serviceFee;
|
||||
final int grandTotal;
|
||||
final int expiresIn;
|
||||
|
||||
FoodQuote({
|
||||
required this.quoteToken,
|
||||
required this.itemsTotal,
|
||||
required this.deliveryFee,
|
||||
required this.serviceFee,
|
||||
required this.grandTotal,
|
||||
required this.expiresIn,
|
||||
});
|
||||
|
||||
factory FoodQuote.fromJson(Map<String, dynamic> j) => FoodQuote(
|
||||
quoteToken: j['quote_token']?.toString() ?? '',
|
||||
itemsTotal: int.tryParse(j['items_total']?.toString() ?? '0') ?? 0,
|
||||
deliveryFee: int.tryParse(j['delivery_fee']?.toString() ?? '0') ?? 0,
|
||||
serviceFee: int.tryParse(j['service_fee']?.toString() ?? '0') ?? 0,
|
||||
grandTotal: int.tryParse(j['grand_total']?.toString() ?? '0') ?? 0,
|
||||
expiresIn: int.tryParse(j['expires_in']?.toString() ?? '600') ?? 600,
|
||||
);
|
||||
}
|
||||
|
||||
class FoodOrderItemLine {
|
||||
final String nameArSnapshot;
|
||||
final int unitPrice;
|
||||
final int quantity;
|
||||
final int lineTotal;
|
||||
|
||||
FoodOrderItemLine({
|
||||
required this.nameArSnapshot,
|
||||
required this.unitPrice,
|
||||
required this.quantity,
|
||||
required this.lineTotal,
|
||||
});
|
||||
|
||||
factory FoodOrderItemLine.fromJson(Map<String, dynamic> j) => FoodOrderItemLine(
|
||||
nameArSnapshot: j['name_ar_snapshot']?.toString() ?? '',
|
||||
unitPrice: int.tryParse(j['unit_price']?.toString() ?? '0') ?? 0,
|
||||
quantity: int.tryParse(j['quantity']?.toString() ?? '1') ?? 1,
|
||||
lineTotal: int.tryParse(j['line_total']?.toString() ?? '0') ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
class FoodOrder {
|
||||
final int id;
|
||||
final String status;
|
||||
final int itemsTotal;
|
||||
final int deliveryFee;
|
||||
final int grandTotal;
|
||||
final String? deliveryAddress;
|
||||
final int? rating;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? deliveredAt;
|
||||
final String? merchantNameAr;
|
||||
final String? merchantLogoUrl;
|
||||
final List<FoodOrderItemLine> items;
|
||||
|
||||
FoodOrder({
|
||||
required this.id,
|
||||
required this.status,
|
||||
required this.itemsTotal,
|
||||
required this.deliveryFee,
|
||||
required this.grandTotal,
|
||||
this.deliveryAddress,
|
||||
this.rating,
|
||||
this.createdAt,
|
||||
this.deliveredAt,
|
||||
this.merchantNameAr,
|
||||
this.merchantLogoUrl,
|
||||
this.items = const [],
|
||||
});
|
||||
|
||||
factory FoodOrder.fromJson(Map<String, dynamic> j) => FoodOrder(
|
||||
id: int.tryParse(j['id'].toString()) ?? 0,
|
||||
status: j['status']?.toString() ?? 'pending',
|
||||
itemsTotal: int.tryParse(j['items_total']?.toString() ?? '0') ?? 0,
|
||||
deliveryFee: int.tryParse(j['delivery_fee']?.toString() ?? '0') ?? 0,
|
||||
grandTotal: int.tryParse(j['grand_total']?.toString() ?? '0') ?? 0,
|
||||
deliveryAddress: j['delivery_address']?.toString(),
|
||||
rating: j['rating'] == null ? null : int.tryParse(j['rating'].toString()),
|
||||
createdAt: DateTime.tryParse(j['created_at']?.toString() ?? ''),
|
||||
deliveredAt: j['delivered_at'] == null ? null : DateTime.tryParse(j['delivered_at'].toString()),
|
||||
merchantNameAr: j['merchant_name_ar']?.toString(),
|
||||
merchantLogoUrl: j['merchant_logo_url']?.toString(),
|
||||
items: (j['items'] is List)
|
||||
? (j['items'] as List)
|
||||
.map((i) => FoodOrderItemLine.fromJson(Map<String, dynamic>.from(i)))
|
||||
.toList()
|
||||
: <FoodOrderItemLine>[],
|
||||
);
|
||||
}
|
||||
|
||||
// ترتيب حالات الطلب لعرض خط زمني تصاعدي في شاشة التتبع
|
||||
const List<String> foodOrderStatusFlow = [
|
||||
'pending',
|
||||
'merchant_accepted',
|
||||
'preparing',
|
||||
'ready',
|
||||
'courier_assigned',
|
||||
'picked_up',
|
||||
'delivered',
|
||||
];
|
||||
|
||||
bool foodOrderIsCancelled(String status) =>
|
||||
status.startsWith('cancelled') || status == 'rejected';
|
||||
|
||||
// المبالغ في backend/food مخزّنة بأصغر وحدة نقدية (fils) — العامل هنا يطابق
|
||||
// FOOD_CURRENCY_DIVISOR الافتراضي في docker/.env.example (1000). إن غُيّر
|
||||
// هناك يجب تغييره هنا أيضاً — القيمتان يجب أن تبقيا متطابقتين دائماً.
|
||||
const int foodCurrencyDivisor = 1000;
|
||||
|
||||
String foodFormatPrice(int smallestUnit, {String currencySymbol = 'د.أ'}) {
|
||||
final decimal = smallestUnit / foodCurrencyDivisor;
|
||||
return '${decimal.toStringAsFixed(3)} $currencySymbol';
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// food_service.dart — طبقة الاتصال بـ backend/food (جهة الراكب)
|
||||
import 'dart:convert';
|
||||
import '../functions/crud.dart';
|
||||
import '../../constant/links.dart';
|
||||
import 'food_models.dart';
|
||||
|
||||
class FoodApiResult<T> {
|
||||
final bool success;
|
||||
final T? data;
|
||||
final String message;
|
||||
final int? code;
|
||||
FoodApiResult(this.success, this.data, this.message, {this.code});
|
||||
}
|
||||
|
||||
class FoodService {
|
||||
static String get _base => '${AppLink.server}/food';
|
||||
|
||||
static Future<FoodApiResult<List<FoodMerchant>>> browseMerchants({
|
||||
required String city,
|
||||
String? category,
|
||||
}) async {
|
||||
final payload = <String, dynamic>{'city': city};
|
||||
if (category != null && category.isNotEmpty) payload['category'] = category;
|
||||
|
||||
final res = await CRUD().post(link: '$_base/merchant/browse.php', payload: payload);
|
||||
|
||||
if (res is Map && res['status'] == 'success') {
|
||||
final msg = res['message'];
|
||||
final list = (msg is Map && msg['merchants'] is List)
|
||||
? (msg['merchants'] as List)
|
||||
.map((m) => FoodMerchant.fromJson(Map<String, dynamic>.from(m)))
|
||||
.toList()
|
||||
: <FoodMerchant>[];
|
||||
return FoodApiResult(true, list, 'ok');
|
||||
}
|
||||
return FoodApiResult(false, null, _errMsg(res));
|
||||
}
|
||||
|
||||
static Future<FoodApiResult<List<FoodMerchant>>> searchMerchants({
|
||||
required String city,
|
||||
required String query,
|
||||
}) async {
|
||||
final res = await CRUD().post(
|
||||
link: '$_base/merchant/search.php',
|
||||
payload: {'city': city, 'q': query},
|
||||
);
|
||||
|
||||
if (res is Map && res['status'] == 'success') {
|
||||
final msg = res['message'];
|
||||
final list = (msg is Map && msg['merchants'] is List)
|
||||
? (msg['merchants'] as List)
|
||||
.map((m) => FoodMerchant.fromJson(Map<String, dynamic>.from(m)))
|
||||
.toList()
|
||||
: <FoodMerchant>[];
|
||||
return FoodApiResult(true, list, 'ok');
|
||||
}
|
||||
return FoodApiResult(false, null, _errMsg(res));
|
||||
}
|
||||
|
||||
static Future<FoodApiResult<Map<String, dynamic>>> merchantDetails(int merchantId) async {
|
||||
final res = await CRUD().post(
|
||||
link: '$_base/merchant/details.php',
|
||||
payload: {'merchant_id': merchantId.toString()},
|
||||
);
|
||||
|
||||
if (res is Map && res['status'] == 'success') {
|
||||
final msg = res['message'];
|
||||
if (msg is Map) {
|
||||
final merchant = FoodMerchant.fromJson(Map<String, dynamic>.from(msg['merchant']));
|
||||
final categories = (msg['categories'] is List)
|
||||
? (msg['categories'] as List)
|
||||
.map((c) => FoodMenuCategory.fromJson(Map<String, dynamic>.from(c)))
|
||||
.toList()
|
||||
: <FoodMenuCategory>[];
|
||||
return FoodApiResult(true, {'merchant': merchant, 'categories': categories}, 'ok');
|
||||
}
|
||||
}
|
||||
return FoodApiResult(false, null, _errMsg(res));
|
||||
}
|
||||
|
||||
static Future<FoodApiResult<FoodQuote>> getQuote({
|
||||
required int merchantId,
|
||||
required List<FoodCartLine> lines,
|
||||
}) async {
|
||||
final res = await CRUD().post(
|
||||
link: '$_base/cart/quote.php',
|
||||
payload: {
|
||||
'merchant_id': merchantId.toString(),
|
||||
'items': jsonEncode(lines.map((l) => l.toQuotePayload()).toList()),
|
||||
},
|
||||
);
|
||||
|
||||
if (res is Map && res['status'] == 'success') {
|
||||
final msg = res['message'];
|
||||
if (msg is Map) return FoodApiResult(true, FoodQuote.fromJson(Map<String, dynamic>.from(msg)), 'ok');
|
||||
}
|
||||
return FoodApiResult(false, null, _errMsg(res), code: _errCode(res));
|
||||
}
|
||||
|
||||
static Future<FoodApiResult<int>> createOrder({
|
||||
required String quoteToken,
|
||||
required String clientOrderUuid,
|
||||
required String deliveryAddress,
|
||||
required double deliveryLat,
|
||||
required double deliveryLng,
|
||||
required String paymentMethod, // wallet | cash
|
||||
String? customerNote,
|
||||
}) async {
|
||||
final res = await CRUD().post(
|
||||
link: '$_base/order/create.php',
|
||||
payload: {
|
||||
'quote_token': quoteToken,
|
||||
'client_order_uuid': clientOrderUuid,
|
||||
'delivery_address': deliveryAddress,
|
||||
'delivery_lat': deliveryLat.toString(),
|
||||
'delivery_lng': deliveryLng.toString(),
|
||||
'payment_method': paymentMethod,
|
||||
if (customerNote != null && customerNote.isNotEmpty) 'customer_note': customerNote,
|
||||
},
|
||||
);
|
||||
|
||||
if (res is Map && res['status'] == 'success') {
|
||||
final msg = res['message'];
|
||||
if (msg is Map && msg['order_id'] != null) {
|
||||
return FoodApiResult(true, int.tryParse(msg['order_id'].toString()) ?? 0, 'ok');
|
||||
}
|
||||
}
|
||||
return FoodApiResult(false, null, _errMsg(res), code: _errCode(res));
|
||||
}
|
||||
|
||||
static Future<FoodApiResult<FoodOrder>> getOrderStatus(int orderId) async {
|
||||
final res = await CRUD().post(
|
||||
link: '$_base/order/status.php',
|
||||
payload: {'order_id': orderId.toString()},
|
||||
);
|
||||
|
||||
if (res is Map && res['status'] == 'success') {
|
||||
final msg = res['message'];
|
||||
if (msg is Map && msg['order'] is Map) {
|
||||
return FoodApiResult(true, FoodOrder.fromJson(Map<String, dynamic>.from(msg['order'])), 'ok');
|
||||
}
|
||||
}
|
||||
return FoodApiResult(false, null, _errMsg(res));
|
||||
}
|
||||
|
||||
static Future<FoodApiResult<void>> cancelOrder(int orderId, {String? reason}) async {
|
||||
final res = await CRUD().post(
|
||||
link: '$_base/order/cancel.php',
|
||||
payload: {'order_id': orderId.toString(), if (reason != null) 'reason': reason},
|
||||
);
|
||||
|
||||
if (res is Map && res['status'] == 'success') return FoodApiResult(true, null, 'ok');
|
||||
return FoodApiResult(false, null, _errMsg(res));
|
||||
}
|
||||
|
||||
static Future<FoodApiResult<void>> rateOrder(int orderId, int rating, {String? comment}) async {
|
||||
final res = await CRUD().post(
|
||||
link: '$_base/order/rate.php',
|
||||
payload: {
|
||||
'order_id': orderId.toString(),
|
||||
'rating': rating.toString(),
|
||||
if (comment != null && comment.isNotEmpty) 'comment': comment,
|
||||
},
|
||||
);
|
||||
|
||||
if (res is Map && res['status'] == 'success') return FoodApiResult(true, null, 'ok');
|
||||
return FoodApiResult(false, null, _errMsg(res));
|
||||
}
|
||||
|
||||
static Future<FoodApiResult<List<FoodOrder>>> getHistory({int page = 1}) async {
|
||||
final res = await CRUD().post(
|
||||
link: '$_base/order/history.php',
|
||||
payload: {'page': page.toString()},
|
||||
);
|
||||
|
||||
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) => FoodOrder.fromJson(Map<String, dynamic>.from(o)))
|
||||
.toList()
|
||||
: <FoodOrder>[];
|
||||
return FoodApiResult(true, list, 'ok');
|
||||
}
|
||||
return FoodApiResult(false, null, _errMsg(res));
|
||||
}
|
||||
|
||||
static int? _errCode(dynamic res) {
|
||||
if (res is Map && res['code'] != null) return int.tryParse(res['code'].toString());
|
||||
return null;
|
||||
}
|
||||
|
||||
static String _errMsg(dynamic res) {
|
||||
if (res == 'no_internet') return 'تحقق من اتصالك بالإنترنت';
|
||||
if (res == 'token_expired') return 'انتهت الجلسة، حاول مجدداً';
|
||||
if (res is Map && res['message'] is String) return res['message'];
|
||||
return 'حدث خطأ، حاول مجدداً';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// food_tracking_controller.dart — تتبّع سائق التوصيل على الخريطة (جهة الراكب)
|
||||
//
|
||||
// مصدر الموقع: backend/food/order/courier_location.php الذي يقرأ من Redis
|
||||
// (عمر 90 ثانية). لا سوكيت هنا: تطبيق الراكب لا يحمل عميلاً لسوكيت الطعام،
|
||||
// والسحب كل 6 ثوانٍ كافٍ لحركة سلسة على الخريطة مع تحريك مُنعّم للعلامة.
|
||||
//
|
||||
// التتبّع يتوقف تلقائياً بانتهاء الطلب: الخادم يُرجع null خارج نافذة
|
||||
// courier_assigned/picked_up، وعندها نُغلق المؤقّت ولا نسأل مجدداً.
|
||||
import 'dart:async';
|
||||
import 'dart:ui' show Offset;
|
||||
|
||||
import 'package:get/get.dart';
|
||||
import 'package:intaleq_maps/intaleq_maps.dart';
|
||||
|
||||
import '../../constant/links.dart';
|
||||
import '../functions/crud.dart';
|
||||
|
||||
class FoodTrackingController extends GetxController {
|
||||
final int orderId;
|
||||
FoodTrackingController(this.orderId);
|
||||
|
||||
IntaleqMapController? mapController;
|
||||
bool isStyleLoaded = false;
|
||||
bool isLoading = true;
|
||||
|
||||
LatLng? courierPosition;
|
||||
LatLng? merchantPosition;
|
||||
LatLng? destinationPosition;
|
||||
String merchantName = '';
|
||||
String? destinationAddress;
|
||||
String orderStatus = '';
|
||||
DateTime? lastFixAt;
|
||||
|
||||
Set<Marker> markers = {};
|
||||
Timer? _timer;
|
||||
bool _cameraSettled = false;
|
||||
|
||||
bool get isTrackingActive =>
|
||||
orderStatus == 'courier_assigned' || orderStatus == 'picked_up';
|
||||
|
||||
/// الموقع قديم إن مضى عليه أكثر من دقيقة — نُعلم الراكب بدل إيهامه بحركة حيّة
|
||||
bool get isStale =>
|
||||
lastFixAt == null || DateTime.now().difference(lastFixAt!).inSeconds > 60;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
_fetch();
|
||||
_timer = Timer.periodic(const Duration(seconds: 6), (_) => _fetch());
|
||||
}
|
||||
|
||||
void onMapCreated(IntaleqMapController controller) {
|
||||
mapController = controller;
|
||||
update();
|
||||
}
|
||||
|
||||
void onStyleLoaded() {
|
||||
isStyleLoaded = true;
|
||||
_rebuildMarkers();
|
||||
_settleCamera();
|
||||
}
|
||||
|
||||
Future<void> _fetch() async {
|
||||
final res = await CRUD().post(
|
||||
link: '${AppLink.server}/food/order/courier_location.php',
|
||||
payload: {'order_id': orderId.toString()},
|
||||
);
|
||||
|
||||
isLoading = false;
|
||||
|
||||
if (res is! Map || res['status'] != 'success' || res['message'] is! Map) {
|
||||
update();
|
||||
return;
|
||||
}
|
||||
|
||||
final data = Map<String, dynamic>.from(res['message']);
|
||||
orderStatus = data['status']?.toString() ?? '';
|
||||
|
||||
final merchant = data['merchant'];
|
||||
if (merchant is Map) {
|
||||
merchantName = merchant['name_ar']?.toString() ?? '';
|
||||
merchantPosition = _latLng(merchant['lat'], merchant['lng']);
|
||||
}
|
||||
|
||||
final dest = data['destination'];
|
||||
if (dest is Map) {
|
||||
destinationAddress = dest['address']?.toString();
|
||||
destinationPosition = _latLng(dest['lat'], dest['lng']);
|
||||
}
|
||||
|
||||
final pos = data['courier_position'];
|
||||
if (pos is Map) {
|
||||
final next = _latLng(pos['lat'], pos['lng']);
|
||||
if (next != null) {
|
||||
courierPosition = next;
|
||||
final ts = int.tryParse(pos['ts']?.toString() ?? '');
|
||||
lastFixAt = ts == null
|
||||
? DateTime.now()
|
||||
: DateTime.fromMillisecondsSinceEpoch(ts * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
// انتهى الطلب — لا داعي لمواصلة السؤال، ولا لإبقاء علامة السائق
|
||||
if (!isTrackingActive) {
|
||||
_timer?.cancel();
|
||||
courierPosition = null;
|
||||
}
|
||||
|
||||
_rebuildMarkers();
|
||||
_settleCamera();
|
||||
update();
|
||||
}
|
||||
|
||||
LatLng? _latLng(dynamic lat, dynamic lng) {
|
||||
final la = double.tryParse(lat?.toString() ?? '');
|
||||
final ln = double.tryParse(lng?.toString() ?? '');
|
||||
if (la == null || ln == null || (la == 0 && ln == 0)) return null;
|
||||
return LatLng(la, ln);
|
||||
}
|
||||
|
||||
void _rebuildMarkers() {
|
||||
if (!isStyleLoaded) return;
|
||||
|
||||
final next = <Marker>{};
|
||||
|
||||
if (merchantPosition != null) {
|
||||
next.add(Marker(
|
||||
markerId: const MarkerId('merchant'),
|
||||
position: merchantPosition!,
|
||||
// نستعمل أصولاً موجودة فعلاً في المشروع — لا أيقونات مطعم/منزل مخصصة بعد
|
||||
icon: InlqBitmap.fromAsset('assets/images/picker.png'),
|
||||
anchor: const Offset(0.5, 0.5),
|
||||
));
|
||||
}
|
||||
if (destinationPosition != null) {
|
||||
next.add(Marker(
|
||||
markerId: const MarkerId('destination'),
|
||||
position: destinationPosition!,
|
||||
icon: InlqBitmap.fromAsset('assets/images/blob.png'),
|
||||
anchor: const Offset(0.5, 0.5),
|
||||
));
|
||||
}
|
||||
if (courierPosition != null) {
|
||||
next.add(Marker(
|
||||
markerId: const MarkerId('courier'),
|
||||
position: courierPosition!,
|
||||
icon: InlqBitmap.fromAsset('assets/images/moto1.png'),
|
||||
anchor: const Offset(0.5, 0.5),
|
||||
));
|
||||
}
|
||||
|
||||
markers = next;
|
||||
}
|
||||
|
||||
// نُحرّك الكاميرا مرة واحدة عند أول موقع، ثم نتبع السائق فقط.
|
||||
// التحريك المستمر على كل تحديث يمنع الراكب من تصفّح الخريطة بيده.
|
||||
void _settleCamera() {
|
||||
if (!isStyleLoaded || mapController == null) return;
|
||||
|
||||
final focus = courierPosition ?? merchantPosition ?? destinationPosition;
|
||||
if (focus == null) return;
|
||||
|
||||
if (!_cameraSettled) {
|
||||
mapController!.animateCamera(CameraUpdate.newLatLngZoom(focus, 15));
|
||||
_cameraSettled = true;
|
||||
} else if (courierPosition != null) {
|
||||
mapController!.animateCamera(CameraUpdate.newLatLng(courierPosition!));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
_timer?.cancel();
|
||||
super.onClose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user