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

This commit is contained in:
Hamza-Ayed
2026-08-02 17:52:28 +03:00
parent b78a6797d5
commit 4620e84d34
96 changed files with 6250 additions and 476 deletions
@@ -0,0 +1,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