Update: 2026-08-02 17:52:28
This commit is contained in:
@@ -81,26 +81,55 @@ class LoginController extends GetxController {
|
||||
// • firstTimeLoadKey != false ← أول مرة يفتح التطبيق → loginFirstTime
|
||||
// • firstTimeLoadKey == false ← مستخدم موجود → loginJwtRider
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
Future<void> getJWT({bool force = false}) async {
|
||||
// إذا كان التوكن الحالي لا يزال صالحاً، لا داعي لطلب واحد جديد
|
||||
static Future<bool>? _jwtFuture;
|
||||
static DateTime _jwtCooldownUntil = DateTime(2000);
|
||||
static int _jwtFailures = 0;
|
||||
|
||||
static Duration get jwtCooldownRemaining {
|
||||
final d = _jwtCooldownUntil.difference(DateTime.now());
|
||||
return d.isNegative ? Duration.zero : d;
|
||||
}
|
||||
|
||||
Future<bool> getJWT({bool force = false}) async {
|
||||
if (_jwtFuture != null) {
|
||||
Log.print('⏳ getJWT: تجديد قيد التنفيذ — إعادة استخدام نفس الـ future.');
|
||||
return _jwtFuture!;
|
||||
}
|
||||
_jwtFuture = _getJwtInternal(force: force).catchError((e) {
|
||||
return _onJwtFailure('exception: $e');
|
||||
});
|
||||
try {
|
||||
return await _jwtFuture!;
|
||||
} finally {
|
||||
_jwtFuture = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _getJwtInternal({bool force = false}) async {
|
||||
if (!force && isTokenValid()) {
|
||||
Log.print("JWT is still valid. Skipping request.");
|
||||
return;
|
||||
_jwtFailures = 0;
|
||||
_jwtCooldownUntil = DateTime(2000);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DateTime.now().isBefore(_jwtCooldownUntil)) {
|
||||
Log.print(
|
||||
'🛑 getJWT: بـ cooldown لمدة ${jwtCooldownRemaining.inSeconds}ث — تخطّي التجديد.');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
dev = Platform.isAndroid ? 'android' : 'ios';
|
||||
|
||||
// تأكد إن البصمة محدّثة قبل أي طلب
|
||||
await DeviceHelper.getDeviceFingerprint();
|
||||
final String fp = box.read(BoxName.deviceFpEncrypted) ?? '';
|
||||
|
||||
final passengerId = box.read(BoxName.passengerID);
|
||||
final isRegistering = passengerId == null || passengerId.toString().isEmpty;
|
||||
|
||||
if (box.read(BoxName.firstTimeLoadKey).toString() != 'false') {
|
||||
// ── أول تسجيل ─────────────────────────────────────────
|
||||
// نرسل البصمة المشفرة مع باقي البيانات
|
||||
// السيرفر سيعمل hash لها ويخزنها في JWT payload
|
||||
if (isRegistering && box.read(BoxName.firstTimeLoadKey).toString() != 'false') {
|
||||
var payload = {
|
||||
'id': box.read(BoxName.passengerID) ?? AK.newId,
|
||||
'id': passengerId ?? AK.newId,
|
||||
'password': AK.passnpassenger,
|
||||
'aud': '${AK.allowed}$dev',
|
||||
'fingerPrint': fp,
|
||||
@@ -109,12 +138,14 @@ class LoginController extends GetxController {
|
||||
var response = await http.post(
|
||||
Uri.parse(AppLink.loginFirstTime),
|
||||
body: payload,
|
||||
);
|
||||
Log.print('AppLink.loginFirstTime: ${AppLink.loginFirstTime}');
|
||||
|
||||
Log.print('payload: $payload');
|
||||
Log.print('response code: ${response.statusCode}');
|
||||
Log.print('response body: ${response.body}');
|
||||
).timeout(const Duration(seconds: 30));
|
||||
|
||||
if (response.statusCode == 429) {
|
||||
final retryAfter = int.tryParse(response.headers['retry-after'] ?? '') ?? 60;
|
||||
_jwtCooldownUntil = DateTime.now().add(Duration(seconds: retryAfter));
|
||||
Log.print('🛑 getJWT(firstTime): 429 — cooldown ${retryAfter}s');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final decoded = jsonDecode(response.body);
|
||||
@@ -125,18 +156,20 @@ class LoginController extends GetxController {
|
||||
: decoded['jwt']);
|
||||
|
||||
if (jwt != null) {
|
||||
// نشفر الـ JWT بالتشفير الثلاثي قبل التخزين في GetStorage
|
||||
box.write(BoxName.jwt, c(jwt));
|
||||
storage.write(key: BoxName.jwt, value: c(jwt));
|
||||
await storage.write(key: BoxName.jwt, value: jwt);
|
||||
await EncryptionHelper.initialize();
|
||||
return _onJwtSuccess();
|
||||
}
|
||||
|
||||
await EncryptionHelper.initialize();
|
||||
return _onJwtFailure('firstTime: لا يوجد jwt بالرد');
|
||||
}
|
||||
return _onJwtFailure('firstTime: HTTP ${response.statusCode}');
|
||||
} else {
|
||||
// ── مستخدم موجود: تجديد التوكن
|
||||
if (isRegistering) {
|
||||
return _onJwtFailure('renew: لا يوجد passengerID');
|
||||
}
|
||||
|
||||
var payload = {
|
||||
'id': box.read(BoxName.passengerID),
|
||||
'id': passengerId,
|
||||
'fingerPrint': fp,
|
||||
'aud': '${AK.allowed}$dev',
|
||||
};
|
||||
@@ -144,11 +177,15 @@ class LoginController extends GetxController {
|
||||
var response = await http.post(
|
||||
Uri.parse(AppLink.loginJwtRider),
|
||||
body: payload,
|
||||
);
|
||||
Log.print('AppLink.loginJwtRider: ${AppLink.loginJwtRider}');
|
||||
).timeout(const Duration(seconds: 30));
|
||||
|
||||
if (response.statusCode == 429) {
|
||||
final retryAfter = int.tryParse(response.headers['retry-after'] ?? '') ?? 60;
|
||||
_jwtCooldownUntil = DateTime.now().add(Duration(seconds: retryAfter));
|
||||
Log.print('🛑 getJWT: 429 — cooldown ${retryAfter}ث');
|
||||
return false;
|
||||
}
|
||||
|
||||
Log.print('payload: $payload');
|
||||
Log.print('response: ${response.body}');
|
||||
if (response.statusCode == 200) {
|
||||
final decoded = jsonDecode(response.body);
|
||||
final String? jwt = decoded['data'] != null
|
||||
@@ -158,16 +195,33 @@ class LoginController extends GetxController {
|
||||
: decoded['jwt']);
|
||||
|
||||
if (jwt != null) {
|
||||
box.write(BoxName.jwt, c(jwt));
|
||||
storage.write(key: BoxName.jwt, value: c(jwt));
|
||||
await storage.write(key: BoxName.jwt, value: jwt);
|
||||
return _onJwtSuccess();
|
||||
}
|
||||
return _onJwtFailure('renew: لا يوجد jwt بالرد');
|
||||
}
|
||||
return _onJwtFailure('renew: HTTP ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
Log.print('Error in getJWT: $e');
|
||||
return _onJwtFailure('Error: $e');
|
||||
}
|
||||
}
|
||||
|
||||
bool _onJwtSuccess() {
|
||||
_jwtFailures = 0;
|
||||
_jwtCooldownUntil = DateTime(2000);
|
||||
Log.print('✅ getJWT: تم توليد توكن جديد بنجاح.');
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _onJwtFailure(String reason) {
|
||||
_jwtFailures++;
|
||||
final seconds = _jwtFailures >= 6 ? 60 : (1 << _jwtFailures);
|
||||
_jwtCooldownUntil = DateTime.now().add(Duration(seconds: seconds));
|
||||
Log.print('❌ getJWT فشل ($reason) — محاولة #$_jwtFailures، cooldown ${seconds}ث');
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// التحقق من صلاحية التوكن يدوياً (بدون مكاتب خارجية)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
@@ -233,6 +287,9 @@ class LoginController extends GetxController {
|
||||
Future<String?> getJwtWallet() async {
|
||||
dev = Platform.isAndroid ? 'android' : 'ios';
|
||||
|
||||
// نعيد حساب البصمة أولاً كي لا نرسل قيمة GCM قديمة عالقة في التخزين
|
||||
// من نسخة سابقة من التطبيق (مثل getJWT تماماً).
|
||||
await DeviceHelper.getDeviceFingerprint();
|
||||
final String fp = box.read(BoxName.deviceFpEncrypted) ?? '';
|
||||
|
||||
var payload = {
|
||||
|
||||
@@ -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 'حدث خطأ، حاول مجدداً';
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,6 @@ class CRUD {
|
||||
final NetGuard _netGuard = NetGuard();
|
||||
final _client = SslPinning.createPinnedClient();
|
||||
|
||||
static bool _isRefreshingJWT = false;
|
||||
static String _lastErrorSignature = '';
|
||||
static DateTime _lastErrorTimestamp = DateTime(2000);
|
||||
static const Duration _errorLogDebounceDuration = Duration(minutes: 1);
|
||||
@@ -98,31 +97,50 @@ class CRUD {
|
||||
|
||||
Future<String> _getJwt() async {
|
||||
try {
|
||||
final String? encryptedJwt = await storage.read(key: BoxName.jwt);
|
||||
if (encryptedJwt == null || encryptedJwt.isEmpty) {
|
||||
final String? fallback = box.read(BoxName.jwt);
|
||||
final jwt = await storage.read(key: BoxName.jwt);
|
||||
if (jwt == null || jwt.toString().isEmpty) {
|
||||
// إذا كان التخزين الآمن فارغاً، نحاول استخراج التوكن القديم من GetStorage للركاب القدامى
|
||||
final fallback = box.read(BoxName.jwt);
|
||||
if (fallback != null) {
|
||||
return r(fallback).toString().split(Env.addd)[0];
|
||||
try {
|
||||
return r(fallback).toString().split(Env.addd)[0]; // فك تشفير القديم
|
||||
} catch (_) {
|
||||
return fallback.toString(); // ربما تم تخزينه بدون تشفير
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
return r(encryptedJwt).toString().split(Env.addd)[0];
|
||||
} catch (e) {
|
||||
Log.print('Error reading JWT from SecureStorage: $e');
|
||||
final String? fallback = box.read(BoxName.jwt);
|
||||
if (fallback != null) {
|
||||
return r(fallback).toString().split(Env.addd)[0];
|
||||
// التحقق السريع إذا كان التوكن لا يزال مشفراً (يبدأ برموز غريبة وليس ey)
|
||||
if (!jwt.startsWith('ey')) {
|
||||
try {
|
||||
return r(jwt).toString().split(Env.addd)[0];
|
||||
} catch (_) {}
|
||||
}
|
||||
return jwt;
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// _ensureJwt — يضمن وجود توكن صالح قبل الإرسال
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
Future<String> _ensureJwt() async {
|
||||
String token = await _getJwt();
|
||||
if (_isJwtValid(token)) return token;
|
||||
|
||||
final ok = await Get.put(LoginController()).getJWT();
|
||||
if (!ok) return '';
|
||||
return await _getJwt();
|
||||
}
|
||||
|
||||
/// Centralized request handler with retry for weak networks.
|
||||
/// For Syria (3G): 60s total timeout, 3 retries, exponential backoff.
|
||||
Future<dynamic> _makeRequest({
|
||||
required String link,
|
||||
Map<String, dynamic>? payload,
|
||||
required Map<String, String> headers,
|
||||
bool allowRefresh = true,
|
||||
}) async {
|
||||
const totalTimeout = Duration(seconds: 60);
|
||||
|
||||
@@ -180,17 +198,33 @@ class CRUD {
|
||||
}
|
||||
}
|
||||
|
||||
// 429 → السيرفر رافض بسبب الضغط؛ ممنوع نجدّد التوكن أو نعيد المحاولة
|
||||
if (sc == 429) {
|
||||
Log.print('🛑 [RES] 429 rate limited — $link');
|
||||
return 'rate_limited';
|
||||
}
|
||||
|
||||
// 401 → تجديد التوكن مرة واحدة ثم إعادة الطلب مرة واحدة فقط
|
||||
if (sc == 401) {
|
||||
// تخطي تجديد التوكن لـ endpoints غير حرجة (مثل تسجيل الأخطاء)
|
||||
final isNonCritical = link.contains('errorApp.php');
|
||||
if (!_isRefreshingJWT && !isNonCritical) {
|
||||
_isRefreshingJWT = true;
|
||||
try {
|
||||
await Get.put(LoginController()).getJWT();
|
||||
} finally {
|
||||
_isRefreshingJWT = false;
|
||||
}
|
||||
}
|
||||
return 'token_expired';
|
||||
if (isNonCritical || !allowRefresh) return 'token_expired';
|
||||
|
||||
final refreshed = await Get.put(LoginController()).getJWT();
|
||||
if (!refreshed) return 'token_expired';
|
||||
|
||||
final newToken = await _getJwt();
|
||||
if (newToken.isEmpty) return 'token_expired';
|
||||
|
||||
// إعادة الطلب بالتوكن الجديد — allowRefresh: false يمنع أي تكرار إضافي
|
||||
final retryHeaders = Map<String, String>.from(headers)
|
||||
..['Authorization'] = 'Bearer $newToken';
|
||||
return await _makeRequest(
|
||||
link: link,
|
||||
payload: payload,
|
||||
headers: retryHeaders,
|
||||
allowRefresh: false,
|
||||
);
|
||||
}
|
||||
|
||||
if (sc >= 500) {
|
||||
@@ -206,7 +240,14 @@ class CRUD {
|
||||
required String link,
|
||||
Map<String, dynamic>? payload,
|
||||
}) async {
|
||||
String token = await _getJwt();
|
||||
String token = await _ensureJwt();
|
||||
if (token.isEmpty) {
|
||||
// إذا فشل الحصول على توكن، لا ترسل الطلب للباك إند لأنّه سيرفض حتماً.
|
||||
// باستثناء تسجيل الدخول لأنه لا يحتاج توكن
|
||||
if (!link.contains('login') && !link.contains('errorApp.php')) {
|
||||
return 'token_expired';
|
||||
}
|
||||
}
|
||||
|
||||
final headers = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
@@ -221,7 +262,12 @@ class CRUD {
|
||||
required String link,
|
||||
Map<String, dynamic>? payload,
|
||||
}) async {
|
||||
String token = await _getJwt();
|
||||
String token = await _ensureJwt();
|
||||
if (token.isEmpty) {
|
||||
if (!link.contains('login') && !link.contains('errorApp.php')) {
|
||||
return 'token_expired';
|
||||
}
|
||||
}
|
||||
|
||||
final headers = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
|
||||
@@ -42,6 +42,15 @@ class EncryptionHelper {
|
||||
debugPrint("EncryptionHelper initialized successfully.");
|
||||
}
|
||||
|
||||
/// Encrypts a string using AES-256-CBC with constant IV (deterministic)
|
||||
/// Same input always produces the same output
|
||||
String encryptDataCbc(String plainText) {
|
||||
final cbcEncrypter =
|
||||
encrypt.Encrypter(encrypt.AES(key, mode: encrypt.AESMode.cbc));
|
||||
final encrypted = cbcEncrypter.encrypt(plainText, iv: iv);
|
||||
return encrypted.base64;
|
||||
}
|
||||
|
||||
/// ✅ FIX H-04: Encrypts a string using AES-256-GCM (new) with random IV
|
||||
String encryptData(String plainText) {
|
||||
try {
|
||||
|
||||
@@ -190,15 +190,11 @@ void showUpdateDialog(BuildContext context) {
|
||||
|
||||
class DeviceHelper {
|
||||
static Future<String> getDeviceFingerprint() async {
|
||||
// ── التحقق من وجود بصمة مخزّنة مسبقاً ──────────────────────
|
||||
// AES-GCM يستخدم IV عشوائي كل مرة، فالتشفير ينتج نتيجة مختلفة
|
||||
// حتى لو النص الأصلي نفسه. لذلك نخزّن البصمة المشفرة أول مرة
|
||||
// ونرجعها من التخزين في كل مرة بعدها لضمان الثبات.
|
||||
final String? cachedFp = box.read(BoxName.deviceFpEncrypted);
|
||||
if (cachedFp != null && cachedFp.isNotEmpty) {
|
||||
return cachedFp;
|
||||
}
|
||||
|
||||
// ── البصمة حتمية: AES-CBC بـ IV ثابت ────────────────────────
|
||||
// نفس الجهاز ⇒ نفس الناتج دائماً، حتى بعد مسح بيانات التطبيق أو
|
||||
// إعادة التثبيت. لذلك لا نعتمد على الكاش كمصدر للثبات (كان ضرورياً
|
||||
// أيام AES-GCM بالـ IV العشوائي)، بل نعيد الحساب في كل مرة ونكتب
|
||||
// القيمة في التخزين فقط لتقرأها بقية الشاشات.
|
||||
final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
|
||||
var deviceData;
|
||||
|
||||
@@ -231,7 +227,7 @@ class DeviceHelper {
|
||||
// Generate and return the encrypted fingerprint
|
||||
final String fingerprint = '${deviceId}_$deviceModel';
|
||||
final String encryptedFp =
|
||||
EncryptionHelper.instance.encryptData(fingerprint);
|
||||
EncryptionHelper.instance.encryptDataCbc(fingerprint);
|
||||
box.write(BoxName.deviceFpEncrypted, encryptedFp);
|
||||
//Log.print(EncryptionHelper.instance.encryptData(fingerprint));
|
||||
return encryptedFp;
|
||||
|
||||
@@ -163,6 +163,7 @@ class LocationSearchController extends GetxController {
|
||||
];
|
||||
readyWayPoints();
|
||||
getLocation();
|
||||
_listenForDeepLink();
|
||||
}
|
||||
|
||||
void readyWayPoints() {
|
||||
|
||||
@@ -567,6 +567,17 @@ class MapEngineController extends GetxController {
|
||||
update();
|
||||
}
|
||||
|
||||
/// إغلاق القائمة الجانبية بشكل مباشر (idempotent) — بعكس [getDrawerMenu]
|
||||
/// التي تعمل كمفتاح تبديل، هذه تُغلق فقط ولا تفتح إن كانت مغلقة أصلاً.
|
||||
void closeDrawerMenu() {
|
||||
if (!heightMenuBool) return;
|
||||
heightMenuBool = false;
|
||||
widthMapTypeAndTraffic = 50;
|
||||
heightMenu = 0;
|
||||
widthMenu = 0;
|
||||
update();
|
||||
}
|
||||
|
||||
void changeMainBottomMenuMap() {
|
||||
if (isWayPointStopsSheetUtilGetMap == true) {
|
||||
changeWayPointSheet();
|
||||
|
||||
@@ -796,6 +796,19 @@ final Map<String, String> ar_eg = {
|
||||
"Open Settings": "افتح الإعدادات",
|
||||
"Open destination search": "فتح بحث الوجهات",
|
||||
"Open in Google Maps": "فتح في خرائط جوجل",
|
||||
"Canceled by you": "إنت لغيتها",
|
||||
"Canceled by driver": "السواق لغاها",
|
||||
"Canceled by driver after accepting": "السواق لغاها بعد الموافقة",
|
||||
"Searching for a driver": "بندور على سواق",
|
||||
"Driver on the way": "السواق في الطريق",
|
||||
"Trip in progress": "الرحلة شغالة",
|
||||
"Not completed": "ما اكتملتش",
|
||||
"To": "إلى",
|
||||
"Locating": "جاري التحديد",
|
||||
"km": "كم",
|
||||
"Rebook": "إعادة الحجز",
|
||||
"Reverse trip": "عكس الرحلة",
|
||||
"Please open the map first": "افتح الخريطة الأول",
|
||||
"Or pay with Cash instead": "أو ادفع كاش",
|
||||
"Order": "طلب",
|
||||
"Order Accepted": "تم قبول الطلب",
|
||||
|
||||
@@ -795,6 +795,19 @@ final Map<String, String> ar_jo = {
|
||||
"Open Settings": "افتح الإعدادات",
|
||||
"Open destination search": "فتح بحث الوجهات",
|
||||
"Open in Google Maps": "فتح في خرائط جوجل",
|
||||
"Canceled by you": "ألغيتها أنت",
|
||||
"Canceled by driver": "ألغاها السائق",
|
||||
"Canceled by driver after accepting": "ألغاها السائق بعد القبول",
|
||||
"Searching for a driver": "البحث عن سائق",
|
||||
"Driver on the way": "السائق في الطريق",
|
||||
"Trip in progress": "الرحلة جارية",
|
||||
"Not completed": "لم تكتمل",
|
||||
"To": "إلى",
|
||||
"Locating": "جارٍ التحديد",
|
||||
"km": "كم",
|
||||
"Rebook": "إعادة الحجز",
|
||||
"Reverse trip": "عكس الرحلة",
|
||||
"Please open the map first": "افتح الخريطة أولاً",
|
||||
"Or pay with Cash instead": "أو ادفع نقداً",
|
||||
"Order": "طلب",
|
||||
"Order Accepted": "تم قبول الطلب",
|
||||
|
||||
@@ -796,6 +796,19 @@ final Map<String, String> ar_sy = {
|
||||
"Open Settings": "افتح الإعدادات",
|
||||
"Open destination search": "فتح بحث الوجهات",
|
||||
"Open in Google Maps": "فتح في خرائط جوجل",
|
||||
"Canceled by you": "ألغيتها أنت",
|
||||
"Canceled by driver": "ألغاها السائق",
|
||||
"Canceled by driver after accepting": "ألغاها السائق بعد القبول",
|
||||
"Searching for a driver": "البحث عن سائق",
|
||||
"Driver on the way": "السائق في الطريق",
|
||||
"Trip in progress": "الرحلة جارية",
|
||||
"Not completed": "لم تكتمل",
|
||||
"To": "إلى",
|
||||
"Locating": "جارٍ التحديد",
|
||||
"km": "كم",
|
||||
"Rebook": "إعادة الحجز",
|
||||
"Reverse trip": "عكس الرحلة",
|
||||
"Please open the map first": "افتح الخريطة أولاً",
|
||||
"Or pay with Cash instead": "أو ادفع كاش",
|
||||
"Order": "طلب",
|
||||
"Order Accepted": "تم قبول الطلب",
|
||||
|
||||
@@ -762,6 +762,19 @@ final Map<String, String> de = {
|
||||
"Open Settings": "Einstellungen öffnen",
|
||||
"Open destination search": "Open destination search",
|
||||
"Open in Google Maps": "Open in Google Maps",
|
||||
"Canceled by you": "Von dir storniert",
|
||||
"Canceled by driver": "Vom Fahrer storniert",
|
||||
"Canceled by driver after accepting": "Vom Fahrer nach Annahme storniert",
|
||||
"Searching for a driver": "Fahrersuche",
|
||||
"Driver on the way": "Fahrer unterwegs",
|
||||
"Trip in progress": "Fahrt läuft",
|
||||
"Not completed": "Nicht abgeschlossen",
|
||||
"To": "Nach",
|
||||
"Locating": "Wird ermittelt",
|
||||
"km": "km",
|
||||
"Rebook": "Erneut buchen",
|
||||
"Reverse trip": "Fahrt umkehren",
|
||||
"Please open the map first": "Bitte zuerst die Karte öffnen",
|
||||
"Or pay with Cash instead": "Oder zahlen Sie stattdessen bar",
|
||||
"Order": "Bestellung",
|
||||
"Order Accepted": "Bestellung angenommen",
|
||||
|
||||
@@ -762,6 +762,19 @@ final Map<String, String> el = {
|
||||
"Open Settings": "Ρυθμίσεις",
|
||||
"Open destination search": "Open destination search",
|
||||
"Open in Google Maps": "Open in Google Maps",
|
||||
"Canceled by you": "Ακυρώθηκε από εσάς",
|
||||
"Canceled by driver": "Ακυρώθηκε από τον οδηγό",
|
||||
"Canceled by driver after accepting": "Ακυρώθηκε από τον οδηγό μετά την αποδοχή",
|
||||
"Searching for a driver": "Αναζήτηση οδηγού",
|
||||
"Driver on the way": "Ο οδηγός είναι καθ' οδόν",
|
||||
"Trip in progress": "Διαδρομή σε εξέλιξη",
|
||||
"Not completed": "Δεν ολοκληρώθηκε",
|
||||
"To": "Προς",
|
||||
"Locating": "Εντοπισμός",
|
||||
"km": "χλμ",
|
||||
"Rebook": "Νέα κράτηση",
|
||||
"Reverse trip": "Αντιστροφή διαδρομής",
|
||||
"Please open the map first": "Ανοίξτε πρώτα τον χάρτη",
|
||||
"Or pay with Cash instead": "Ή πληρώστε με Μετρητά",
|
||||
"Order": "Αίτημα",
|
||||
"Order Accepted": "Order Accepted",
|
||||
|
||||
@@ -762,6 +762,19 @@ final Map<String, String> es = {
|
||||
"Open Settings": "Abrir configuración",
|
||||
"Open destination search": "Open destination search",
|
||||
"Open in Google Maps": "Open in Google Maps",
|
||||
"Canceled by you": "Cancelado por ti",
|
||||
"Canceled by driver": "Cancelado por el conductor",
|
||||
"Canceled by driver after accepting": "Cancelado por el conductor tras aceptar",
|
||||
"Searching for a driver": "Buscando conductor",
|
||||
"Driver on the way": "Conductor en camino",
|
||||
"Trip in progress": "Viaje en curso",
|
||||
"Not completed": "No completado",
|
||||
"To": "Hasta",
|
||||
"Locating": "Localizando",
|
||||
"km": "km",
|
||||
"Rebook": "Reservar de nuevo",
|
||||
"Reverse trip": "Invertir el viaje",
|
||||
"Please open the map first": "Abre el mapa primero",
|
||||
"Or pay with Cash instead": "O pague en efectivo en su lugar",
|
||||
"Order": "Pedido",
|
||||
"Order Accepted": "Pedido aceptado",
|
||||
|
||||
@@ -762,6 +762,19 @@ final Map<String, String> fa = {
|
||||
"Open Settings": "باز کردن تنظیمات",
|
||||
"Open destination search": "Open destination search",
|
||||
"Open in Google Maps": "Open in Google Maps",
|
||||
"Canceled by you": "توسط شما لغو شد",
|
||||
"Canceled by driver": "توسط راننده لغو شد",
|
||||
"Canceled by driver after accepting": "پس از پذیرش توسط راننده لغو شد",
|
||||
"Searching for a driver": "جستجوی راننده",
|
||||
"Driver on the way": "راننده در راه است",
|
||||
"Trip in progress": "سفر در جریان است",
|
||||
"Not completed": "تکمیل نشده",
|
||||
"To": "به",
|
||||
"Locating": "در حال تعیین",
|
||||
"km": "کیلومتر",
|
||||
"Rebook": "رزرو مجدد",
|
||||
"Reverse trip": "معکوس کردن سفر",
|
||||
"Please open the map first": "ابتدا نقشه را باز کنید",
|
||||
"Or pay with Cash instead": "یا به صورت نقدی پرداخت کنید",
|
||||
"Order": "درخواست",
|
||||
"Order Accepted": "Order Accepted",
|
||||
|
||||
@@ -762,6 +762,19 @@ final Map<String, String> fr = {
|
||||
"Open Settings": "Ouvrir les paramètres",
|
||||
"Open destination search": "Open destination search",
|
||||
"Open in Google Maps": "Open in Google Maps",
|
||||
"Canceled by you": "Annulé par vous",
|
||||
"Canceled by driver": "Annulé par le chauffeur",
|
||||
"Canceled by driver after accepting": "Annulé par le chauffeur après acceptation",
|
||||
"Searching for a driver": "Recherche d'un chauffeur",
|
||||
"Driver on the way": "Chauffeur en route",
|
||||
"Trip in progress": "Trajet en cours",
|
||||
"Not completed": "Non terminé",
|
||||
"To": "À",
|
||||
"Locating": "Localisation",
|
||||
"km": "km",
|
||||
"Rebook": "Réserver à nouveau",
|
||||
"Reverse trip": "Inverser le trajet",
|
||||
"Please open the map first": "Veuillez d'abord ouvrir la carte",
|
||||
"Or pay with Cash instead": "Ou payez en espèces",
|
||||
"Order": "Commande",
|
||||
"Order Accepted": "Order Accepted",
|
||||
|
||||
@@ -762,6 +762,19 @@ final Map<String, String> hi = {
|
||||
"Open Settings": "सेटिंग्स खोलें",
|
||||
"Open destination search": "Open destination search",
|
||||
"Open in Google Maps": "Open in Google Maps",
|
||||
"Canceled by you": "आपने रद्द किया",
|
||||
"Canceled by driver": "चालक ने रद्द किया",
|
||||
"Canceled by driver after accepting": "स्वीकार करने के बाद चालक ने रद्द किया",
|
||||
"Searching for a driver": "चालक खोजा जा रहा है",
|
||||
"Driver on the way": "चालक रास्ते में है",
|
||||
"Trip in progress": "यात्रा जारी है",
|
||||
"Not completed": "पूर्ण नहीं हुआ",
|
||||
"To": "तक",
|
||||
"Locating": "पता लगाया जा रहा है",
|
||||
"km": "किमी",
|
||||
"Rebook": "फिर से बुक करें",
|
||||
"Reverse trip": "यात्रा उलटें",
|
||||
"Please open the map first": "पहले मानचित्र खोलें",
|
||||
"Or pay with Cash instead": "या नकद भुगतान करें",
|
||||
"Order": "ऑर्डर",
|
||||
"Order Accepted": "Order Accepted",
|
||||
|
||||
@@ -762,6 +762,19 @@ final Map<String, String> it = {
|
||||
"Open Settings": "Impostazioni",
|
||||
"Open destination search": "Open destination search",
|
||||
"Open in Google Maps": "Open in Google Maps",
|
||||
"Canceled by you": "Annullato da te",
|
||||
"Canceled by driver": "Annullato dall'autista",
|
||||
"Canceled by driver after accepting": "Annullato dall'autista dopo l'accettazione",
|
||||
"Searching for a driver": "Ricerca autista",
|
||||
"Driver on the way": "Autista in arrivo",
|
||||
"Trip in progress": "Viaggio in corso",
|
||||
"Not completed": "Non completato",
|
||||
"To": "A",
|
||||
"Locating": "Localizzazione",
|
||||
"km": "km",
|
||||
"Rebook": "Prenota di nuovo",
|
||||
"Reverse trip": "Inverti il viaggio",
|
||||
"Please open the map first": "Apri prima la mappa",
|
||||
"Or pay with Cash instead": "O paga in contanti",
|
||||
"Order": "Ordine",
|
||||
"Order Accepted": "Order Accepted",
|
||||
|
||||
@@ -762,6 +762,19 @@ final Map<String, String> ru = {
|
||||
"Open Settings": "Настройки",
|
||||
"Open destination search": "Open destination search",
|
||||
"Open in Google Maps": "Open in Google Maps",
|
||||
"Canceled by you": "Отменено вами",
|
||||
"Canceled by driver": "Отменено водителем",
|
||||
"Canceled by driver after accepting": "Отменено водителем после принятия",
|
||||
"Searching for a driver": "Поиск водителя",
|
||||
"Driver on the way": "Водитель в пути",
|
||||
"Trip in progress": "Поездка выполняется",
|
||||
"Not completed": "Не завершено",
|
||||
"To": "Куда",
|
||||
"Locating": "Определение",
|
||||
"km": "км",
|
||||
"Rebook": "Заказать снова",
|
||||
"Reverse trip": "Обратный маршрут",
|
||||
"Please open the map first": "Сначала откройте карту",
|
||||
"Or pay with Cash instead": "Или оплатите наличными",
|
||||
"Order": "Заказ",
|
||||
"Order Accepted": "Order Accepted",
|
||||
|
||||
@@ -762,6 +762,19 @@ final Map<String, String> tr = {
|
||||
"Open Settings": "Ayarları Aç",
|
||||
"Open destination search": "Open destination search",
|
||||
"Open in Google Maps": "Open in Google Maps",
|
||||
"Canceled by you": "Sizin tarafınızdan iptal edildi",
|
||||
"Canceled by driver": "Sürücü iptal etti",
|
||||
"Canceled by driver after accepting": "Sürücü kabul ettikten sonra iptal etti",
|
||||
"Searching for a driver": "Sürücü aranıyor",
|
||||
"Driver on the way": "Sürücü yolda",
|
||||
"Trip in progress": "Yolculuk sürüyor",
|
||||
"Not completed": "Tamamlanmadı",
|
||||
"To": "Nereye",
|
||||
"Locating": "Belirleniyor",
|
||||
"km": "km",
|
||||
"Rebook": "Yeniden rezerve et",
|
||||
"Reverse trip": "Yolculuğu ters çevir",
|
||||
"Please open the map first": "Lütfen önce haritayı açın",
|
||||
"Or pay with Cash instead": "Veya Nakit öde",
|
||||
"Order": "Sipariş",
|
||||
"Order Accepted": "Order Accepted",
|
||||
|
||||
@@ -762,6 +762,19 @@ final Map<String, String> ur = {
|
||||
"Open Settings": "ترتیبات کھولیں",
|
||||
"Open destination search": "Open destination search",
|
||||
"Open in Google Maps": "Open in Google Maps",
|
||||
"Canceled by you": "آپ نے منسوخ کیا",
|
||||
"Canceled by driver": "ڈرائیور نے منسوخ کیا",
|
||||
"Canceled by driver after accepting": "قبول کرنے کے بعد ڈرائیور نے منسوخ کیا",
|
||||
"Searching for a driver": "ڈرائیور تلاش کیا جا رہا ہے",
|
||||
"Driver on the way": "ڈرائیور راستے میں ہے",
|
||||
"Trip in progress": "سفر جاری ہے",
|
||||
"Not completed": "مکمل نہیں ہوا",
|
||||
"To": "تک",
|
||||
"Locating": "تعین ہو رہا ہے",
|
||||
"km": "کلومیٹر",
|
||||
"Rebook": "دوبارہ بک کریں",
|
||||
"Reverse trip": "سفر الٹا کریں",
|
||||
"Please open the map first": "پہلے نقشہ کھولیں",
|
||||
"Or pay with Cash instead": "یا اس کے بجائے نقد ادائیگی کریں",
|
||||
"Order": "آرڈر",
|
||||
"Order Accepted": "Order Accepted",
|
||||
|
||||
@@ -762,6 +762,19 @@ final Map<String, String> zh = {
|
||||
"Open Settings": "افتح الإعدادات",
|
||||
"Open destination search": "Open destination search",
|
||||
"Open in Google Maps": "Open in Google Maps",
|
||||
"Canceled by you": "您已取消",
|
||||
"Canceled by driver": "司机已取消",
|
||||
"Canceled by driver after accepting": "司机接单后取消",
|
||||
"Searching for a driver": "正在寻找司机",
|
||||
"Driver on the way": "司机正在赶来",
|
||||
"Trip in progress": "行程进行中",
|
||||
"Not completed": "未完成",
|
||||
"To": "到",
|
||||
"Locating": "定位中",
|
||||
"km": "公里",
|
||||
"Rebook": "重新预订",
|
||||
"Reverse trip": "反向行程",
|
||||
"Please open the map first": "请先打开地图",
|
||||
"Or pay with Cash instead": "أو ادفع كاش",
|
||||
"Order": "طلب",
|
||||
"Order Accepted": "Order Accepted",
|
||||
|
||||
Reference in New Issue
Block a user