Update: 2026-08-07 06:00:56
This commit is contained in:
@@ -65,7 +65,7 @@ class OrderRequestController extends GetxController
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// معلومات الحجز المسبق إن كانت الرحلة مؤجلة.
|
/// معلومات الحجز المسبق إن كانت الرحلة مجدولة.
|
||||||
///
|
///
|
||||||
/// يصل الحقلان من buildMarketPayload عبر السوكِت (خريطة)، أو من الفهرسين
|
/// يصل الحقلان من buildMarketPayload عبر السوكِت (خريطة)، أو من الفهرسين
|
||||||
/// 39/40 عبر FCM (قائمة). _getValueAt يوحّد الشكلين.
|
/// 39/40 عبر FCM (قائمة). _getValueAt يوحّد الشكلين.
|
||||||
|
|||||||
@@ -35,9 +35,17 @@ class OrderData {
|
|||||||
/// ثواني السبق الحصري كما حددها الخادم (DISPATCH_HEAD_START_SECONDS).
|
/// ثواني السبق الحصري كما حددها الخادم (DISPATCH_HEAD_START_SECONDS).
|
||||||
final int offerExpiresIn;
|
final int offerExpiresIn;
|
||||||
|
|
||||||
|
/// رحلة محجوزة مسبقاً — موعد مضبوط لا طلب فوري.
|
||||||
|
final bool isScheduled;
|
||||||
|
|
||||||
|
/// موعد الانطلاق كما حجزه الراكب. فارغ في الرحلات العادية.
|
||||||
|
final String scheduledAt;
|
||||||
|
|
||||||
OrderData({
|
OrderData({
|
||||||
this.offerType,
|
this.offerType,
|
||||||
this.offerExpiresIn = 0,
|
this.offerExpiresIn = 0,
|
||||||
|
this.isScheduled = false,
|
||||||
|
this.scheduledAt = '',
|
||||||
required this.customerName,
|
required this.customerName,
|
||||||
required this.customerToken,
|
required this.customerToken,
|
||||||
required this.tripDistanceKm,
|
required this.tripDistanceKm,
|
||||||
@@ -151,6 +159,11 @@ class OrderData {
|
|||||||
: null,
|
: null,
|
||||||
offerExpiresIn:
|
offerExpiresIn:
|
||||||
list.length > 38 ? (int.tryParse(list[38].toString()) ?? 0) : 0,
|
list.length > 38 ? (int.tryParse(list[38].toString()) ?? 0) : 0,
|
||||||
|
|
||||||
|
// الفهرسان 39/40 — يضيفهما add_ride.php للرحلات المحجوزة.
|
||||||
|
isScheduled: list.length > 39 &&
|
||||||
|
(list[39].toString() == '1' || list[39].toString() == 'true'),
|
||||||
|
scheduledAt: list.length > 40 ? list[40].toString() : '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +210,8 @@ class OrderData {
|
|||||||
: null,
|
: null,
|
||||||
offerType: str('offer_type').isNotEmpty ? str('offer_type') : null,
|
offerType: str('offer_type').isNotEmpty ? str('offer_type') : null,
|
||||||
offerExpiresIn: int.tryParse(str('offer_expires_in', '0')) ?? 0,
|
offerExpiresIn: int.tryParse(str('offer_expires_in', '0')) ?? 0,
|
||||||
|
isScheduled: str('is_scheduled') == '1' || str('is_scheduled') == 'true',
|
||||||
|
scheduledAt: str('scheduled_at'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -475,19 +475,34 @@ class _OrderOverlayState extends State<OrderOverlay>
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
isExclusive ? Icons.star_rounded : Icons.drive_eta_rounded,
|
orderData?.isScheduled == true
|
||||||
color: isExclusive ? const Color(0xFFFFD54F) : AppColors.white,
|
? Icons.schedule
|
||||||
|
: isExclusive
|
||||||
|
? Icons.star_rounded
|
||||||
|
: Icons.drive_eta_rounded,
|
||||||
|
color: orderData?.isScheduled == true
|
||||||
|
? const Color(0xFFB39DDB)
|
||||||
|
: isExclusive
|
||||||
|
? const Color(0xFFFFD54F)
|
||||||
|
: AppColors.white,
|
||||||
size: 24,
|
size: 24,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
// العنوان يحمل حالة العرض: خاص خلال السبق، عام بعده، و"طلب
|
// العنوان يحمل حالة العرض: خاص خلال السبق، عام بعده، و"طلب
|
||||||
// جديد" في البثّ الحر حيث لا معنى للتمييز.
|
// جديد" في البثّ الحر حيث لا معنى للتمييز.
|
||||||
Text(
|
Text(
|
||||||
orderData?.offerType == null
|
orderData?.isScheduled == true
|
||||||
|
// الأولوية للمجدولة: معلومة دائمة عن الرحلة تسبق حالة
|
||||||
|
// العرض المؤقتة، والسائق يحتاجها ليقرر إن كان يستطيع
|
||||||
|
// الالتزام بالموعد.
|
||||||
|
? "🗓️ رحلة مجدولة".tr
|
||||||
|
: orderData?.offerType == null
|
||||||
? "طلب جديد".tr
|
? "طلب جديد".tr
|
||||||
: (isExclusive ? "⭐ طلب خاص لك".tr : "🌐 طلب عام".tr),
|
: (isExclusive ? "⭐ طلب خاص لك".tr : "🌐 طلب عام".tr),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: isExclusive
|
color: orderData?.isScheduled == true
|
||||||
|
? const Color(0xFFB39DDB)
|
||||||
|
: isExclusive
|
||||||
? const Color(0xFFFFD54F)
|
? const Color(0xFFFFD54F)
|
||||||
: AppColors.white,
|
: AppColors.white,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ class OrderRequestPage extends StatelessWidget {
|
|||||||
OfferTypeBadge(rideInfo: controller.offerInfo!),
|
OfferTypeBadge(rideInfo: controller.offerInfo!),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
],
|
],
|
||||||
// شارة الرحلة المؤجلة — موعد مضبوط لا طلب فوري
|
// شارة الرحلة المجدولة — موعد مضبوط لا طلب فوري
|
||||||
if (ScheduledRideBadge.isScheduled(
|
if (ScheduledRideBadge.isScheduled(
|
||||||
controller.scheduleInfo)) ...[
|
controller.scheduleInfo)) ...[
|
||||||
ScheduledRideBadge(
|
ScheduledRideBadge(
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ class RideAvailableCard extends StatelessWidget {
|
|||||||
.copyWith(color: AppColor.greenColor, fontSize: 13),
|
.copyWith(color: AppColor.greenColor, fontSize: 13),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// شارة الرحلة المؤجلة — معلومة دائمة عن الرحلة، تظهر هنا
|
// شارة الرحلة المجدولة — معلومة دائمة عن الرحلة، تظهر هنا
|
||||||
// وفي نافذة الطلب والأوفرلي معاً.
|
// وفي نافذة الطلب والأوفرلي معاً.
|
||||||
if (ScheduledRideBadge.isScheduled(rideInfo)) ...[
|
if (ScheduledRideBadge.isScheduled(rideInfo)) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
/// شارة «رحلة مؤجلة».
|
/// شارة «رحلة مجدولة».
|
||||||
///
|
///
|
||||||
/// السائق يجب أن يعرف أن هذه ليست رحلة لحظية بل موعد مضبوط: الراكب
|
/// السائق يجب أن يعرف أن هذه ليست رحلة لحظية بل موعد مضبوط: الراكب
|
||||||
/// ينتظره في وقت محدد، والتأخر عليه يختلف عن التأخر على طلب فوري.
|
/// ينتظره في وقت محدد، والتأخر عليه يختلف عن التأخر على طلب فوري.
|
||||||
@@ -73,7 +73,7 @@ class ScheduledRideBadge extends StatelessWidget {
|
|||||||
Icon(Icons.schedule, color: color, size: compact ? 14 : 16),
|
Icon(Icons.schedule, color: color, size: compact ? 14 : 16),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
t.isEmpty ? 'رحلة مؤجلة'.tr : '${'رحلة مؤجلة'.tr} · $t',
|
t.isEmpty ? 'رحلة مجدولة'.tr : '${'رحلة مجدولة'.tr} · $t',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: color,
|
color: color,
|
||||||
fontSize: compact ? 11 : 13,
|
fontSize: compact ? 11 : 13,
|
||||||
|
|||||||
+36
-2
@@ -581,6 +581,35 @@ class TripOverlayService : Service() {
|
|||||||
}
|
}
|
||||||
detailsContainer.addView(offerBadge)
|
detailsContainer.addView(offerBadge)
|
||||||
|
|
||||||
|
// شارة الرحلة المجدولة — تظهر مع شارة نوع الطلب لا بدلاً منها:
|
||||||
|
// الأولى تصف الرحلة والثانية تصف العرض، ومعلومتان مختلفتان.
|
||||||
|
if (trip.isScheduled) {
|
||||||
|
val scheduledBadge =
|
||||||
|
TextView(ctx).apply {
|
||||||
|
val color = Color.parseColor("#6A5ACD")
|
||||||
|
text = if (trip.scheduledAt.isEmpty()) "🗓️ رحلة مجدولة"
|
||||||
|
else "🗓️ رحلة مجدولة · " + trip.scheduledAt
|
||||||
|
gravity = Gravity.CENTER
|
||||||
|
textSize = 13f
|
||||||
|
typeface = android.graphics.Typeface.DEFAULT_BOLD
|
||||||
|
setPadding(24, 12, 24, 12)
|
||||||
|
setTextColor(color)
|
||||||
|
background =
|
||||||
|
android.graphics.drawable.GradientDrawable().apply {
|
||||||
|
setColor(Color.parseColor("#F0EDFB"))
|
||||||
|
cornerRadius = 40f
|
||||||
|
setStroke(2, color)
|
||||||
|
}
|
||||||
|
layoutParams =
|
||||||
|
LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||||
|
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||||
|
)
|
||||||
|
.apply { bottomMargin = 24 }
|
||||||
|
}
|
||||||
|
detailsContainer.addView(scheduledBadge)
|
||||||
|
}
|
||||||
|
|
||||||
// ── 4. شريط الوقت ──
|
// ── 4. شريط الوقت ──
|
||||||
val countdownLabel =
|
val countdownLabel =
|
||||||
TextView(ctx).apply {
|
TextView(ctx).apply {
|
||||||
@@ -925,7 +954,9 @@ class TripOverlayService : Service() {
|
|||||||
pickupLat = obj.getDouble("pickupLat"),
|
pickupLat = obj.getDouble("pickupLat"),
|
||||||
pickupLng = obj.getDouble("pickupLng"),
|
pickupLng = obj.getDouble("pickupLng"),
|
||||||
offerType = obj.optString("offerType", ""),
|
offerType = obj.optString("offerType", ""),
|
||||||
offerExpiresIn = obj.optInt("offerExpiresIn", 0)
|
offerExpiresIn = obj.optInt("offerExpiresIn", 0),
|
||||||
|
isScheduled = obj.optBoolean("isScheduled", false),
|
||||||
|
scheduledAt = obj.optString("scheduledAt", "")
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
null
|
null
|
||||||
@@ -944,6 +975,9 @@ class TripOverlayService : Service() {
|
|||||||
val pickupLng: Double,
|
val pickupLng: Double,
|
||||||
// "exclusive" خلال السبق الحصري، "public" بعده، وفارغ في البثّ الحر
|
// "exclusive" خلال السبق الحصري، "public" بعده، وفارغ في البثّ الحر
|
||||||
val offerType: String = "",
|
val offerType: String = "",
|
||||||
val offerExpiresIn: Int = 0
|
val offerExpiresIn: Int = 0,
|
||||||
|
// رحلة محجوزة مسبقاً — موعد مضبوط لا طلب فوري
|
||||||
|
val isScheduled: Boolean = false,
|
||||||
|
val scheduledAt: String = ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ class TripData {
|
|||||||
/// من نفسها — النافذة لا تُغلق، فالسائق يظل قادراً على القبول.
|
/// من نفسها — النافذة لا تُغلق، فالسائق يظل قادراً على القبول.
|
||||||
final int offerExpiresIn;
|
final int offerExpiresIn;
|
||||||
|
|
||||||
|
/// رحلة محجوزة مسبقاً وموعدها. السائق يجب أن يعرف أنها ليست طلباً فورياً.
|
||||||
|
final bool isScheduled;
|
||||||
|
final String scheduledAt;
|
||||||
|
|
||||||
TripData({
|
TripData({
|
||||||
required this.tripId,
|
required this.tripId,
|
||||||
required this.passengerName,
|
required this.passengerName,
|
||||||
@@ -38,6 +42,8 @@ class TripData {
|
|||||||
this.passengerAvatarUrl,
|
this.passengerAvatarUrl,
|
||||||
this.offerType,
|
this.offerType,
|
||||||
this.offerExpiresIn = 0,
|
this.offerExpiresIn = 0,
|
||||||
|
this.isScheduled = false,
|
||||||
|
this.scheduledAt = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
Map<String, dynamic> toMap() => {
|
Map<String, dynamic> toMap() => {
|
||||||
@@ -53,6 +59,8 @@ class TripData {
|
|||||||
'passengerAvatarUrl': passengerAvatarUrl ?? '',
|
'passengerAvatarUrl': passengerAvatarUrl ?? '',
|
||||||
'offerType': offerType ?? '',
|
'offerType': offerType ?? '',
|
||||||
'offerExpiresIn': offerExpiresIn,
|
'offerExpiresIn': offerExpiresIn,
|
||||||
|
'isScheduled': isScheduled,
|
||||||
|
'scheduledAt': scheduledAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
factory TripData.fromMap(Map<String, dynamic> map) => TripData(
|
factory TripData.fromMap(Map<String, dynamic> map) => TripData(
|
||||||
@@ -67,6 +75,8 @@ class TripData {
|
|||||||
? null
|
? null
|
||||||
: map['offerType'].toString(),
|
: map['offerType'].toString(),
|
||||||
offerExpiresIn: int.tryParse('${map['offerExpiresIn'] ?? 0}') ?? 0,
|
offerExpiresIn: int.tryParse('${map['offerExpiresIn'] ?? 0}') ?? 0,
|
||||||
|
isScheduled: map['isScheduled'] == true || '${map['isScheduled']}' == '1',
|
||||||
|
scheduledAt: (map['scheduledAt'] ?? '').toString(),
|
||||||
pickupLat: (map['pickupLat'] ?? 0.0).toDouble(),
|
pickupLat: (map['pickupLat'] ?? 0.0).toDouble(),
|
||||||
pickupLng: (map['pickupLng'] ?? 0.0).toDouble(),
|
pickupLng: (map['pickupLng'] ?? 0.0).toDouble(),
|
||||||
passengerAvatarUrl: map['passengerAvatarUrl'],
|
passengerAvatarUrl: map['passengerAvatarUrl'],
|
||||||
|
|||||||
@@ -416,6 +416,11 @@ class AppLink {
|
|||||||
/// needsReview في getRideStatusFromStartApp محصور بنافذة ساعة مقصودة
|
/// needsReview في getRideStatusFromStartApp محصور بنافذة ساعة مقصودة
|
||||||
/// لاستئناف رحلة قائمة، فمن يغلق التطبيق بعد نزوله لا يُطلب منه تقييم.
|
/// لاستئناف رحلة قائمة، فمن يغلق التطبيق بعد نزوله لا يُطلب منه تقييم.
|
||||||
static String get pendingRating => "$server/ride/rate/pending_rating.php";
|
static String get pendingRating => "$server/ride/rate/pending_rating.php";
|
||||||
|
|
||||||
|
// ── الحجز المسبق ──
|
||||||
|
static String get scheduledAdd => "$server/ride/scheduled/add.php";
|
||||||
|
static String get scheduledList => "$server/ride/scheduled/list.php";
|
||||||
|
static String get scheduledCancel => "$server/ride/scheduled/cancel.php";
|
||||||
static String get getDriverRate => "$server/ride/rate/getDriverRate.php";
|
static String get getDriverRate => "$server/ride/rate/getDriverRate.php";
|
||||||
static String get getPassengerRate =>
|
static String get getPassengerRate =>
|
||||||
"$server/ride/rate/getPassengerRate.php";
|
"$server/ride/rate/getPassengerRate.php";
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import '../../constant/links.dart';
|
||||||
|
import '../../views/widgets/error_snakbar.dart';
|
||||||
|
import '../functions/crud.dart';
|
||||||
|
|
||||||
|
/// حجوزات الراكب المسبقة.
|
||||||
|
///
|
||||||
|
/// الحجز ليس رحلة: يسجَّل كنيّة سفر، ويحوّله الخادم لرحلة فعلية قبل
|
||||||
|
/// الموعد بهامش يشتقّه من المسافة. لذلك لا شاشة تتبّع هنا — الرحلة
|
||||||
|
/// تظهر في الخريطة كأي رحلة حين يحين وقتها.
|
||||||
|
class ScheduledRidesController extends GetxController {
|
||||||
|
final CRUD _crud = CRUD();
|
||||||
|
|
||||||
|
final bookings = <Map<String, dynamic>>[].obs;
|
||||||
|
final isLoading = false.obs;
|
||||||
|
final isSaving = false.obs;
|
||||||
|
|
||||||
|
/// حدود الخادم — مكرّرة هنا للتحقق المبكر فقط. الخادم هو من يفرضها.
|
||||||
|
static const int minLeadMinutes = 30;
|
||||||
|
static const int maxDaysAhead = 2;
|
||||||
|
|
||||||
|
DateTime get earliest =>
|
||||||
|
DateTime.now().add(const Duration(minutes: minLeadMinutes));
|
||||||
|
DateTime get latest => DateTime.now().add(const Duration(days: maxDaysAhead));
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
fetch();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetch() async {
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
final res = await _crud.post(
|
||||||
|
link: AppLink.scheduledList,
|
||||||
|
payload: {'scope': 'upcoming'},
|
||||||
|
);
|
||||||
|
final data = _extract(res);
|
||||||
|
final list = (data?['bookings'] as List?) ?? [];
|
||||||
|
bookings.assignAll(
|
||||||
|
list.map((e) => Map<String, dynamic>.from(e as Map)).toList());
|
||||||
|
} catch (e) {
|
||||||
|
mySnackbarError('تعذّر جلب الحجوزات');
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ينشئ حجزاً. يرجع true عند النجاح.
|
||||||
|
///
|
||||||
|
/// [when] موعد الانطلاق كما اختاره الراكب.
|
||||||
|
Future<bool> create({
|
||||||
|
required String startLocation,
|
||||||
|
required String endLocation,
|
||||||
|
required String startName,
|
||||||
|
required String endName,
|
||||||
|
required String carType,
|
||||||
|
required double distanceKm,
|
||||||
|
required int durationSeconds,
|
||||||
|
required double estimatedPrice,
|
||||||
|
required DateTime when,
|
||||||
|
String? note,
|
||||||
|
}) async {
|
||||||
|
if (isSaving.value) return false;
|
||||||
|
|
||||||
|
// فحص محلي للراحة — يوفّر رحلة للخادم ويعطي رسالة أوضح.
|
||||||
|
if (when.isBefore(earliest)) {
|
||||||
|
mySnackbarError('اختر موعداً بعد $minLeadMinutes دقيقة على الأقل');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (when.isAfter(latest)) {
|
||||||
|
mySnackbarError('يمكنك الحجز حتى $maxDaysAhead يومين مقدماً فقط');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSaving.value = true;
|
||||||
|
try {
|
||||||
|
final res = await _crud.post(
|
||||||
|
link: AppLink.scheduledAdd,
|
||||||
|
payload: {
|
||||||
|
'start_location': startLocation,
|
||||||
|
'end_location': endLocation,
|
||||||
|
'start_name': startName,
|
||||||
|
'end_name': endName,
|
||||||
|
'car_type': carType,
|
||||||
|
'distance': distanceKm.toString(),
|
||||||
|
'duration': durationSeconds.toString(),
|
||||||
|
'estimated_price': estimatedPrice.toString(),
|
||||||
|
'scheduled_at': _fmt(when),
|
||||||
|
if (note != null && note.trim().isNotEmpty) 'note': note.trim(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final data = _extract(res);
|
||||||
|
if (data == null) return false;
|
||||||
|
|
||||||
|
await fetch();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
mySnackbarError('تعذّر الاتصال بالخادم');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> cancel(int id) async {
|
||||||
|
try {
|
||||||
|
final res = await _crud.post(
|
||||||
|
link: AppLink.scheduledCancel,
|
||||||
|
payload: {'id': id.toString()},
|
||||||
|
);
|
||||||
|
if (_extract(res) == null) return;
|
||||||
|
mySnackbarSuccess('أُلغي الحجز');
|
||||||
|
await fetch();
|
||||||
|
} catch (e) {
|
||||||
|
mySnackbarError('تعذّر إلغاء الحجز');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _fmt(DateTime d) =>
|
||||||
|
'${d.year}-${_2(d.month)}-${_2(d.day)} ${_2(d.hour)}:${_2(d.minute)}:00';
|
||||||
|
|
||||||
|
String _2(int n) => n.toString().padLeft(2, '0');
|
||||||
|
|
||||||
|
Map<String, dynamic>? _extract(dynamic res) {
|
||||||
|
final decoded = res is String ? jsonDecode(res) : res;
|
||||||
|
if (decoded is! Map) {
|
||||||
|
mySnackbarError('رد غير مفهوم من الخادم');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (decoded['status'] != 'success') {
|
||||||
|
// رسالة الخادم أدقّ من أي نص عام: تفرّق بين "لديك حجز قريب" و
|
||||||
|
// "الموعد خارج المدى المسموح".
|
||||||
|
mySnackbarError(decoded['message']?.toString() ?? 'فشلت العملية');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final data = decoded['message'] ?? decoded['data'];
|
||||||
|
return data is Map ? Map<String, dynamic>.from(data) : <String, dynamic>{};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import '../../controller/scheduled/scheduled_rides_controller.dart';
|
||||||
|
|
||||||
|
/// شاشة حجوزات الراكب المسبقة.
|
||||||
|
///
|
||||||
|
/// تعرض القادم فقط: الحجز الفائت لا يفيد، والراكب يفتح الشاشة ليرى ما
|
||||||
|
/// ينتظره لا ليتصفّح أرشيفه.
|
||||||
|
class ScheduledRidesPage extends StatelessWidget {
|
||||||
|
const ScheduledRidesPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final c = Get.put(ScheduledRidesController());
|
||||||
|
|
||||||
|
return Directionality(
|
||||||
|
textDirection: TextDirection.rtl,
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(title: Text('رحلاتي المحجوزة'.tr)),
|
||||||
|
body: Obx(() {
|
||||||
|
if (c.isLoading.value && c.bookings.isEmpty) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (c.bookings.isEmpty) {
|
||||||
|
return _Empty(maxDays: ScheduledRidesController.maxDaysAhead);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: c.fetch,
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: c.bookings.length,
|
||||||
|
itemBuilder: (_, i) => _BookingCard(b: c.bookings[i], c: c),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Empty extends StatelessWidget {
|
||||||
|
const _Empty({required this.maxDays});
|
||||||
|
final int maxDays;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 80),
|
||||||
|
Icon(Icons.event_available_outlined,
|
||||||
|
size: 64, color: cs.primary.withOpacity(0.4)),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text('لا حجوزات قادمة'.tr,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
'احجز رحلتك للمطار أو موعدك الطبي أو دوامك الصباحي'
|
||||||
|
' حتى $maxDays يومين مقدماً، ونرسل لك سائقاً في وقته.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 13, height: 1.6, color: cs.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BookingCard extends StatelessWidget {
|
||||||
|
const _BookingCard({required this.b, required this.c});
|
||||||
|
final Map<String, dynamic> b;
|
||||||
|
final ScheduledRidesController c;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final when = DateTime.tryParse('${b['scheduled_at']}');
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.schedule, size: 18, color: Color(0xFF6A5ACD)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
when == null ? '${b['scheduled_at']}' : _when(when),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold, fontSize: 15),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (when != null) _Countdown(when: when),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(height: 20),
|
||||||
|
_row(context, '🟢', b['start_name'] ?? 'موقع الانطلاق'),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_row(context, '🔴', b['end_name'] ?? 'الوجهة'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text('${b['car_type'] ?? ''}',
|
||||||
|
style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant)),
|
||||||
|
const Spacer(),
|
||||||
|
// نصرّح بأن السعر تقديري: التسعير النهائي لحظة إنشاء
|
||||||
|
// الرحلة، فرقم اليوم قد لا يطابق رقم الغد.
|
||||||
|
Text(
|
||||||
|
'تقديري: ${b['estimated_price'] ?? '—'}',
|
||||||
|
style: TextStyle(fontSize: 12, color: cs.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: () => _confirmCancel(context),
|
||||||
|
icon: Icon(Icons.close, size: 16, color: cs.error),
|
||||||
|
label: Text('إلغاء الحجز'.tr,
|
||||||
|
style: TextStyle(color: cs.error, fontSize: 13)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _confirmCancel(BuildContext context) {
|
||||||
|
Get.defaultDialog(
|
||||||
|
title: 'إلغاء الحجز'.tr,
|
||||||
|
// بلا رسم: لا سائق قُبِل ولا أحد تحرّك. نقولها صراحةً ليطمئن الراكب.
|
||||||
|
middleText: 'سيُلغى الحجز بلا أي رسوم — لم يُسند سائق بعد.'.tr,
|
||||||
|
textConfirm: 'تأكيد'.tr,
|
||||||
|
textCancel: 'تراجع'.tr,
|
||||||
|
onConfirm: () {
|
||||||
|
Get.back();
|
||||||
|
final id = int.tryParse('${b['id']}');
|
||||||
|
if (id != null) c.cancel(id);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _row(BuildContext context, String dot, dynamic text) => Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(dot, style: const TextStyle(fontSize: 12)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text('$text',
|
||||||
|
style: const TextStyle(fontSize: 13, height: 1.4)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
String _when(DateTime d) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final isToday =
|
||||||
|
d.year == now.year && d.month == now.month && d.day == now.day;
|
||||||
|
final tomorrow = now.add(const Duration(days: 1));
|
||||||
|
final isTomorrow = d.year == tomorrow.year &&
|
||||||
|
d.month == tomorrow.month &&
|
||||||
|
d.day == tomorrow.day;
|
||||||
|
|
||||||
|
final hh = d.hour.toString().padLeft(2, '0');
|
||||||
|
final mm = d.minute.toString().padLeft(2, '0');
|
||||||
|
|
||||||
|
if (isToday) return 'اليوم $hh:$mm';
|
||||||
|
if (isTomorrow) return 'غداً $hh:$mm';
|
||||||
|
return '${d.day}/${d.month} — $hh:$mm';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// كم تبقّى للموعد. يجعل الحجز محسوساً بدل تاريخ جامد.
|
||||||
|
class _Countdown extends StatelessWidget {
|
||||||
|
const _Countdown({required this.when});
|
||||||
|
final DateTime when;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final diff = when.difference(DateTime.now());
|
||||||
|
if (diff.isNegative) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final label = diff.inHours >= 1
|
||||||
|
? 'بعد ${diff.inHours} ساعة'
|
||||||
|
: 'بعد ${diff.inMinutes} دقيقة';
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF6A5ACD).withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(label,
|
||||||
|
style: const TextStyle(fontSize: 11, color: Color(0xFF6A5ACD))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user