1214 lines
43 KiB
Dart
1214 lines
43 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:flutter_map/flutter_map.dart';
|
|
import 'package:latlong2/latlong.dart';
|
|
import 'package:sefer_admin1/constant/links.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
import '../../../controller/functions/crud.dart';
|
|
import '../../../constant/box_name.dart';
|
|
import '../../../main.dart';
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 1. MODEL
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
class RideDashboardModel {
|
|
final String rideId;
|
|
final String status;
|
|
final String startLocation;
|
|
final String endLocation;
|
|
final String date;
|
|
final String time;
|
|
final String price;
|
|
final String distance;
|
|
final String driverId;
|
|
final String driverName;
|
|
final String driverPhone;
|
|
final String driverCompletedCount;
|
|
final String driverCanceledCount;
|
|
final String passengerName;
|
|
final String passengerPhone;
|
|
final String passengerCompletedCount;
|
|
final String cancelReason;
|
|
|
|
RideDashboardModel({
|
|
required this.rideId,
|
|
required this.status,
|
|
required this.startLocation,
|
|
required this.endLocation,
|
|
required this.date,
|
|
required this.time,
|
|
required this.price,
|
|
required this.distance,
|
|
required this.driverId,
|
|
required this.driverName,
|
|
required this.driverPhone,
|
|
required this.driverCompletedCount,
|
|
required this.driverCanceledCount,
|
|
required this.passengerName,
|
|
required this.passengerPhone,
|
|
required this.passengerCompletedCount,
|
|
required this.cancelReason,
|
|
});
|
|
|
|
factory RideDashboardModel.fromJson(Map<String, dynamic> json) {
|
|
return RideDashboardModel(
|
|
rideId: json['id'].toString(),
|
|
status: json['status'] ?? '',
|
|
startLocation: json['start_location'] ?? '',
|
|
endLocation: json['end_location'] ?? '',
|
|
date: json['date'] ?? '',
|
|
time: json['time'] ?? '',
|
|
price: json['price']?.toString() ?? '0',
|
|
distance: json['distance']?.toString() ?? '0',
|
|
driverId: json['driver_id'].toString(),
|
|
driverName: json['driver_full_name'] ?? 'غير معروف',
|
|
driverPhone: json['d_phone'] ?? '',
|
|
driverCompletedCount: (json['d_completed'] ?? 0).toString(),
|
|
driverCanceledCount: (json['d_canceled'] ?? 0).toString(),
|
|
passengerName: json['passenger_full_name'] ?? 'غير معروف',
|
|
passengerPhone: json['p_phone'] ?? '',
|
|
passengerCompletedCount: (json['p_completed'] ?? 0).toString(),
|
|
cancelReason: json['cancel_reason'] ?? '',
|
|
);
|
|
}
|
|
|
|
LatLng? getStartLatLng() {
|
|
try {
|
|
var parts = startLocation.split(',');
|
|
return LatLng(double.parse(parts[0]), double.parse(parts[1]));
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
LatLng? getEndLatLng() {
|
|
try {
|
|
var parts = endLocation.split(',');
|
|
return LatLng(double.parse(parts[0]), double.parse(parts[1]));
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 2. CONTROLLER
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
class RidesListController extends GetxController {
|
|
var isLoading = false.obs;
|
|
var allRidesList = <RideDashboardModel>[];
|
|
var displayedRides = <RideDashboardModel>[].obs;
|
|
|
|
TextEditingController searchController = TextEditingController();
|
|
String currentStatus = 'Begin';
|
|
|
|
String myPhone = box.read(BoxName.adminPhone)?.toString() ?? '';
|
|
|
|
bool get isSuperAdmin {
|
|
return myPhone == '963942542053' || myPhone == '963992952235';
|
|
}
|
|
|
|
final String apiUrl = "${AppLink.server}/Admin/rides/get_rides_by_status.php";
|
|
|
|
// ═══ Statistics ═══
|
|
var beginCount = 0.obs;
|
|
var newCount = 0.obs;
|
|
var completedCount = 0.obs;
|
|
var canceledCount = 0.obs;
|
|
var totalRevenue = 0.0.obs;
|
|
var totalDistance = 0.0.obs;
|
|
|
|
@override
|
|
void onInit() {
|
|
super.onInit();
|
|
fetchRides();
|
|
}
|
|
|
|
void changeTab(String status) {
|
|
currentStatus = status;
|
|
searchController.clear();
|
|
fetchRides();
|
|
}
|
|
|
|
void filterRides(String query) {
|
|
if (query.isEmpty) {
|
|
displayedRides.value = allRidesList;
|
|
} else {
|
|
displayedRides.value = allRidesList.where((ride) {
|
|
return ride.driverPhone.contains(query) ||
|
|
ride.passengerPhone.contains(query) ||
|
|
ride.driverName.toLowerCase().contains(query.toLowerCase()) ||
|
|
ride.passengerName.toLowerCase().contains(query.toLowerCase()) ||
|
|
ride.rideId.contains(query);
|
|
}).toList();
|
|
}
|
|
}
|
|
|
|
void calculateStatistics() {
|
|
beginCount.value = allRidesList.where((r) => r.status == 'Begin').length;
|
|
newCount.value = allRidesList.where((r) => r.status == 'New').length;
|
|
completedCount.value =
|
|
allRidesList.where((r) => r.status == 'Finished').length;
|
|
canceledCount.value =
|
|
allRidesList.where((r) => r.status.contains('Cancel')).length;
|
|
|
|
totalRevenue.value = allRidesList.fold(
|
|
0.0, (sum, ride) => sum + (double.tryParse(ride.price) ?? 0.0));
|
|
totalDistance.value = allRidesList.fold(
|
|
0.0, (sum, ride) => sum + (double.tryParse(ride.distance) ?? 0.0));
|
|
}
|
|
|
|
Future<void> fetchRides() async {
|
|
isLoading.value = true;
|
|
allRidesList.clear();
|
|
displayedRides.clear();
|
|
try {
|
|
var response =
|
|
await CRUD().post(link: apiUrl, payload: {"status": currentStatus});
|
|
if (response != 'failure' && response['status'] == 'success') {
|
|
List<dynamic> data = [];
|
|
if (response['message'] is List) {
|
|
data = response['message'];
|
|
} else if (response['data'] is List) {
|
|
data = response['data'];
|
|
}
|
|
|
|
allRidesList = data.map((e) => RideDashboardModel.fromJson(e)).toList();
|
|
displayedRides.value = allRidesList;
|
|
calculateStatistics();
|
|
}
|
|
} catch (e) {
|
|
debugPrint("Error fetching rides: $e");
|
|
} finally {
|
|
isLoading.value = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 3. MAIN DASHBOARD SCREEN (ADVANCED SLIVER IMPLEMENTATION)
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
class RidesDashboardScreen extends StatefulWidget {
|
|
const RidesDashboardScreen({super.key});
|
|
|
|
@override
|
|
State<RidesDashboardScreen> createState() => _RidesDashboardScreenState();
|
|
}
|
|
|
|
class _RidesDashboardScreenState extends State<RidesDashboardScreen>
|
|
with SingleTickerProviderStateMixin {
|
|
late final RidesListController controller;
|
|
late TabController _tabController;
|
|
|
|
// 🎨 الألوان العصرية
|
|
final Color bgColor = const Color(0xFFF4F7FE);
|
|
final Color primaryColor = const Color(0xFF4318FF);
|
|
final Color textPrimary = const Color(0xFF2B3674);
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
controller = Get.put(RidesListController());
|
|
_tabController = TabController(length: 4, vsync: this);
|
|
_tabController.addListener(_handleTabChange);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tabController.removeListener(_handleTabChange);
|
|
_tabController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _handleTabChange() {
|
|
if (_tabController.indexIsChanging) return;
|
|
List<String> statuses = ['Begin', 'New', 'Completed', 'Canceled'];
|
|
controller.changeTab(statuses[_tabController.index]);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: bgColor,
|
|
body: SafeArea(
|
|
top: false, // نسمح للـ AppBar بالتمدد لأعلى الشاشة
|
|
child: CustomScrollView(
|
|
physics: const BouncingScrollPhysics(),
|
|
slivers: [
|
|
// 1. Sliver AppBar (المتحرك الذكي الذي يصغر عند التمرير)
|
|
SliverAppBar(
|
|
pinned: true,
|
|
floating: false,
|
|
expandedHeight: 310.0, // ارتفاع الجزء العلوي بالكامل
|
|
backgroundColor: primaryColor,
|
|
elevation: 4,
|
|
shadowColor: primaryColor.withOpacity(0.4),
|
|
iconTheme: const IconThemeData(color: Colors.white),
|
|
centerTitle: true,
|
|
title: const Text(
|
|
'إدارة الرحلات',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 20,
|
|
),
|
|
),
|
|
actions: [
|
|
Container(
|
|
margin:
|
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withOpacity(0.2),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
alignment: Alignment.center,
|
|
child: const Text(
|
|
'اليوم',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
flexibleSpace: FlexibleSpaceBar(
|
|
background: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
colors: [primaryColor, primaryColor.withOpacity(0.8)],
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
),
|
|
),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
// الإحصائيات تختفي عند التمرير لأعلى
|
|
_buildStatisticsSection(),
|
|
const SizedBox(height: 12),
|
|
// شريط البحث يختفي عند التمرير لأعلى
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: _buildSearchBar(),
|
|
),
|
|
const SizedBox(height: 60), // مساحة للـ TabBar بالأسفل
|
|
],
|
|
),
|
|
),
|
|
),
|
|
// TabBar يثبت دائماً أسفل الـ AppBar عند التمرير
|
|
bottom: PreferredSize(
|
|
preferredSize: const Size.fromHeight(54),
|
|
child: Container(
|
|
decoration: const BoxDecoration(
|
|
color:
|
|
Color(0xFFF4F7FE), // لون خلفية التطبيق ليظهر بشكل مدمج
|
|
borderRadius:
|
|
BorderRadius.vertical(top: Radius.circular(24)),
|
|
),
|
|
child: TabBar(
|
|
controller: _tabController,
|
|
isScrollable: true,
|
|
labelColor: primaryColor,
|
|
unselectedLabelColor: Colors.grey,
|
|
indicatorColor: primaryColor,
|
|
indicatorWeight: 3,
|
|
labelStyle: const TextStyle(
|
|
fontWeight: FontWeight.bold, fontSize: 14),
|
|
tabAlignment: TabAlignment.center,
|
|
dividerColor: Colors.transparent,
|
|
tabs: const [
|
|
Tab(
|
|
icon: Icon(Icons.directions_car_rounded),
|
|
text: 'جارية'),
|
|
Tab(
|
|
icon: Icon(Icons.new_releases_rounded),
|
|
text: 'جديدة'),
|
|
Tab(
|
|
icon: Icon(Icons.check_circle_rounded),
|
|
text: 'مكتملة'),
|
|
Tab(icon: Icon(Icons.cancel_rounded), text: 'ملغاة'),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// 2. قائمة الرحلات (Sliver List)
|
|
SliverPadding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
sliver: Obx(() {
|
|
if (controller.isLoading.value) {
|
|
return const SliverFillRemaining(
|
|
hasScrollBody: false,
|
|
child: Center(child: CircularProgressIndicator()),
|
|
);
|
|
}
|
|
|
|
if (controller.displayedRides.isEmpty) {
|
|
return SliverFillRemaining(
|
|
hasScrollBody: false,
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.inbox_rounded,
|
|
size: 80, color: Colors.grey[400]),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'لا توجد رحلات في هذا القسم',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
color: Colors.grey[600],
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return SliverList(
|
|
delegate: SliverChildBuilderDelegate(
|
|
(context, index) {
|
|
final ride = controller.displayedRides[index];
|
|
return _buildRideCardCompact(
|
|
ride, controller.isSuperAdmin);
|
|
},
|
|
childCount: controller.displayedRides.length,
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// --- Components ---
|
|
|
|
Widget _buildStatisticsSection() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
|
child: Text(
|
|
'نظرة عامة',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.white.withOpacity(0.8),
|
|
),
|
|
),
|
|
),
|
|
SingleChildScrollView(
|
|
physics: const BouncingScrollPhysics(),
|
|
scrollDirection: Axis.horizontal,
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Row(
|
|
children: [
|
|
Obx(() => _buildStatCard(
|
|
'جارية',
|
|
controller.beginCount.toString(),
|
|
Icons.directions_car_rounded,
|
|
const Color(0xFF10B981))),
|
|
Obx(() => _buildStatCard('جديدة', controller.newCount.toString(),
|
|
Icons.new_releases_rounded, const Color(0xFF3B82F6))),
|
|
Obx(() => _buildStatCard(
|
|
'مكتملة',
|
|
controller.completedCount.toString(),
|
|
Icons.check_circle_rounded,
|
|
const Color(0xFF14B8A6))),
|
|
Obx(() => _buildStatCard(
|
|
'ملغاة',
|
|
controller.canceledCount.toString(),
|
|
Icons.cancel_rounded,
|
|
const Color(0xFFEF4444))),
|
|
Obx(() => _buildStatCard(
|
|
'الإيرادات',
|
|
'${controller.totalRevenue.value.toStringAsFixed(0)}',
|
|
Icons.payments_rounded,
|
|
const Color(0xFFF59E0B))),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildStatCard(
|
|
String label, String value, IconData icon, Color iconColor) {
|
|
return Container(
|
|
width: 105,
|
|
margin: const EdgeInsets.only(left: 10),
|
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(16),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.1),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 4),
|
|
)
|
|
],
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, color: iconColor, size: 24),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
value,
|
|
style: TextStyle(
|
|
fontSize: 18, fontWeight: FontWeight.bold, color: textPrimary),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Colors.grey[600],
|
|
fontWeight: FontWeight.bold),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSearchBar() {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(16),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.05),
|
|
blurRadius: 8,
|
|
offset: const Offset(0, 4),
|
|
)
|
|
],
|
|
),
|
|
child: TextField(
|
|
controller: controller.searchController,
|
|
onChanged: (val) => controller.filterRides(val),
|
|
style: TextStyle(color: textPrimary, fontWeight: FontWeight.w600),
|
|
decoration: InputDecoration(
|
|
hintText: 'ابحث عن رقم الرحلة، السائق، أو الراكب...',
|
|
hintStyle: TextStyle(color: Colors.grey[400], fontSize: 13),
|
|
prefixIcon: Icon(Icons.search_rounded, color: primaryColor),
|
|
suffixIcon: controller.searchController.text.isNotEmpty
|
|
? IconButton(
|
|
icon: const Icon(Icons.close_rounded, color: Colors.grey),
|
|
onPressed: () {
|
|
controller.searchController.clear();
|
|
controller.filterRides('');
|
|
},
|
|
)
|
|
: null,
|
|
border: InputBorder.none,
|
|
contentPadding:
|
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// تصميم بطاقة الرحلة (المدمج والمنظم - Slim Design)
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
Widget _buildRideCardCompact(RideDashboardModel ride, bool isAdmin) {
|
|
Color statusColor = _getStatusColor(ride.status);
|
|
String statusText = _getStatusText(ride.status);
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(20),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.04),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
border: Border.all(color: Colors.grey.withOpacity(0.1)),
|
|
),
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(20),
|
|
onTap: () => Get.to(
|
|
() => RideMapMonitorScreen(ride: ride, isAdmin: isAdmin),
|
|
transition: Transition.cupertino,
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// 1. Header (ID + Status)
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(6),
|
|
decoration: BoxDecoration(
|
|
color: primaryColor.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(Icons.confirmation_number_rounded,
|
|
color: primaryColor, size: 18),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'رحلة #${ride.rideId}',
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.bold,
|
|
color: textPrimary),
|
|
),
|
|
Text(
|
|
'${ride.date} • ${ride.time}',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Colors.grey[500],
|
|
fontWeight: FontWeight.w600),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 10, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: statusColor.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Text(
|
|
statusText,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.bold,
|
|
color: statusColor),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 12),
|
|
child: Divider(height: 1),
|
|
),
|
|
|
|
// 2. Locations (Timeline style)
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Column(
|
|
children: [
|
|
const Icon(Icons.my_location_rounded,
|
|
size: 14, color: Color(0xFF10B981)),
|
|
Container(
|
|
width: 2,
|
|
height: 16,
|
|
color: Colors.grey.withOpacity(0.3),
|
|
margin: const EdgeInsets.symmetric(vertical: 2)),
|
|
const Icon(Icons.location_on_rounded,
|
|
size: 14, color: Color(0xFFEF4444)),
|
|
],
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
ride.startLocation,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.black87),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
ride.endLocation,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.black87),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// 3. Driver & Passenger (Slim Rows)
|
|
Container(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: bgColor,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
_buildSlimUserRow(
|
|
icon: Icons.local_taxi_rounded,
|
|
title: 'السائق',
|
|
name: ride.driverName,
|
|
phone: ride.driverPhone,
|
|
color: const Color(0xFF3B82F6),
|
|
isAdmin: isAdmin,
|
|
),
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 6),
|
|
child: Divider(height: 1, thickness: 0.5),
|
|
),
|
|
_buildSlimUserRow(
|
|
icon: Icons.person_rounded,
|
|
title: 'الراكب',
|
|
name: ride.passengerName,
|
|
phone: ride.passengerPhone,
|
|
color: const Color(0xFF8B5CF6),
|
|
isAdmin: isAdmin,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 14),
|
|
|
|
// 4. Information Chips
|
|
Row(
|
|
children: [
|
|
_buildInfoChip(
|
|
Icons.payments_rounded,
|
|
'${double.tryParse(ride.price)?.toStringAsFixed(0) ?? 0} ل.س',
|
|
const Color(0xFF10B981)),
|
|
const SizedBox(width: 8),
|
|
_buildInfoChip(
|
|
Icons.straighten_rounded,
|
|
'${double.tryParse(ride.distance)?.toStringAsFixed(1) ?? 0} كم',
|
|
const Color(0xFFF59E0B)),
|
|
const SizedBox(width: 8),
|
|
_buildInfoChip(
|
|
Icons.access_time_rounded,
|
|
ride.time.length > 5
|
|
? ride.time.substring(0, 5)
|
|
: ride.time,
|
|
const Color(0xFF3B82F6)),
|
|
],
|
|
),
|
|
|
|
// 5. Cancel Reason (If any)
|
|
if ((ride.status.contains('Cancel') ||
|
|
ride.status == 'TimeOut') &&
|
|
ride.cancelReason.isNotEmpty &&
|
|
ride.cancelReason != 'لا يوجد سبب') ...[
|
|
const SizedBox(height: 12),
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFEF4444).withOpacity(0.08),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color: const Color(0xFFEF4444).withOpacity(0.2)),
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Icon(Icons.info_outline_rounded,
|
|
size: 16, color: Color(0xFFEF4444)),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Text(
|
|
'السبب: ${ride.cancelReason}',
|
|
style: const TextStyle(
|
|
color: Color(0xFFB91C1C),
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// تصميم الصف النحيف للمستخدم (لتوفير المساحة)
|
|
Widget _buildSlimUserRow({
|
|
required IconData icon,
|
|
required String title,
|
|
required String name,
|
|
required String phone,
|
|
required Color color,
|
|
required bool isAdmin,
|
|
}) {
|
|
String displayPhone = phone;
|
|
if (!isAdmin && phone.length > 4) {
|
|
displayPhone =
|
|
phone.substring(phone.length - 4).padLeft(phone.length, '*');
|
|
}
|
|
|
|
return Row(
|
|
children: [
|
|
Icon(icon, size: 16, color: color),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
'$title:',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Colors.grey[600],
|
|
fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Text(
|
|
name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.black87),
|
|
),
|
|
),
|
|
Text(
|
|
displayPhone,
|
|
style: TextStyle(
|
|
fontSize: 11, color: Colors.grey[500], letterSpacing: 0.5),
|
|
),
|
|
if (isAdmin && phone.isNotEmpty) ...[
|
|
const SizedBox(width: 8),
|
|
GestureDetector(
|
|
onTap: () async {
|
|
String formattedPhone = phone;
|
|
if (!formattedPhone.startsWith('+'))
|
|
formattedPhone = '+$formattedPhone';
|
|
final Uri launchUri = Uri(scheme: 'tel', path: formattedPhone);
|
|
if (await canLaunchUrl(launchUri)) await launchUrl(launchUri);
|
|
},
|
|
child: Icon(Icons.call_rounded, size: 16, color: color),
|
|
),
|
|
]
|
|
],
|
|
);
|
|
}
|
|
|
|
// تصميم الرقاقة (Chip) للمعلومات السفلية
|
|
Widget _buildInfoChip(IconData icon, String text, Color color) {
|
|
return Expanded(
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(0.08),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(icon, size: 14, color: color),
|
|
const SizedBox(width: 4),
|
|
Flexible(
|
|
child: Text(
|
|
text,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 11, fontWeight: FontWeight.bold, color: color),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Helper Methods for Status
|
|
Color _getStatusColor(String status) {
|
|
if (status == 'Begin' || status == 'Arrived')
|
|
return const Color(0xFF10B981);
|
|
if (status == 'Finished') return const Color(0xFF14B8A6);
|
|
if (status.contains('Cancel') || status == 'TimeOut')
|
|
return const Color(0xFFEF4444);
|
|
if (status == 'New') return const Color(0xFF3B82F6);
|
|
return Colors.grey;
|
|
}
|
|
|
|
String _getStatusText(String status) {
|
|
if (status == 'Begin' || status == 'Arrived') return 'جارية';
|
|
if (status == 'Finished') return 'مكتملة';
|
|
if (status == 'CancelFromDriver' || status == 'CancelFromDriverAfterApply')
|
|
return 'ألغى السائق';
|
|
if (status == 'CancelFromPassenger') return 'ألغى الراكب';
|
|
if (status == 'TimeOut') return 'انتهى الوقت';
|
|
if (status == 'New') return 'جديدة';
|
|
return 'ملغاة';
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 5. MAP MONITOR SCREEN (Minor UI Polish)
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
class RideMapMonitorScreen extends StatefulWidget {
|
|
final RideDashboardModel ride;
|
|
final bool isAdmin;
|
|
|
|
const RideMapMonitorScreen({
|
|
super.key,
|
|
required this.ride,
|
|
required this.isAdmin,
|
|
});
|
|
|
|
@override
|
|
State<RideMapMonitorScreen> createState() => _RideMapMonitorScreenState();
|
|
}
|
|
|
|
class _RideMapMonitorScreenState extends State<RideMapMonitorScreen> {
|
|
final MapController mapController = MapController();
|
|
LatLng? startPos, endPos, driverPos;
|
|
Timer? _timer;
|
|
bool isFirstLoad = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
startPos = widget.ride.getStartLatLng();
|
|
endPos = widget.ride.getEndLatLng();
|
|
|
|
if (widget.ride.status == 'Begin' || widget.ride.status == 'Arrived') {
|
|
fetchDriverLocation();
|
|
_timer = Timer.periodic(
|
|
const Duration(seconds: 10),
|
|
(_) => fetchDriverLocation(),
|
|
);
|
|
}
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _fitBounds());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_timer?.cancel();
|
|
mapController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _fitBounds() {
|
|
List<LatLng> points = [];
|
|
if (startPos != null) points.add(startPos!);
|
|
if (endPos != null) points.add(endPos!);
|
|
if (driverPos != null) points.add(driverPos!);
|
|
|
|
if (points.isNotEmpty) {
|
|
try {
|
|
mapController.fitCamera(
|
|
CameraFit.bounds(
|
|
bounds: LatLngBounds.fromPoints(points),
|
|
padding: const EdgeInsets.all(100),
|
|
),
|
|
);
|
|
} catch (e) {}
|
|
}
|
|
}
|
|
|
|
Future<void> fetchDriverLocation() async {
|
|
String trackUrl = "${AppLink.server}/Admin/rides/get_driver_live_pos.php";
|
|
try {
|
|
var response = await CRUD().post(
|
|
link: trackUrl,
|
|
payload: {"driver_id": widget.ride.driverId},
|
|
);
|
|
|
|
if (response != 'failure') {
|
|
var d = response['message'];
|
|
setState(() {
|
|
driverPos = LatLng(
|
|
double.parse(d['latitude'].toString()),
|
|
double.parse(d['longitude'].toString()),
|
|
);
|
|
});
|
|
|
|
if (isFirstLoad) {
|
|
_fitBounds();
|
|
isFirstLoad = false;
|
|
}
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(
|
|
'تتبع الرحلة #${widget.ride.rideId}',
|
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
|
|
),
|
|
backgroundColor: Colors.white,
|
|
foregroundColor: const Color(0xFF2B3674),
|
|
elevation: 0,
|
|
centerTitle: true,
|
|
),
|
|
body: Stack(
|
|
children: [
|
|
// Map
|
|
FlutterMap(
|
|
mapController: mapController,
|
|
options: MapOptions(
|
|
initialCenter: startPos ?? const LatLng(33.513, 36.276),
|
|
initialZoom: 13,
|
|
),
|
|
children: [
|
|
TileLayer(
|
|
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
|
userAgentPackageName: 'com.tripz.app',
|
|
),
|
|
// Route Polyline
|
|
if (startPos != null && endPos != null)
|
|
PolylineLayer(
|
|
polylines: [
|
|
Polyline(
|
|
points: [startPos!, endPos!],
|
|
strokeWidth: 5,
|
|
color: const Color(0xFF4318FF).withOpacity(0.8),
|
|
),
|
|
],
|
|
),
|
|
// Markers
|
|
MarkerLayer(
|
|
markers: [
|
|
// Start Point
|
|
if (startPos != null)
|
|
Marker(
|
|
point: startPos!,
|
|
width: 40,
|
|
height: 40,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
shape: BoxShape.circle,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.2),
|
|
blurRadius: 5),
|
|
],
|
|
),
|
|
child: const Icon(Icons.flag_rounded,
|
|
color: Color(0xFF10B981), size: 24),
|
|
),
|
|
alignment: Alignment.topCenter,
|
|
),
|
|
// End Point
|
|
if (endPos != null)
|
|
Marker(
|
|
point: endPos!,
|
|
width: 40,
|
|
height: 40,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
shape: BoxShape.circle,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.2),
|
|
blurRadius: 5),
|
|
],
|
|
),
|
|
child: const Icon(Icons.location_on_rounded,
|
|
color: Color(0xFFEF4444), size: 24),
|
|
),
|
|
alignment: Alignment.topCenter,
|
|
),
|
|
// Driver Current Position
|
|
if (driverPos != null)
|
|
Marker(
|
|
point: driverPos!,
|
|
width: 50,
|
|
height: 50,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
shape: BoxShape.circle,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: const Color(0xFF3B82F6).withOpacity(0.3),
|
|
blurRadius: 8),
|
|
],
|
|
),
|
|
child: const Icon(Icons.directions_car_rounded,
|
|
color: Color(0xFF3B82F6), size: 28),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
|
|
// Info Panel (Floating)
|
|
Positioned(
|
|
bottom: 24,
|
|
left: 16,
|
|
right: 16,
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(24),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.1),
|
|
blurRadius: 20,
|
|
offset: const Offset(0, 10),
|
|
),
|
|
],
|
|
),
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Driver Info
|
|
_buildMapUserInfo(
|
|
icon: Icons.local_taxi_rounded,
|
|
title: 'السائق',
|
|
name: widget.ride.driverName,
|
|
phone: widget.ride.driverPhone,
|
|
color: const Color(0xFF3B82F6),
|
|
),
|
|
const SizedBox(height: 12),
|
|
// Passenger Info
|
|
_buildMapUserInfo(
|
|
icon: Icons.person_rounded,
|
|
title: 'الراكب',
|
|
name: widget.ride.passengerName,
|
|
phone: widget.ride.passengerPhone,
|
|
color: const Color(0xFF8B5CF6),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
// Fit Button
|
|
Positioned(
|
|
top: 16,
|
|
right: 16,
|
|
child: FloatingActionButton.small(
|
|
backgroundColor: Colors.white,
|
|
foregroundColor: const Color(0xFF2B3674),
|
|
onPressed: _fitBounds,
|
|
child: const Icon(Icons.center_focus_strong_rounded),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMapUserInfo({
|
|
required IconData icon,
|
|
required String title,
|
|
required String name,
|
|
required String phone,
|
|
required Color color,
|
|
}) {
|
|
String displayPhone = phone;
|
|
if (!widget.isAdmin && phone.length > 4) {
|
|
displayPhone =
|
|
phone.substring(phone.length - 4).padLeft(phone.length, '*');
|
|
}
|
|
|
|
return Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Icon(icon, size: 20, color: color),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Colors.grey[600],
|
|
fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.bold,
|
|
color: Color(0xFF2B3674)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (widget.isAdmin && phone.isNotEmpty)
|
|
GestureDetector(
|
|
onTap: () async {
|
|
String formattedPhone = phone;
|
|
if (!formattedPhone.startsWith('+'))
|
|
formattedPhone = '+$formattedPhone';
|
|
final Uri launchUri = Uri(scheme: 'tel', path: formattedPhone);
|
|
if (await canLaunchUrl(launchUri)) await launchUrl(launchUri);
|
|
},
|
|
child: Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.green.withOpacity(0.1),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child:
|
|
const Icon(Icons.call_rounded, size: 20, color: Colors.green),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|