Update: 2026-08-07 06:00:56
This commit is contained in:
@@ -416,6 +416,11 @@ class AppLink {
|
||||
/// needsReview في getRideStatusFromStartApp محصور بنافذة ساعة مقصودة
|
||||
/// لاستئناف رحلة قائمة، فمن يغلق التطبيق بعد نزوله لا يُطلب منه تقييم.
|
||||
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 getPassengerRate =>
|
||||
"$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