Food delivery driver module + masked calls + TURN

This commit is contained in:
Hamza-Ayed
2026-08-04 01:50:50 +03:00
parent 04c214bdf6
commit f01e408ba6
35 changed files with 3029 additions and 212 deletions
@@ -0,0 +1,200 @@
// food_delivery_earnings_page.dart — أرباح التوصيل وسجل الطلبات المسلَّمة
// (منفصلة عن أرباح الرحلات: المصدر food_orders.delivery_fee لا محفظة الرحلات)
import 'package:flutter/material.dart';
import '../../constant/colors.dart';
import '../../constant/style.dart';
import '../../controller/food_delivery/food_delivery_models.dart';
import '../../controller/food_delivery/food_delivery_service.dart';
import '../widgets/my_scafold.dart';
class FoodDeliveryEarningsPage extends StatefulWidget {
const FoodDeliveryEarningsPage({super.key});
@override
State<FoodDeliveryEarningsPage> createState() => _FoodDeliveryEarningsPageState();
}
class _FoodDeliveryEarningsPageState extends State<FoodDeliveryEarningsPage> {
FoodEarnings? earnings;
List<FoodHistoryEntry> history = [];
bool isLoading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final results = await Future.wait([
FoodDeliveryService.getEarnings(),
FoodDeliveryService.getHistory(limit: 30),
]);
if (!mounted) return;
setState(() {
isLoading = false;
earnings = (results[0] as FoodDeliveryApiResult<FoodEarnings>).data;
history = (results[1] as FoodDeliveryApiResult<List<FoodHistoryEntry>>).data ?? [];
});
}
@override
Widget build(BuildContext context) {
return MyScafolld(
title: 'أرباح التوصيل',
isleading: true,
body: [
if (isLoading)
const Center(child: CircularProgressIndicator())
else
RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
if (earnings != null) ..._summary(earnings!),
const SizedBox(height: 20),
Text('سجل التوصيلات',
style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
if (history.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: Center(
child: Text('لم تنفّذ أي طلب توصيل بعد', style: AppStyle.subtitle)),
)
else
...history.map(_historyTile),
],
),
),
],
);
}
List<Widget> _summary(FoodEarnings e) {
return [
Row(
children: [
Expanded(child: _statTile('اليوم', e.todayEarnings, e.todayOrders, AppColor.greenColor)),
const SizedBox(width: 10),
Expanded(child: _statTile('آخر 7 أيام', e.weekEarnings, e.weekOrders, AppColor.accentColor)),
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(child: _statTile('آخر 30 يوم', e.monthEarnings, e.monthOrders, AppColor.primaryColor)),
const SizedBox(width: 10),
Expanded(child: _statTile('الإجمالي', e.totalEarnings, e.totalOrders, AppColor.grayColor)),
],
),
if (e.pendingCashOwed > 0) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFFF29900).withOpacity(0.12),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFF29900)),
),
child: Row(
children: [
const Icon(Icons.account_balance_rounded, color: Color(0xFFF29900)),
const SizedBox(width: 10),
Expanded(
child: Text(
'مبالغ نقدية بذمّتك للتسوية: ${foodFormatPrice(e.pendingCashOwed)}',
style: AppStyle.title,
),
),
],
),
),
],
if (e.daily.isNotEmpty) ...[
const SizedBox(height: 20),
Text('تفصيل آخر 7 أيام', style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
...e.daily.map(
(d) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(d.day, style: AppStyle.subtitle),
Text('${d.ordersCount} طلب', style: AppStyle.subtitle),
Text(foodFormatPrice(d.earnings),
style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
],
),
),
),
],
];
}
Widget _statTile(String label, int amount, int orders, Color color) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColor.cardColor,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColor.borderColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: AppStyle.subtitle),
const SizedBox(height: 6),
Text(foodFormatPrice(amount),
style: AppStyle.title.copyWith(fontWeight: FontWeight.bold, color: color)),
Text('$orders طلب', style: AppStyle.subtitle),
],
),
);
}
Widget _historyTile(FoodHistoryEntry h) {
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColor.cardColor,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColor.borderColor),
),
child: Row(
children: [
Icon(Icons.moped_rounded, color: AppColor.accentColor),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(h.merchantNameAr,
style: AppStyle.title.copyWith(fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis),
Text(
[
if (h.deliveredAt != null)
'${h.deliveredAt!.year}/${h.deliveredAt!.month}/${h.deliveredAt!.day}',
if (h.durationMinutes != null) '${h.durationMinutes} دقيقة',
'${h.itemsCount} صنف',
h.paymentMethod == 'cash' ? 'نقداً' : 'محفظة',
].join(' • '),
style: AppStyle.subtitle,
),
],
),
),
Text(foodFormatPrice(h.deliveryFee),
style: AppStyle.title.copyWith(
fontWeight: FontWeight.bold, color: AppColor.greenColor)),
],
),
);
}
}
@@ -1,4 +1,6 @@
// food_delivery_home_page.dart — تبويب التوصيل: تفعيل وضع التوصيل، عروض واردة، مهام نشطة
// food_delivery_home_page.dart — تبويب التوصيل: وضع التوصيل، أرباح اليوم، مهام نشطة
// شاشة العرض الوارد مستقلة (FoodOfferPage) وتُفتح تلقائياً من الكنترولر عند وصول
// عرض من سوكيت الطعام — هذه الشاشة للإدارة والمتابعة فقط.
import 'package:flutter/material.dart';
import 'package:get/get.dart';
@@ -8,6 +10,9 @@ import '../../controller/food_delivery/food_delivery_controller.dart';
import '../../controller/food_delivery/food_delivery_models.dart';
import '../widgets/elevated_btn.dart';
import '../widgets/my_scafold.dart';
import 'food_delivery_earnings_page.dart';
import 'food_offer_page.dart';
import 'food_task_details_page.dart';
class FoodDeliveryHomePage extends StatelessWidget {
const FoodDeliveryHomePage({super.key});
@@ -18,16 +23,25 @@ class FoodDeliveryHomePage extends StatelessWidget {
return GetBuilder<FoodDeliveryController>(
builder: (c) => MyScafolld(
title: 'Delivery'.tr,
title: 'التوصيل',
isleading: true,
action: IconButton(
icon: Icon(Icons.account_balance_wallet_rounded, color: AppColor.accentColor),
tooltip: 'أرباح التوصيل',
onPressed: () => Get.to(() => const FoodDeliveryEarningsPage()),
),
body: [
Column(
children: [
_modeToggleBar(c),
if (c.pendingOffers.isNotEmpty) _offersSection(c),
_todayStrip(c),
if (c.pendingOffers.isNotEmpty) _offersBanner(c),
Expanded(
child: RefreshIndicator(
onRefresh: c.fetchActiveTasks,
onRefresh: () async {
await c.refreshStatus();
await c.fetchActiveTasks();
},
child: c.isLoadingTasks && c.activeTasks.isEmpty
? const Center(child: CircularProgressIndicator())
: c.activeTasks.isEmpty
@@ -48,12 +62,14 @@ class FoodDeliveryHomePage extends StatelessWidget {
Widget _modeToggleBar(FoodDeliveryController c) {
return Container(
margin: const EdgeInsets.all(16),
margin: const EdgeInsets.fromLTRB(16, 16, 16, 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColor.cardColor,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColor.borderColor),
border: Border.all(
color: c.isDeliveryModeEnabled ? AppColor.greenColor : AppColor.borderColor,
),
),
child: Row(
children: [
@@ -66,11 +82,27 @@ class FoodDeliveryHomePage extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Delivery Mode'.tr, style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
Row(
children: [
Text('وضع التوصيل',
style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
if (c.isDeliveryModeEnabled) ...[
const SizedBox(width: 8),
// مؤشّر قناة الطعام وحدها — انقطاعها لا يعني انقطاع الرحلات
Icon(
c.isSocketConnected ? Icons.wifi_rounded : Icons.wifi_off_rounded,
size: 14,
color: c.isSocketConnected ? AppColor.greenColor : AppColor.grayColor,
),
],
],
),
Text(
c.isDeliveryModeEnabled
? 'You will receive delivery offers'.tr
: 'Turn on to receive delivery offers'.tr,
? (c.isSocketConnected
? 'متصل — ستصلك عروض التوصيل فوراً'
: 'إعادة الاتصال… العروض تصل بالتحديث الدوري')
: 'فعّله لاستقبال عروض توصيل الطعام',
style: AppStyle.subtitle,
),
],
@@ -88,80 +120,62 @@ class FoodDeliveryHomePage extends StatelessWidget {
);
}
Widget _offersSection(FoodDeliveryController c) {
return SizedBox(
height: 150,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
itemCount: c.pendingOffers.length,
itemBuilder: (_, i) => _offerCard(c, c.pendingOffers[i]),
Widget _todayStrip(FoodDeliveryController c) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: AppColor.cardColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColor.borderColor),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_miniStat('توصيلات اليوم', '${c.todayOrdersCount}'),
Container(width: 1, height: 28, color: AppColor.borderColor),
_miniStat('أرباح اليوم', foodFormatPrice(c.todayEarnings)),
],
),
);
}
Widget _offerCard(FoodDeliveryController c, FoodDeliveryOffer offer) {
final isResponding = c.isRespondingToOffer(offer.orderId);
return Container(
width: 260,
margin: const EdgeInsets.symmetric(horizontal: 4),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColor.primaryColor,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.15), blurRadius: 12, offset: const Offset(0, 4))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.notifications_active_rounded, color: Colors.white, size: 18),
const SizedBox(width: 6),
Expanded(
child: Text('New Delivery Offer'.tr,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 13)),
Widget _miniStat(String label, String value) {
return Column(
children: [
Text(label, style: AppStyle.subtitle),
Text(value, style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
],
);
}
// شريط تنبيه فقط — القرار الفعلي يتم في شاشة العرض المستقلة
Widget _offersBanner(FoodDeliveryController c) {
final count = c.pendingOffers.length;
return GestureDetector(
onTap: () => Get.to(() => const FoodOfferPage(), fullscreenDialog: true),
child: Container(
margin: const EdgeInsets.fromLTRB(16, 12, 16, 0),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [Color(0xFFFF6B35), Color(0xFFF7931E)]),
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
const Icon(Icons.notifications_active_rounded, color: Colors.white),
const SizedBox(width: 10),
Expanded(
child: Text(
count > 1 ? 'لديك $count عروض توصيل بانتظار ردّك' : 'لديك عرض توصيل جديد',
style: const TextStyle(
color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15),
),
Text('${offer.secondsRemaining}s', style: const TextStyle(color: Colors.amber, fontWeight: FontWeight.bold)),
],
),
const SizedBox(height: 6),
Text(offer.merchantNameAr,
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15),
maxLines: 1, overflow: TextOverflow.ellipsis),
Text(foodFormatPrice(offer.deliveryFee),
style: const TextStyle(color: Colors.white70, fontSize: 13)),
const Spacer(),
Row(
children: [
Expanded(
child: SizedBox(
height: 36,
child: isResponding
? const Center(child: SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)))
: OutlinedButton(
style: OutlinedButton.styleFrom(side: const BorderSide(color: Colors.white54)),
onPressed: () => c.respondToOffer(offer.orderId, false),
child: Text('Reject'.tr, style: const TextStyle(color: Colors.white70, fontSize: 12)),
),
),
),
const SizedBox(width: 8),
Expanded(
child: SizedBox(
height: 36,
child: isResponding
? const SizedBox.shrink()
: MyElevatedButton(
title: 'Accept'.tr,
kolor: AppColor.greenColor,
onPressed: () => c.respondToOffer(offer.orderId, true),
),
),
),
],
),
],
),
Text('${c.pendingOffers.first.secondsRemaining}s',
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
],
),
),
);
}
@@ -172,13 +186,20 @@ class FoodDeliveryHomePage extends StatelessWidget {
const SizedBox(height: 100),
Icon(Icons.moped_outlined, size: 72, color: AppColor.grayColor),
const SizedBox(height: 16),
Center(child: Text('No delivery tasks right now'.tr, style: AppStyle.title)),
Center(
child: Text(
c.isDeliveryModeEnabled
? 'لا توجد مهام توصيل حالياً — بانتظار العروض'
: 'فعّل وضع التوصيل لاستقبال الطلبات',
style: AppStyle.title,
textAlign: TextAlign.center,
),
),
],
);
}
Widget _taskCard(FoodDeliveryController c, FoodDeliveryTask task) {
final isPickedUp = task.status == 'picked_up';
final isBusy = c.isTaskBusy(task.id);
return Card(
@@ -187,47 +208,71 @@ class FoodDeliveryHomePage extends StatelessWidget {
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: BorderSide(color: isPickedUp ? AppColor.greenColor : AppColor.borderColor, width: isPickedUp ? 1.5 : 1),
side: BorderSide(
color: task.isPickedUp ? AppColor.greenColor : AppColor.borderColor,
width: task.isPickedUp ? 1.5 : 1,
),
),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(isPickedUp ? Icons.location_on_rounded : Icons.storefront_rounded,
color: AppColor.accentColor),
const SizedBox(width: 8),
Expanded(
child: Text(
isPickedUp ? (task.deliveryAddress ?? '') : task.merchantNameAr,
style: AppStyle.title.copyWith(fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Text(foodFormatPrice(task.deliveryFee), style: AppStyle.subtitle),
],
),
const SizedBox(height: 4),
Text(
isPickedUp ? 'Deliver to customer'.tr : (task.merchantAddress ?? ''),
style: AppStyle.subtitle,
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
height: 44,
child: isBusy
? const Center(child: CircularProgressIndicator())
: MyElevatedButton(
title: isPickedUp ? 'Mark Delivered'.tr : 'Picked Up from Restaurant'.tr,
kolor: isPickedUp ? AppColor.greenColor : AppColor.accentColor,
onPressed: () => isPickedUp ? c.markDelivered(task.id) : c.markPickedUp(task.id),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => Get.to(() => FoodTaskDetailsPage(orderId: task.id)),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(task.isPickedUp ? Icons.location_on_rounded : Icons.storefront_rounded,
color: AppColor.accentColor),
const SizedBox(width: 8),
Expanded(
child: Text(
task.isPickedUp
? (task.deliveryAddress ?? 'عنوان الزبون')
: task.merchantNameAr,
style: AppStyle.title.copyWith(fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
Text(foodFormatPrice(task.deliveryFee), style: AppStyle.subtitle),
],
),
const SizedBox(height: 4),
Text(
task.isPickedUp
? 'في الطريق للزبون — ${task.itemsCount} صنف'
: (task.merchantAddress ?? 'استلم الطلب من المطعم'),
style: AppStyle.subtitle,
),
if (task.isCash) ...[
const SizedBox(height: 6),
Row(
children: [
const Icon(Icons.payments_rounded, size: 16, color: Color(0xFFF29900)),
const SizedBox(width: 6),
Text('تحصيل نقدي: ${foodFormatPrice(task.cashToCollect)}',
style: AppStyle.subtitle.copyWith(color: const Color(0xFFF29900))),
],
),
],
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
height: 44,
child: isBusy
? const Center(child: CircularProgressIndicator())
: MyElevatedButton(
title: task.isPickedUp ? 'تأكيد التسليم' : 'استلمت الطلب من المطعم',
kolor: task.isPickedUp ? AppColor.greenColor : AppColor.accentColor,
onPressed: () => task.isPickedUp
? c.markDelivered(task.id)
: c.markPickedUp(task.id),
),
),
],
),
),
),
);
@@ -0,0 +1,439 @@
// food_offer_page.dart — شاشة استقبال عرض التوصيل (مستقلة عن شاشة عرض الرحلة)
//
// شاشة كاملة مقصودة لا بطاقة صغيرة: مهلة العرض 20 ثانية، والسائق يحتاج يقرأ
// المطعم والأجرة وهل التحصيل نقدي قبل أن يقرر. تفتح تلقائياً عند وصول العرض
// من سوكيت الطعام، وتُغلق نفسها إذا انتهت مهلة كل العروض.
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:get/get.dart';
import '../../constant/colors.dart';
import '../../controller/food_delivery/food_delivery_controller.dart';
import '../../controller/food_delivery/food_delivery_models.dart';
import '../../controller/functions/location_controller.dart';
class FoodOfferPage extends StatelessWidget {
const FoodOfferPage({super.key});
@override
Widget build(BuildContext context) {
return GetBuilder<FoodDeliveryController>(
builder: (c) {
if (c.pendingOffers.isEmpty) return const _NoOfferView();
final offer = c.pendingOffers.first;
final isResponding = c.isRespondingToOffer(offer.orderId);
return Scaffold(
backgroundColor: const Color(0xFF12171F),
body: SafeArea(
child: Column(
children: [
_Header(offer: offer, queued: c.pendingOffers.length),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
children: [
const SizedBox(height: 8),
_FeeBadge(offer: offer),
const SizedBox(height: 20),
_RouteCard(offer: offer),
const SizedBox(height: 14),
_MetaRow(offer: offer),
if (offer.isCash) ...[
const SizedBox(height: 14),
_CashWarning(amount: offer.cashToCollect),
],
const SizedBox(height: 24),
],
),
),
),
_Actions(
isResponding: isResponding,
onAccept: () => c.respondToOffer(offer.orderId, true),
onReject: () => c.respondToOffer(offer.orderId, false),
),
],
),
),
);
},
);
}
}
class _NoOfferView extends StatelessWidget {
const _NoOfferView();
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF12171F),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.timer_off_rounded, size: 64, color: Colors.white38),
const SizedBox(height: 12),
const Text('انتهت مهلة العرض',
style: TextStyle(color: Colors.white70, fontSize: 16)),
const SizedBox(height: 20),
TextButton(onPressed: Get.back, child: const Text('رجوع')),
],
),
),
);
}
}
class _Header extends StatelessWidget {
final FoodDeliveryOffer offer;
final int queued;
const _Header({required this.offer, required this.queued});
@override
Widget build(BuildContext context) {
final remaining = offer.secondsRemaining;
final progress = offer.ttlSeconds == 0 ? 0.0 : remaining / offer.ttlSeconds;
return Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Row(
children: [
SizedBox(
width: 62,
height: 62,
child: Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 62,
height: 62,
child: CircularProgressIndicator(
value: progress,
strokeWidth: 5,
backgroundColor: Colors.white12,
valueColor: AlwaysStoppedAnimation(
remaining <= 5 ? const Color(0xFFE53935) : const Color(0xFFFF6B35),
),
),
),
Text('$remaining',
style: const TextStyle(
color: Colors.white, fontWeight: FontWeight.bold, fontSize: 20)),
],
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('🍔 عرض توصيل طلب طعام',
style: TextStyle(
color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold)),
Text(
queued > 1 ? 'لديك $queued عروض بالانتظار' : 'وافق قبل انتهاء المهلة',
style: const TextStyle(color: Colors.white54, fontSize: 13),
),
],
),
),
],
),
);
}
}
class _FeeBadge extends StatelessWidget {
final FoodDeliveryOffer offer;
const _FeeBadge({required this.offer});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 18),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFFFF6B35), Color(0xFFF7931E)],
),
borderRadius: BorderRadius.circular(18),
),
child: Column(
children: [
const Text('أجرة التوصيل',
style: TextStyle(color: Colors.white70, fontSize: 13)),
const SizedBox(height: 4),
Text(foodFormatPrice(offer.deliveryFee),
style: const TextStyle(
color: Colors.white, fontSize: 30, fontWeight: FontWeight.bold)),
],
),
);
}
}
class _RouteCard extends StatelessWidget {
final FoodDeliveryOffer offer;
const _RouteCard({required this.offer});
// مسافات تقريبية بخط مستقيم — كافية لقرار القبول خلال ثوانٍ، ولا نستدعي
// خدمة مسارات هنا حتى لا نضيف زمناً على مهلة عرض قصيرة أصلاً.
double? _distanceKm(double? aLat, double? aLng, double bLat, double bLng) {
if (aLat == null || aLng == null || bLat == 0 || bLng == 0) return null;
return Geolocator.distanceBetween(aLat, aLng, bLat, bLng) / 1000;
}
@override
Widget build(BuildContext context) {
double? driverLat;
double? driverLng;
if (Get.isRegistered<LocationController>()) {
final loc = Get.find<LocationController>().myLocation;
if (loc.latitude != 0) {
driverLat = loc.latitude;
driverLng = loc.longitude;
}
}
final toMerchant =
_distanceKm(driverLat, driverLng, offer.merchantLat ?? 0, offer.merchantLng ?? 0);
final merchantToCustomer =
_distanceKm(offer.merchantLat, offer.merchantLng, offer.deliveryLat, offer.deliveryLng);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.06),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.white12),
),
child: Column(
children: [
_RouteRow(
icon: Icons.storefront_rounded,
iconColor: const Color(0xFFFF6B35),
title: offer.merchantNameAr,
subtitle: offer.merchantAddress ?? 'الاستلام من المطعم',
trailing: toMerchant == null ? null : '${toMerchant.toStringAsFixed(1)} كم',
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
const SizedBox(width: 18),
Container(width: 2, height: 22, color: Colors.white24),
],
),
),
_RouteRow(
icon: Icons.location_on_rounded,
iconColor: const Color(0xFF4CAF50),
title: 'التسليم للزبون',
// العنوان النصّي لا يصل في العرض — يظهر بعد القبول فقط (حماية بيانات الزبون)
subtitle: 'يظهر العنوان الكامل بعد قبول الطلب',
trailing: merchantToCustomer == null
? null
: '${merchantToCustomer.toStringAsFixed(1)} كم',
),
],
),
);
}
}
class _RouteRow extends StatelessWidget {
final IconData icon;
final Color iconColor;
final String title;
final String subtitle;
final String? trailing;
const _RouteRow({
required this.icon,
required this.iconColor,
required this.title,
required this.subtitle,
this.trailing,
});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: iconColor, size: 24),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: const TextStyle(
color: Colors.white, fontSize: 15, fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis),
Text(subtitle,
style: const TextStyle(color: Colors.white54, fontSize: 12),
maxLines: 2,
overflow: TextOverflow.ellipsis),
],
),
),
if (trailing != null)
Text(trailing!,
style: const TextStyle(
color: Colors.white70, fontSize: 13, fontWeight: FontWeight.bold)),
],
);
}
}
class _MetaRow extends StatelessWidget {
final FoodDeliveryOffer offer;
const _MetaRow({required this.offer});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: _MetaTile(
icon: Icons.shopping_bag_rounded,
label: 'عدد الأصناف',
value: offer.itemsCount > 0 ? '${offer.itemsCount}' : '—',
),
),
const SizedBox(width: 12),
Expanded(
child: _MetaTile(
icon: offer.isCash ? Icons.payments_rounded : Icons.account_balance_wallet_rounded,
label: 'طريقة الدفع',
value: offer.isCash ? 'نقداً' : 'محفظة',
),
),
],
);
}
}
class _MetaTile extends StatelessWidget {
final IconData icon;
final String label;
final String value;
const _MetaTile({required this.icon, required this.label, required this.value});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 14),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.06),
borderRadius: BorderRadius.circular(14),
),
child: Column(
children: [
Icon(icon, color: Colors.white70, size: 20),
const SizedBox(height: 6),
Text(label, style: const TextStyle(color: Colors.white54, fontSize: 11)),
Text(value,
style: const TextStyle(
color: Colors.white, fontSize: 15, fontWeight: FontWeight.bold)),
],
),
);
}
}
class _CashWarning extends StatelessWidget {
final int amount;
const _CashWarning({required this.amount});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFFF29900).withOpacity(0.15),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFF29900).withOpacity(0.5)),
),
child: Row(
children: [
const Icon(Icons.info_rounded, color: Color(0xFFF29900)),
const SizedBox(width: 10),
Expanded(
child: Text(
'طلب نقدي — ستحصّل ${foodFormatPrice(amount)} من الزبون عند التسليم',
style: const TextStyle(color: Color(0xFFFFD08A), fontSize: 13),
),
),
],
),
);
}
}
class _Actions extends StatelessWidget {
final bool isResponding;
final VoidCallback onAccept;
final VoidCallback onReject;
const _Actions({
required this.isResponding,
required this.onAccept,
required this.onReject,
});
@override
Widget build(BuildContext context) {
if (isResponding) {
return const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator(color: Color(0xFFFF6B35))),
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Row(
children: [
Expanded(
child: SizedBox(
height: 56,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
side: const BorderSide(color: Colors.white24),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
onPressed: onReject,
child: const Text('رفض',
style: TextStyle(color: Colors.white70, fontSize: 16)),
),
),
),
const SizedBox(width: 12),
Expanded(
flex: 2,
child: SizedBox(
height: 56,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: AppColor.greenColor,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
onPressed: onAccept,
child: const Text('قبول الطلب',
style: TextStyle(
color: Colors.white, fontSize: 17, fontWeight: FontWeight.bold)),
),
),
),
],
),
);
}
}
@@ -0,0 +1,368 @@
// food_task_details_page.dart — شاشة مهمة التوصيل: الأصناف، المبالغ، الملاحة،
// الاتصال بالمطعم، ومبلغ التحصيل النقدي، وزر الخطوة التالية (استلمت/سلّمت).
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../constant/colors.dart';
import '../../constant/style.dart';
import '../../controller/food_delivery/food_delivery_controller.dart';
import '../../controller/food_delivery/food_delivery_models.dart';
import '../../controller/food_delivery/food_delivery_service.dart';
import '../widgets/elevated_btn.dart';
import '../widgets/error_snakbar.dart';
import '../widgets/my_scafold.dart';
class FoodTaskDetailsPage extends StatefulWidget {
final int orderId;
const FoodTaskDetailsPage({super.key, required this.orderId});
@override
State<FoodTaskDetailsPage> createState() => _FoodTaskDetailsPageState();
}
class _FoodTaskDetailsPageState extends State<FoodTaskDetailsPage> {
FoodOrderDetails? details;
bool isLoading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final res = await FoodDeliveryService.getOrderDetails(widget.orderId);
if (!mounted) return;
setState(() {
isLoading = false;
details = res.data;
});
if (!res.success) mySnackbarWarning(res.message);
}
Future<void> _openMaps(double lat, double lng) async {
// نفتح تطبيق الخرائط الافتراضي على الجهاز — لا نضمّن مفاتيح ولا نمرّر
// بيانات الزبون في الرابط سوى الإحداثيات اللازمة للملاحة.
final uri = Uri.parse('https://www.google.com/maps/dir/?api=1&destination=$lat,$lng');
if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
mySnackbarWarning('تعذّر فتح تطبيق الخرائط');
}
}
Future<void> _call(String phone) async {
final uri = Uri(scheme: 'tel', path: phone);
if (!await launchUrl(uri)) mySnackbarWarning('تعذّر إجراء الاتصال');
}
@override
Widget build(BuildContext context) {
return MyScafolld(
title: 'مهمة التوصيل',
isleading: true,
body: [
if (isLoading)
const Center(child: CircularProgressIndicator())
else if (details == null)
Center(child: Text('تعذّر تحميل تفاصيل الطلب', style: AppStyle.title))
else
RefreshIndicator(
onRefresh: _load,
child: ListView(
padding: const EdgeInsets.all(16),
children: _content(details!),
),
),
],
);
}
List<Widget> _content(FoodOrderDetails d) {
final t = d.task;
return [
_statusStepper(t),
const SizedBox(height: 16),
if (t.isCash) ...[_cashBanner(t), const SizedBox(height: 16)],
_locationCard(
icon: Icons.storefront_rounded,
color: AppColor.accentColor,
title: t.merchantNameAr,
subtitle: t.merchantAddress ?? 'مطعم',
onNavigate: (t.merchantLat != null && t.merchantLng != null)
? () => _openMaps(t.merchantLat!, t.merchantLng!)
: null,
onCall: d.merchantPhone == null ? null : () => _call(d.merchantPhone!),
),
const SizedBox(height: 12),
_locationCard(
icon: Icons.location_on_rounded,
color: AppColor.greenColor,
title: 'عنوان الزبون',
subtitle: t.deliveryAddress ?? 'غير متاح',
onNavigate: t.deliveryLat == 0 ? null : () => _openMaps(t.deliveryLat, t.deliveryLng),
// مكالمة مقنّعة: لا رقم هاتف للزبون يظهر أو يُخزَّن — اتصال صوتي
// عبر جلسة مؤقتة، ومتاح فقط ما دامت المهمة نشطة.
onCall: () => Get.find<FoodDeliveryController>().callCustomer(t.id),
callIcon: Icons.phone_in_talk_rounded,
callTooltip: 'اتصال مقنّع بالزبون',
),
if ((t.customerNote ?? '').isNotEmpty) ...[
const SizedBox(height: 12),
_noteCard(t.customerNote!),
],
const SizedBox(height: 16),
_itemsCard(d.items),
const SizedBox(height: 16),
_totalsCard(t),
const SizedBox(height: 20),
_actionButton(t),
const SizedBox(height: 24),
];
}
Widget _statusStepper(FoodDeliveryTask t) {
final steps = [
('تم الإسناد', true),
('استلام من المطعم', t.isPickedUp),
('تسليم للزبون', false),
];
return Row(
children: List.generate(steps.length * 2 - 1, (i) {
if (i.isOdd) {
final done = steps[(i - 1) ~/ 2].$2;
return Expanded(
child: Container(height: 2, color: done ? AppColor.greenColor : AppColor.borderColor),
);
}
final step = steps[i ~/ 2];
return Column(
children: [
CircleAvatar(
radius: 14,
backgroundColor: step.$2 ? AppColor.greenColor : AppColor.borderColor,
child: Icon(step.$2 ? Icons.check : Icons.circle,
size: 14, color: Colors.white),
),
const SizedBox(height: 4),
SizedBox(
width: 80,
child: Text(step.$1,
textAlign: TextAlign.center,
style: AppStyle.subtitle.copyWith(fontSize: 11)),
),
],
);
}),
);
}
Widget _cashBanner(FoodDeliveryTask t) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFFF29900).withOpacity(0.12),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFF29900)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.payments_rounded, color: Color(0xFFF29900)),
const SizedBox(width: 8),
Text('تحصيل نقدي',
style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
],
),
const SizedBox(height: 8),
Text('المبلغ المطلوب من الزبون: ${foodFormatPrice(t.cashToCollect)}',
style: AppStyle.title),
const SizedBox(height: 4),
// نفصّل الرقمين صراحةً كي لا يحسب السائق على باب الزبون:
// أجرته تبقى معه، والباقي يُسوَّى إدارياً لاحقاً.
Text(
'تحتفظ بأجرتك ${foodFormatPrice(t.deliveryFee)} — ويبقى ${foodFormatPrice(t.courierOwes)} للتسوية',
style: AppStyle.subtitle,
),
],
),
);
}
Widget _locationCard({
required IconData icon,
required Color color,
required String title,
required String subtitle,
VoidCallback? onNavigate,
VoidCallback? onCall,
IconData callIcon = Icons.call_rounded,
String callTooltip = 'اتصال',
}) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColor.cardColor,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColor.borderColor),
),
child: Row(
children: [
Icon(icon, color: color),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: AppStyle.title.copyWith(fontWeight: FontWeight.bold),
maxLines: 1,
overflow: TextOverflow.ellipsis),
Text(subtitle, style: AppStyle.subtitle, maxLines: 2),
],
),
),
if (onCall != null)
IconButton(
onPressed: onCall,
icon: Icon(callIcon, color: AppColor.greenColor),
tooltip: callTooltip,
),
if (onNavigate != null)
IconButton(
onPressed: onNavigate,
icon: Icon(Icons.navigation_rounded, color: AppColor.accentColor),
tooltip: 'الملاحة',
),
],
),
);
}
Widget _noteCard(String note) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColor.cardColor,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColor.borderColor),
),
child: Row(
children: [
Icon(Icons.sticky_note_2_rounded, color: AppColor.grayColor),
const SizedBox(width: 10),
Expanded(child: Text('ملاحظة الزبون: $note', style: AppStyle.subtitle)),
],
),
);
}
Widget _itemsCard(List<FoodOrderItem> items) {
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColor.cardColor,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColor.borderColor),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('محتوى الطلب', style: AppStyle.title.copyWith(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
if (items.isEmpty)
Text('لا توجد تفاصيل أصناف', style: AppStyle.subtitle)
else
...items.map(
(i) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: AppColor.accentColor.withOpacity(0.15),
borderRadius: BorderRadius.circular(8),
),
child: Text('×${i.quantity}',
style: AppStyle.subtitle.copyWith(fontWeight: FontWeight.bold)),
),
const SizedBox(width: 10),
Expanded(child: Text(i.nameAr, style: AppStyle.title)),
Text(foodFormatPrice(i.lineTotal), style: AppStyle.subtitle),
],
),
),
),
],
),
);
}
Widget _totalsCard(FoodDeliveryTask t) {
Widget row(String label, int amount, {bool bold = false}) => Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label,
style: bold
? AppStyle.title.copyWith(fontWeight: FontWeight.bold)
: AppStyle.subtitle),
Text(foodFormatPrice(amount),
style: bold
? AppStyle.title.copyWith(fontWeight: FontWeight.bold)
: AppStyle.subtitle),
],
),
);
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColor.cardColor,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColor.borderColor),
),
child: Column(
children: [
row('قيمة الأصناف', t.itemsTotal),
row('أجرة التوصيل (لك)', t.deliveryFee),
if (t.serviceFee > 0) row('رسوم الخدمة', t.serviceFee),
if (t.discount > 0) row('الخصم', t.discount),
const Divider(),
row('إجمالي الطلب', t.grandTotal, bold: true),
],
),
);
}
Widget _actionButton(FoodDeliveryTask t) {
final c = Get.find<FoodDeliveryController>();
return GetBuilder<FoodDeliveryController>(
builder: (_) {
if (c.isTaskBusy(t.id)) {
return const Center(child: CircularProgressIndicator());
}
return SizedBox(
width: double.infinity,
height: 52,
child: MyElevatedButton(
title: t.isPickedUp ? 'تأكيد التسليم للزبون' : 'استلمت الطلب من المطعم',
kolor: t.isPickedUp ? AppColor.greenColor : AppColor.accentColor,
onPressed: () async {
if (t.isPickedUp) {
await c.markDelivered(t.id);
if (mounted) Get.back();
} else {
await c.markPickedUp(t.id);
await _load();
}
},
),
);
},
);
}
}