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
+2 -1
View File
@@ -10,7 +10,8 @@
</array>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:intaleqapp.com</string>
<string>applinks:siromove.com</string>
<string>applinks:www.siromove.com</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
+4 -4
View File
@@ -4,8 +4,8 @@ class AppInformation {
static const String phoneNumber = '';
static const String linkedInProfile =
'https://www.linkedin.com/in/hamza-ayed/';
static const String website = 'https://intaleqapp.com';
static const String email = 'support@intaleqapp.com';
static const String website = 'https://siromove.com';
static const String email = 'support@siromove.com';
static const String addd = 'BlBlNl';
static const String privacyPolicy = '''
<!DOCTYPE html>
@@ -140,7 +140,7 @@ class AppInformation {
<h2>7. Account Deletion & Contact</h2>
<p>You have the right to request the deletion of your account and personal data. To do so, or for any other questions, please contact us. We will respond to deletion requests within 30 days.</p>
<p><strong>Email:</strong> <a href="mailto:support@intaleqapp.com">support@intaleqapp.com</a></p>
<p><strong>Email:</strong> <a href="mailto:support@siromove.com">support@siromove.com</a></p>
</body>
</html>
@@ -280,7 +280,7 @@ class AppInformation {
<h2>7. حذف الحساب والتواصل</h2>
<p>لديك الحق في طلب حذف حسابك وبياناتك الشخصية. للقيام بذلك، أو لأي استفسارات أخرى، يرجى التواصل معنا. سنرد على طلبات الحذف في غضون 30 يومًا.</p>
<p><strong>البريد الإلكتروني:</strong> <a href="mailto:support@intaleqapp.com">support@intaleqapp.com</a></p>
<p><strong>البريد الإلكتروني:</strong> <a href="mailto:support@siromove.com">support@siromove.com</a></p>
</body>
</html>
@@ -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 'حدث خطأ، حاول مجدداً';
}
}
+68 -22
View File
@@ -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": "تم قبول الطلب",
+13
View File
@@ -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",
+13
View File
@@ -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",
+13
View File
@@ -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",
+13
View File
@@ -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",
+13
View File
@@ -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",
+13
View File
@@ -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",
+13
View File
@@ -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",
+13
View File
@@ -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",
+13
View File
@@ -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",
+13
View File
@@ -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",
+13
View File
@@ -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",
@@ -0,0 +1,248 @@
// food_cart_page.dart — مراجعة السلة، عرض السعر الموقّع، وإنشاء الطلب
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:geolocator/geolocator.dart';
import '../../constant/box_name.dart';
import '../../constant/colors.dart';
import '../../constant/style.dart';
import '../../main.dart';
import '../../views/widgets/error_snakbar.dart';
import '../../controller/food/food_controller.dart';
import '../../controller/food/food_models.dart';
import '../widgets/my_scafold.dart';
import 'food_order_tracking_page.dart';
class FoodCartPage extends StatefulWidget {
const FoodCartPage({super.key});
@override
State<FoodCartPage> createState() => _FoodCartPageState();
}
class _FoodCartPageState extends State<FoodCartPage> {
final TextEditingController _addressController = TextEditingController();
final TextEditingController _noteController = TextEditingController();
String _paymentMethod = 'wallet';
Position? _position;
bool _isFetchingLocation = false;
bool get _isAr => box.read(BoxName.lang) == 'ar';
@override
void initState() {
super.initState();
_fetchLocation();
}
Future<void> _fetchLocation() async {
setState(() => _isFetchingLocation = true);
try {
final permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
await Geolocator.requestPermission();
}
_position = await Geolocator.getCurrentPosition(desiredAccuracy: LocationAccuracy.high);
} catch (_) {
_position = null;
}
if (mounted) setState(() => _isFetchingLocation = false);
}
@override
Widget build(BuildContext context) {
return GetBuilder<FoodController>(
builder: (c) => MyScafolld(
title: _isAr ? 'السلة' : 'Cart',
isleading: true,
body: [
c.cartLines.isEmpty ? _emptyCart() : _cartContent(c),
],
),
);
}
Widget _emptyCart() {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.shopping_cart_outlined, size: 72, color: AppColor.grayColor),
const SizedBox(height: 16),
Text(_isAr ? 'سلتك فارغة' : 'Your cart is empty', style: AppStyle.title),
],
),
);
}
Widget _cartContent(FoodController c) {
return Column(
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
...c.cartLines.map((line) => _cartLineTile(c, line)),
const SizedBox(height: 16),
_summaryRow(_isAr ? 'المجموع الفرعي' : 'Subtotal', foodFormatPrice(c.cartTotal)),
if (c.currentQuote != null) ...[
_summaryRow(_isAr ? 'رسوم التوصيل' : 'Delivery fee', foodFormatPrice(c.currentQuote!.deliveryFee)),
const Divider(),
_summaryRow(_isAr ? 'الإجمالي' : 'Total', foodFormatPrice(c.currentQuote!.grandTotal), bold: true),
],
const SizedBox(height: 20),
TextField(
controller: _addressController,
decoration: InputDecoration(
labelText: _isAr ? 'عنوان التوصيل' : 'Delivery address',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
suffixIcon: _isFetchingLocation
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
)
: Icon(_position != null ? Icons.my_location_rounded : Icons.location_off_rounded,
color: _position != null ? AppColor.greenColor : AppColor.redColor),
),
),
const SizedBox(height: 12),
TextField(
controller: _noteController,
decoration: InputDecoration(
labelText: _isAr ? 'ملاحظات (اختياري)' : 'Notes (optional)',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
),
const SizedBox(height: 16),
_paymentMethodPicker(),
],
),
),
Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, MediaQuery.of(context).padding.bottom + 16),
child: SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColor.primaryColor,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
onPressed: c.isQuoting || c.isPlacingOrder ? null : () => _onCheckoutPressed(c),
child: c.isPlacingOrder
? const CircularProgressIndicator(color: Colors.white)
: Text(
c.currentQuote == null
? (_isAr ? 'احسب السعر' : 'Get Quote')
: '${_isAr ? "اطلب الآن" : "Place Order"} · ${foodFormatPrice(c.currentQuote!.grandTotal)}',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16),
),
),
),
),
],
);
}
Future<void> _onCheckoutPressed(FoodController c) async {
if (c.currentQuote == null) {
await c.refreshQuote();
return;
}
if (_addressController.text.trim().isEmpty) {
mySnackbarWarning(_isAr ? 'أدخل عنوان التوصيل' : 'Enter a delivery address');
return;
}
if (_position == null) {
mySnackbarWarning(_isAr ? 'تعذّر تحديد موقعك — فعّل خدمة الموقع' : 'Could not get your location — enable location services');
return;
}
final orderId = await c.placeOrder(
deliveryAddress: _addressController.text.trim(),
deliveryLat: _position!.latitude,
deliveryLng: _position!.longitude,
paymentMethod: _paymentMethod,
customerNote: _noteController.text.trim(),
);
if (orderId != null) {
Get.offAll(() => FoodOrderTrackingPage(orderId: orderId));
}
}
Widget _cartLineTile(FoodController c, FoodCartLine line) {
return Card(
margin: const EdgeInsets.only(bottom: 10),
color: AppColor.cardColor,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: BorderSide(color: AppColor.borderColor),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(line.item.nameAr, style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text(foodFormatPrice(line.lineTotal), style: AppStyle.subtitle.copyWith(color: AppColor.accentColor)),
],
),
),
IconButton(
icon: const Icon(Icons.remove_circle_outline_rounded),
onPressed: () => c.updateQuantity(line.lineKey, line.quantity - 1),
),
Text(line.quantity.toString(), style: AppStyle.title),
IconButton(
icon: const Icon(Icons.add_circle_outline_rounded),
onPressed: () => c.updateQuantity(line.lineKey, line.quantity + 1),
),
],
),
),
);
}
Widget _summaryRow(String label, String value, {bool bold = false}) {
final style = bold
? AppStyle.title.copyWith(fontWeight: FontWeight.bold, fontSize: 18)
: AppStyle.subtitle;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [Text(label, style: style), Text(value, style: style)],
),
);
}
Widget _paymentMethodPicker() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_isAr ? 'طريقة الدفع' : 'Payment method', style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
RadioListTile<String>(
contentPadding: EdgeInsets.zero,
value: 'wallet',
groupValue: _paymentMethod,
title: Text(_isAr ? 'المحفظة' : 'Wallet'),
onChanged: (v) => setState(() => _paymentMethod = v!),
),
RadioListTile<String>(
contentPadding: EdgeInsets.zero,
value: 'cash',
groupValue: _paymentMethod,
title: Text(_isAr ? 'نقداً عند الاستلام' : 'Cash on delivery'),
onChanged: (v) => setState(() => _paymentMethod = v!),
),
],
);
}
}
@@ -0,0 +1,270 @@
// food_home_page.dart — الصفحة الرئيسية لتبويب "طعام": تصفح المطاعم
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../constant/box_name.dart';
import '../../constant/colors.dart';
import '../../constant/style.dart';
import '../../main.dart';
import '../../controller/food/food_controller.dart';
import '../../controller/food/food_models.dart';
import '../widgets/my_scafold.dart';
import 'food_merchant_page.dart';
import 'food_cart_page.dart';
import 'food_order_history_page.dart';
class FoodHomePage extends StatelessWidget {
const FoodHomePage({super.key});
bool get _isAr => box.read(BoxName.lang) == 'ar';
@override
Widget build(BuildContext context) {
final c = Get.put(FoodController());
c.fetchMerchants();
return GetBuilder<FoodController>(
builder: (c) => MyScafolld(
title: _isAr ? 'طعام' : 'Food',
isleading: true,
action: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(Icons.history_rounded, color: AppColor.primaryColor),
onPressed: () => Get.to(() => const FoodOrderHistoryPage()),
),
Stack(
clipBehavior: Clip.none,
children: [
IconButton(
icon: Icon(Icons.shopping_cart_rounded, color: AppColor.primaryColor),
onPressed: () => Get.to(() => const FoodCartPage()),
),
if (c.cartItemsCount > 0)
Positioned(
right: 4,
top: 4,
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(color: AppColor.redColor, shape: BoxShape.circle),
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
child: Text(
c.cartItemsCount.toString(),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold),
),
),
),
],
),
],
),
body: [
Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: _citySearchBar(c),
),
Expanded(
child: RefreshIndicator(
onRefresh: () => c.fetchMerchants(),
child: c.isLoadingMerchants
? const Center(child: CircularProgressIndicator())
: c.merchants.isEmpty
? _emptyState()
: ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
itemCount: c.merchants.length,
itemBuilder: (_, i) => _merchantCard(c.merchants[i]),
),
),
),
],
),
],
),
);
}
Widget _citySearchBar(FoodController c) {
return Row(
children: [
Expanded(
child: InkWell(
onTap: () => _showCityPicker(c),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: AppColor.cardColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColor.borderColor),
),
child: Row(
children: [
Icon(Icons.location_on_rounded, color: AppColor.accentColor, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(c.city, style: AppStyle.title, overflow: TextOverflow.ellipsis),
),
Icon(Icons.keyboard_arrow_down_rounded, color: AppColor.grayColor),
],
),
),
),
),
const SizedBox(width: 8),
InkWell(
onTap: () => _showSearchDialog(c),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColor.cardColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColor.borderColor),
),
child: Icon(Icons.search_rounded, color: AppColor.primaryColor),
),
),
],
);
}
void _showCityPicker(FoodController c) {
final controller = TextEditingController(text: c.city);
Get.dialog(
AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: Text(_isAr ? 'اختر المدينة' : 'Select City'),
content: TextField(
controller: controller,
decoration: InputDecoration(hintText: _isAr ? 'اسم المدينة' : 'City name'),
),
actions: [
TextButton(onPressed: () => Get.back(), child: Text(_isAr ? 'إلغاء' : 'Cancel')),
ElevatedButton(
onPressed: () {
Get.back();
c.setCity(controller.text);
},
child: Text(_isAr ? 'تم' : 'Done'),
),
],
),
);
}
void _showSearchDialog(FoodController c) {
final controller = TextEditingController();
Get.dialog(
AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: Text(_isAr ? 'بحث عن مطعم أو صنف' : 'Search restaurant or dish'),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(hintText: _isAr ? 'اكتب هنا...' : 'Type here...'),
),
actions: [
TextButton(onPressed: () => Get.back(), child: Text(_isAr ? 'إلغاء' : 'Cancel')),
ElevatedButton(
onPressed: () {
Get.back();
c.searchMerchants(controller.text);
},
child: Text(_isAr ? 'بحث' : 'Search'),
),
],
),
);
}
Widget _emptyState() {
return ListView(
padding: const EdgeInsets.all(24),
children: [
const SizedBox(height: 60),
Icon(Icons.restaurant_menu_rounded, size: 72, color: AppColor.grayColor),
const SizedBox(height: 16),
Text(
_isAr ? 'لا توجد مطاعم متاحة في هذه المدينة حالياً' : 'No restaurants available in this city yet',
textAlign: TextAlign.center,
style: AppStyle.title.copyWith(fontWeight: FontWeight.bold),
),
],
);
}
Widget _merchantCard(FoodMerchant m) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
color: AppColor.cardColor,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: AppColor.borderColor),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => Get.to(() => FoodMerchantPage(merchantId: m.id)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (m.coverUrl != null && m.coverUrl!.isNotEmpty)
Image.network(m.coverUrl!, height: 120, width: double.infinity, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(height: 120, color: AppColor.borderColor)),
Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
CircleAvatar(
radius: 24,
backgroundColor: AppColor.accentColor.withOpacity(0.15),
backgroundImage: (m.logoUrl != null && m.logoUrl!.isNotEmpty)
? NetworkImage(m.logoUrl!)
: null,
child: (m.logoUrl == null || m.logoUrl!.isEmpty)
? Icon(Icons.restaurant_rounded, color: AppColor.accentColor)
: null,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(m.nameAr, style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Row(
children: [
const Icon(Icons.star_rounded, color: Color(0xFFF59E0B), size: 16),
const SizedBox(width: 2),
Text(m.ratingAvg.toStringAsFixed(1), style: AppStyle.subtitle),
const SizedBox(width: 10),
Icon(Icons.access_time_rounded, size: 14, color: AppColor.grayColor),
const SizedBox(width: 2),
Text('${m.avgPrepMinutes} ${_isAr ? "د" : "min"}', style: AppStyle.subtitle),
],
),
],
),
),
if (!m.isOpen)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: AppColor.redColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(_isAr ? 'مغلق' : 'Closed',
style: TextStyle(color: AppColor.redColor, fontSize: 11, fontWeight: FontWeight.bold)),
),
],
),
),
],
),
),
);
}
}
@@ -0,0 +1,312 @@
// food_merchant_page.dart — قائمة مطعم واحد + إضافة أصناف للسلة
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../constant/box_name.dart';
import '../../constant/colors.dart';
import '../../constant/style.dart';
import '../../main.dart';
import '../../views/widgets/error_snakbar.dart';
import '../../controller/food/food_controller.dart';
import '../../controller/food/food_models.dart';
import '../widgets/my_scafold.dart';
import 'food_cart_page.dart';
class FoodMerchantPage extends StatelessWidget {
final int merchantId;
const FoodMerchantPage({super.key, required this.merchantId});
bool get _isAr => box.read(BoxName.lang) == 'ar';
@override
Widget build(BuildContext context) {
final c = Get.find<FoodController>();
c.openMerchant(merchantId);
return GetBuilder<FoodController>(
builder: (c) {
final merchant = c.selectedMerchant;
return MyScafolld(
title: merchant?.nameAr ?? (_isAr ? 'المطعم' : 'Restaurant'),
isleading: true,
action: Stack(
clipBehavior: Clip.none,
children: [
IconButton(
icon: Icon(Icons.shopping_cart_rounded, color: AppColor.primaryColor),
onPressed: () => Get.to(() => const FoodCartPage()),
),
if (c.cartItemsCount > 0)
Positioned(
right: 4,
top: 4,
child: Container(
padding: const EdgeInsets.all(4),
decoration: const BoxDecoration(color: AppColor.redColor, shape: BoxShape.circle),
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
child: Text(c.cartItemsCount.toString(),
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold)),
),
),
],
),
body: [
c.isLoadingMenu
? const Center(child: CircularProgressIndicator())
: merchant == null
? Center(child: Text(_isAr ? 'تعذر تحميل المطعم' : 'Failed to load restaurant'))
: _content(c, merchant),
],
);
},
);
}
Widget _content(FoodController c, FoodMerchant merchant) {
return ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
if (!merchant.isOpen)
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: AppColor.redColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
_isAr ? 'هذا المطعم مغلق حالياً' : 'This restaurant is currently closed',
style: TextStyle(color: AppColor.redColor, fontWeight: FontWeight.bold),
),
),
if (merchant.descriptionAr != null && merchant.descriptionAr!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(merchant.descriptionAr!, style: AppStyle.subtitle),
),
Text(
_isAr
? 'الحد الأدنى للطلب: ${(merchant.minOrderAmount / 1000).toStringAsFixed(3)} د.أ'
: 'Minimum order: ${(merchant.minOrderAmount / 1000).toStringAsFixed(3)} JOD',
style: AppStyle.subtitle.copyWith(color: AppColor.grayColor),
),
const SizedBox(height: 16),
...c.categories.map((cat) => _categorySection(c, merchant, cat)),
],
);
}
Widget _categorySection(FoodController c, FoodMerchant merchant, FoodMenuCategory cat) {
if (cat.items.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(cat.nameAr, style: AppStyle.headTitle2.copyWith(fontSize: 18)),
const SizedBox(height: 8),
...cat.items.map((item) => _itemTile(c, merchant, item)),
const SizedBox(height: 16),
],
);
}
Widget _itemTile(FoodController c, FoodMerchant merchant, FoodMenuItem item) {
return Card(
margin: const EdgeInsets.only(bottom: 10),
color: AppColor.cardColor,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: BorderSide(color: AppColor.borderColor),
),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: !item.isAvailable || !merchant.isOpen
? null
: () => _openItemSheet(c, merchant, item),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.nameAr, style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
if (item.descriptionAr != null && item.descriptionAr!.isNotEmpty) ...[
const SizedBox(height: 4),
Text(item.descriptionAr!,
style: AppStyle.subtitle, maxLines: 2, overflow: TextOverflow.ellipsis),
],
const SizedBox(height: 6),
Text(foodFormatPrice(item.price),
style: AppStyle.title.copyWith(color: AppColor.accentColor, fontWeight: FontWeight.bold)),
],
),
),
const SizedBox(width: 12),
if (item.imageUrl != null && item.imageUrl!.isNotEmpty)
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.network(item.imageUrl!, width: 72, height: 72, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(width: 72, height: 72, color: AppColor.borderColor)),
),
if (!item.isAvailable)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(_isAr ? 'غير متاح' : 'Unavailable',
style: TextStyle(color: AppColor.redColor, fontSize: 12, fontWeight: FontWeight.bold)),
),
],
),
),
),
);
}
void _openItemSheet(FoodController c, FoodMerchant merchant, FoodMenuItem item) {
final Map<int, List<String>> selected = {};
int quantity = 1;
Get.bottomSheet(
isScrollControlled: true,
StatefulBuilder(
builder: (context, setState) {
int unitPrice = item.price;
for (final group in item.options) {
for (final choiceId in (selected[group.id] ?? [])) {
final choice = group.choices.firstWhere((ch) => ch.id == choiceId,
orElse: () => FoodOptionChoice(id: '', labelAr: '', price: 0));
unitPrice += choice.price;
}
}
bool canSubmit = true;
for (final group in item.options) {
if (group.isRequired && (selected[group.id] ?? []).isEmpty) canSubmit = false;
}
return Container(
constraints: BoxConstraints(maxHeight: Get.height * 0.85),
decoration: BoxDecoration(
color: AppColor.secondaryColor,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 12),
Container(width: 40, height: 4,
decoration: BoxDecoration(color: Colors.grey.withOpacity(0.3), borderRadius: BorderRadius.circular(2))),
Expanded(
child: ListView(
padding: const EdgeInsets.all(20),
children: [
Text(item.nameAr, style: AppStyle.headTitle2.copyWith(fontSize: 20)),
if (item.descriptionAr != null && item.descriptionAr!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(item.descriptionAr!, style: AppStyle.subtitle),
],
const SizedBox(height: 16),
...item.options.map((group) => _optionGroupWidget(group, selected, setState)),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.remove_circle_outline_rounded),
onPressed: quantity > 1 ? () => setState(() => quantity--) : null,
),
Text(quantity.toString(), style: AppStyle.headTitle2.copyWith(fontSize: 20)),
IconButton(
icon: const Icon(Icons.add_circle_outline_rounded),
onPressed: () => setState(() => quantity++),
),
],
),
],
),
),
Padding(
padding: EdgeInsets.fromLTRB(20, 8, 20, MediaQuery.of(context).padding.bottom + 16),
child: SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: canSubmit ? AppColor.primaryColor : AppColor.grayColor,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
onPressed: !canSubmit
? null
: () {
final ok = c.addToCartForMerchant(merchant.id, item,
quantity: quantity, options: selected);
Get.back();
if (ok) {
mySnackbarSuccess(_isAr ? 'أُضيف إلى السلة' : 'Added to cart');
}
},
child: Text(
'${_isAr ? "إضافة" : "Add"} · ${foodFormatPrice(unitPrice * quantity)}',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16),
),
),
),
),
],
),
);
},
),
);
}
Widget _optionGroupWidget(
FoodItemOptionGroup group, Map<int, List<String>> selected, StateSetter setState) {
final isSingle = group.maxSelect <= 1;
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(group.groupNameAr, style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
if (group.isRequired) ...[
const SizedBox(width: 6),
Text(_isAr ? '(مطلوب)' : '(required)',
style: TextStyle(color: AppColor.redColor, fontSize: 12)),
],
],
),
...group.choices.map((choice) {
final isChecked = (selected[group.id] ?? []).contains(choice.id);
return CheckboxListTile(
contentPadding: EdgeInsets.zero,
dense: true,
value: isChecked,
controlAffinity: ListTileControlAffinity.leading,
title: Text(choice.labelAr),
secondary: choice.price > 0 ? Text('+${foodFormatPrice(choice.price)}') : null,
onChanged: (checked) {
setState(() {
final list = List<String>.from(selected[group.id] ?? []);
if (checked == true) {
if (isSingle) list.clear();
if (!isSingle && list.length >= group.maxSelect) return;
list.add(choice.id);
} else {
list.remove(choice.id);
}
selected[group.id] = list;
});
},
);
}),
],
),
);
}
}
@@ -0,0 +1,102 @@
// food_order_history_page.dart — سجل طلبات الطعام
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../constant/box_name.dart';
import '../../constant/colors.dart';
import '../../constant/style.dart';
import '../../main.dart';
import '../../controller/food/food_models.dart';
import '../../controller/food/food_service.dart';
import '../widgets/my_scafold.dart';
import 'food_order_tracking_page.dart';
class FoodOrderHistoryPage extends StatefulWidget {
const FoodOrderHistoryPage({super.key});
@override
State<FoodOrderHistoryPage> createState() => _FoodOrderHistoryPageState();
}
class _FoodOrderHistoryPageState extends State<FoodOrderHistoryPage> {
bool get _isAr => box.read(BoxName.lang) == 'ar';
bool _isLoading = true;
List<FoodOrder> _orders = [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _isLoading = true);
final res = await FoodService.getHistory();
setState(() {
_orders = res.data ?? [];
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
return MyScafolld(
title: _isAr ? 'سجل الطلبات' : 'Order History',
isleading: true,
body: [
_isLoading
? const Center(child: CircularProgressIndicator())
: _orders.isEmpty
? Center(
child: Text(_isAr ? 'لا يوجد طلبات سابقة' : 'No past orders', style: AppStyle.title),
)
: RefreshIndicator(
onRefresh: _load,
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _orders.length,
itemBuilder: (_, i) => _orderTile(_orders[i]),
),
),
],
);
}
Widget _orderTile(FoodOrder order) {
final isCancelled = foodOrderIsCancelled(order.status);
return Card(
margin: const EdgeInsets.only(bottom: 10),
color: AppColor.cardColor,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: BorderSide(color: AppColor.borderColor),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
leading: CircleAvatar(
backgroundColor: AppColor.accentColor.withOpacity(0.15),
backgroundImage: (order.merchantLogoUrl != null && order.merchantLogoUrl!.isNotEmpty)
? NetworkImage(order.merchantLogoUrl!)
: null,
child: (order.merchantLogoUrl == null || order.merchantLogoUrl!.isEmpty)
? Icon(Icons.restaurant_rounded, color: AppColor.accentColor)
: null,
),
title: Text(order.merchantNameAr ?? '#${order.id}', style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
subtitle: Text(
isCancelled
? (_isAr ? 'مُلغى' : 'Cancelled')
: order.status == 'delivered'
? (_isAr ? 'تم التسليم' : 'Delivered')
: (_isAr ? 'قيد التنفيذ' : 'In progress'),
style: AppStyle.subtitle.copyWith(
color: isCancelled ? AppColor.redColor : (order.status == 'delivered' ? AppColor.greenColor : AppColor.accentColor),
),
),
trailing: Text(foodFormatPrice(order.grandTotal), style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
onTap: () => Get.to(() => FoodOrderTrackingPage(orderId: order.id)),
),
);
}
}
@@ -0,0 +1,267 @@
// food_order_tracking_page.dart — تتبّع حالة الطلب (polling — نفس مصدر الحقيقة في backend)
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../constant/box_name.dart';
import '../../constant/colors.dart';
import '../../constant/style.dart';
import '../../main.dart';
import '../../controller/food/food_controller.dart';
import '../../controller/food/food_models.dart';
import '../widgets/my_scafold.dart';
import 'food_home_page.dart';
class FoodOrderTrackingPage extends StatefulWidget {
final int orderId;
const FoodOrderTrackingPage({super.key, required this.orderId});
@override
State<FoodOrderTrackingPage> createState() => _FoodOrderTrackingPageState();
}
class _FoodOrderTrackingPageState extends State<FoodOrderTrackingPage> {
bool get _isAr => box.read(BoxName.lang) == 'ar';
@override
void initState() {
super.initState();
Get.find<FoodController>().startTrackingOrder(widget.orderId);
}
static const Map<String, String> _statusLabelsAr = {
'pending': 'بانتظار قبول المطعم',
'merchant_accepted': 'المطعم قبل طلبك',
'preparing': 'جاري التحضير',
'ready': 'جاهز، بانتظار سائق',
'courier_assigned': 'تم تعيين سائق',
'picked_up': 'السائق في الطريق إليك',
'delivered': 'تم التسليم',
'rejected': 'رُفض الطلب',
'cancelled_by_customer': 'أُلغي الطلب',
'cancelled_by_merchant': 'أُلغي من المطعم',
'cancelled_system': 'أُلغي تلقائياً',
};
static const Map<String, String> _statusLabelsEn = {
'pending': 'Awaiting merchant',
'merchant_accepted': 'Accepted by restaurant',
'preparing': 'Preparing',
'ready': 'Ready, waiting for courier',
'courier_assigned': 'Courier assigned',
'picked_up': 'Courier on the way',
'delivered': 'Delivered',
'rejected': 'Order rejected',
'cancelled_by_customer': 'Cancelled',
'cancelled_by_merchant': 'Cancelled by restaurant',
'cancelled_system': 'Auto-cancelled',
};
@override
Widget build(BuildContext context) {
return GetBuilder<FoodController>(
builder: (c) {
final order = c.activeOrder;
return MyScafolld(
title: _isAr ? 'طلبك #${widget.orderId}' : 'Order #${widget.orderId}',
isleading: true,
body: [
order == null
? const Center(child: CircularProgressIndicator())
: _content(c, order),
],
);
},
);
}
Widget _content(FoodController c, FoodOrder order) {
final isCancelled = foodOrderIsCancelled(order.status);
final label = _isAr ? _statusLabelsAr[order.status] : _statusLabelsEn[order.status];
return ListView(
padding: const EdgeInsets.all(20),
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isCancelled ? AppColor.redColor.withOpacity(0.08) : AppColor.accentColor.withOpacity(0.08),
borderRadius: BorderRadius.circular(16),
),
child: Column(
children: [
Icon(
isCancelled
? Icons.cancel_rounded
: order.status == 'delivered'
? Icons.check_circle_rounded
: Icons.delivery_dining_rounded,
size: 48,
color: isCancelled ? AppColor.redColor : AppColor.accentColor,
),
const SizedBox(height: 12),
Text(label ?? order.status,
style: AppStyle.headTitle2.copyWith(fontSize: 18), textAlign: TextAlign.center),
],
),
),
const SizedBox(height: 20),
if (!isCancelled) _statusTimeline(order.status),
const SizedBox(height: 20),
Text(_isAr ? 'ملخص الطلب' : 'Order Summary', style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
...order.items.map((i) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: Text('${i.quantity}× ${i.nameArSnapshot}', style: AppStyle.subtitle)),
Text(foodFormatPrice(i.lineTotal), style: AppStyle.subtitle),
],
),
)),
const Divider(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(_isAr ? 'الإجمالي' : 'Total', style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
Text(foodFormatPrice(order.grandTotal), style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
],
),
const SizedBox(height: 24),
if (order.status == 'pending')
SizedBox(
width: double.infinity,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
side: BorderSide(color: AppColor.redColor),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
onPressed: () => _confirmCancel(c),
child: Text(_isAr ? 'إلغاء الطلب' : 'Cancel Order', style: TextStyle(color: AppColor.redColor)),
),
),
if (order.status == 'delivered' && order.rating == null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColor.primaryColor,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
onPressed: () => _showRatingDialog(c, order.id),
child: Text(_isAr ? 'قيّم الطلب' : 'Rate Order', style: const TextStyle(color: Colors.white)),
),
),
),
if (order.status == 'delivered' || isCancelled)
Padding(
padding: const EdgeInsets.only(top: 8),
child: SizedBox(
width: double.infinity,
child: TextButton(
onPressed: () => Get.offAll(() => const FoodHomePage()),
child: Text(_isAr ? 'العودة للمطاعم' : 'Back to restaurants'),
),
),
),
],
);
}
Widget _statusTimeline(String currentStatus) {
final currentIndex = foodOrderStatusFlow.indexOf(currentStatus);
return Column(
children: List.generate(foodOrderStatusFlow.length, (i) {
final status = foodOrderStatusFlow[i];
final isDone = currentIndex >= i;
final label = _isAr ? _statusLabelsAr[status] : _statusLabelsEn[status];
return Row(
children: [
Column(
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isDone ? AppColor.accentColor : AppColor.borderColor,
),
),
if (i < foodOrderStatusFlow.length - 1)
Container(width: 2, height: 28, color: isDone ? AppColor.accentColor : AppColor.borderColor),
],
),
const SizedBox(width: 12),
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(label ?? status,
style: AppStyle.subtitle.copyWith(
color: isDone ? AppColor.writeColor : AppColor.grayColor,
fontWeight: isDone ? FontWeight.bold : FontWeight.normal)),
),
],
);
}),
);
}
void _confirmCancel(FoodController c) {
Get.dialog(
AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: Text(_isAr ? 'إلغاء الطلب؟' : 'Cancel order?'),
content: Text(_isAr
? 'سيتم استرجاع المبلغ إلى محفظتك فوراً إن دفعت من المحفظة.'
: 'Your wallet will be refunded immediately if paid by wallet.'),
actions: [
TextButton(onPressed: () => Get.back(), child: Text(_isAr ? 'تراجع' : 'Back')),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppColor.redColor),
onPressed: () {
Get.back();
c.cancelActiveOrder();
},
child: Text(_isAr ? 'إلغاء الطلب' : 'Cancel Order', style: const TextStyle(color: Colors.white)),
),
],
),
);
}
void _showRatingDialog(FoodController c, int orderId) {
int rating = 5;
Get.dialog(
StatefulBuilder(
builder: (context, setState) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: Text(_isAr ? 'قيّم تجربتك' : 'Rate your experience'),
content: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(5, (i) {
final star = i + 1;
return IconButton(
icon: Icon(star <= rating ? Icons.star_rounded : Icons.star_border_rounded,
color: const Color(0xFFF59E0B), size: 32),
onPressed: () => setState(() => rating = star),
);
}),
),
actions: [
TextButton(onPressed: () => Get.back(), child: Text(_isAr ? 'لاحقاً' : 'Later')),
ElevatedButton(
onPressed: () async {
Get.back();
await c.rateActiveOrder(rating);
},
child: Text(_isAr ? 'إرسال' : 'Submit'),
),
],
),
),
);
}
}
@@ -23,6 +23,7 @@ import '../HomePage/share_app_page.dart';
import '../setting_page.dart';
import '../profile/passenger_profile_page.dart';
import '../../transit/transit_home_page.dart';
import '../../food/food_home_page.dart';
// ─── ألوان النظام (Integrated with AppColor) ──────────────────────────────────
Color get _kCyan => AppColor.cyanBlue;
@@ -157,6 +158,11 @@ class MapMenuWidget extends StatelessWidget {
onTap: () =>
Get.to(() => const TransitHomePage()),
),
MenuListItem(
title: 'Food'.tr,
icon: Icons.restaurant_rounded,
onTap: () => Get.to(() => const FoodHomePage()),
),
MenuListItem(
title: 'My Balance'.tr,
icon: Icons.account_balance_wallet_outlined,
File diff suppressed because it is too large Load Diff
+6 -6
View File
@@ -1297,10 +1297,10 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
url: "https://pub.dev"
source: hosted
version: "0.12.19"
version: "0.12.18"
material_color_utilities:
dependency: transitive
description:
@@ -1313,10 +1313,10 @@ packages:
dependency: transitive
description:
name: meta
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.18.0"
version: "1.17.0"
mime:
dependency: "direct main"
description:
@@ -1901,10 +1901,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
url: "https://pub.dev"
source: hosted
version: "0.7.11"
version: "0.7.9"
timezone:
dependency: transitive
description: