271 lines
7.9 KiB
Dart
271 lines
7.9 KiB
Dart
// 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();
|
|
}
|
|
}
|