Update: 2026-08-02 17:52:28

This commit is contained in:
Hamza-Ayed
2026-08-02 17:52:28 +03:00
parent b78a6797d5
commit 4620e84d34
96 changed files with 6250 additions and 476 deletions
@@ -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 'حدث خطأ، حاول مجدداً';
}
}