You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

877 lines
25 KiB

import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:flutter_polyline_points/flutter_polyline_points.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';
import 'package:supplier_new/common/settings.dart';
class DeliveryUpdatesPage extends StatefulWidget {
final String orderId;
final String initialStatus;
const DeliveryUpdatesPage({
super.key,
required this.orderId,
required this.initialStatus,
});
@override
State<DeliveryUpdatesPage> createState() => _DeliveryUpdatesPageState();
}
class _DeliveryUpdatesPageState extends State<DeliveryUpdatesPage> {
GoogleMapController? _mapController;
Timer? _timer;
final PolylinePoints _polylinePoints = PolylinePoints();
/// ---------------- CHANGE THIS ----------------
/// Put your Google Maps API key here
/// Need Directions API + Distance Matrix API enabled
static const String googleApiKey = 'AIzaSyDJpK9RVhlBejtJu9xSGfneuTN6HOfJgSM';
/// --------------------------------------------
bool isLoading = true;
bool mapReady = false;
String currentStatus = '';
String etaText = '--';
String distanceText = '--';
String durationText = '--';
String driverName = '';
String driverPhone = '';
String tankerName = '';
String orderAddress = '';
double? driverLat;
double? driverLng;
double? destinationLat;
double? destinationLng;
Set<Marker> markers = {};
Set<Polyline> polylines = {};
List<LatLng> routePoints = [];
@override
void initState() {
super.initState();
currentStatus = _beautifyStatus(widget.initialStatus);
_loadTracking(showLoader: true);
_timer = Timer.periodic(const Duration(seconds: 5), (_) {
_loadTracking(showLoader: false);
});
}
@override
void dispose() {
_timer?.cancel();
_mapController?.dispose();
super.dispose();
}
Future<void> _loadTracking({bool showLoader = false}) async {
if (showLoader && mounted) {
setState(() {
isLoading = true;
});
}
try {
/// ===========================
/// IMPORTANT:
/// Change this endpoint path to your real backend route
/// Example:
/// ${AppSettings.host}trackOrder/${widget.orderId}
/// ${AppSettings.host}getOrderTracking/${widget.orderId}
/// ===========================
final uri = Uri.parse(
'${AppSettings.host}trackOrder/${widget.orderId}',
);
final response = await http.get(uri);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('Tracking API failed: ${response.statusCode}');
}
final decoded = jsonDecode(response.body);
/// Supports either:
/// { status:true, data:{...} }
/// OR direct flat response
final data = decoded is Map && decoded['data'] is Map
? decoded['data'] as Map<String, dynamic>
: decoded as Map<String, dynamic>;
driverLat = _toDouble(
data['driverLat'] ??
data['driver_lat'] ??
data['deliveryBoyLat'] ??
data['deliveryboyLat'] ??
data['lat'],
);
driverLng = _toDouble(
data['driverLng'] ??
data['driver_lng'] ??
data['deliveryBoyLng'] ??
data['deliveryboyLng'] ??
data['lng'] ??
data['long'],
);
destinationLat = _toDouble(
data['destinationLat'] ??
data['destination_lat'] ??
data['orderLat'] ??
data['customerLat'] ??
data['dropLat'],
);
destinationLng = _toDouble(
data['destinationLng'] ??
data['destination_lng'] ??
data['orderLng'] ??
data['customerLng'] ??
data['dropLng'],
);
driverName = (data['driverName'] ??
data['delivery_agent_name'] ??
data['deliveryBoyName'] ??
'')
.toString();
driverPhone = (data['driverPhone'] ??
data['delivery_agent_mobile'] ??
data['deliveryBoyPhone'] ??
'')
.toString();
tankerName = (data['tankerName'] ?? data['tanker_name'] ?? '').toString();
orderAddress = (data['destinationAddress'] ??
data['displayAddress'] ??
data['address'] ??
data['customerAddress'] ??
'')
.toString();
final apiStatus =
(data['status'] ?? data['orderStatus'] ?? widget.initialStatus)
.toString();
currentStatus = _beautifyStatus(apiStatus);
final apiEta =
(data['eta'] ?? data['etaText'] ?? data['estimatedTime'] ?? '')
.toString()
.trim();
final apiDistance =
(data['distance'] ?? data['distanceText'] ?? '').toString().trim();
if (apiEta.isNotEmpty) etaText = apiEta;
if (apiDistance.isNotEmpty) distanceText = apiDistance;
if (driverLat != null &&
driverLng != null &&
destinationLat != null &&
destinationLng != null) {
await _buildMarkers();
await _fetchGoogleRouteAndEta();
await _moveCameraToBounds();
}
if (mounted) {
setState(() {
isLoading = false;
});
}
} catch (e) {
debugPrint('Tracking error: $e');
if (mounted) {
setState(() {
isLoading = false;
});
}
}
}
Future<void> _buildMarkers() async {
if (driverLat == null ||
driverLng == null ||
destinationLat == null ||
destinationLng == null) {
return;
}
markers = {
Marker(
markerId: const MarkerId('driver'),
position: LatLng(driverLat!, driverLng!),
infoWindow: InfoWindow(
title: driverName.isNotEmpty ? driverName : 'Driver',
snippet: tankerName.isNotEmpty ? tankerName : 'Live location',
),
icon: BitmapDescriptor.defaultMarkerWithHue(
BitmapDescriptor.hueAzure,
),
),
Marker(
markerId: const MarkerId('destination'),
position: LatLng(destinationLat!, destinationLng!),
infoWindow: InfoWindow(
title: 'Destination',
snippet: orderAddress.isNotEmpty ? orderAddress : 'Order location',
),
icon: BitmapDescriptor.defaultMarkerWithHue(
BitmapDescriptor.hueRed,
),
),
};
}
Future<void> _fetchGoogleRouteAndEta() async {
if (driverLat == null ||
driverLng == null ||
destinationLat == null ||
destinationLng == null) {
return;
}
try {
final origin = '${driverLat!},${driverLng!}';
final destination = '${destinationLat!},${destinationLng!}';
final directionsUrl = Uri.parse(
'https://maps.googleapis.com/maps/api/directions/json'
'?origin=$origin'
'&destination=$destination'
'&departure_time=now'
'&traffic_model=best_guess'
'&mode=driving'
'&key=$googleApiKey',
);
final directionsResponse = await http.get(directionsUrl);
final directionsData = jsonDecode(directionsResponse.body);
if (directionsData['routes'] != null &&
(directionsData['routes'] as List).isNotEmpty) {
final route = directionsData['routes'][0];
final overviewPolyline =
route['overview_polyline']?['points']?.toString() ?? '';
final legs = route['legs'] as List?;
if (legs != null && legs.isNotEmpty) {
final leg = legs[0];
final distanceObj = leg['distance'];
final durationObj = leg['duration'];
final durationTrafficObj = leg['duration_in_traffic'];
if (distanceObj != null && (distanceText == '--' || distanceText.isEmpty)) {
distanceText = (distanceObj['text'] ?? '--').toString();
}
if (durationObj != null) {
durationText = (durationObj['text'] ?? '--').toString();
}
if (durationTrafficObj != null) {
etaText = (durationTrafficObj['text'] ?? '--').toString();
} else if (durationObj != null && (etaText == '--' || etaText.isEmpty)) {
etaText = (durationObj['text'] ?? '--').toString();
}
}
final result = _polylinePoints.decodePolyline(overviewPolyline);
routePoints = result
.map((p) => LatLng(p.latitude, p.longitude))
.toList();
polylines = {
Polyline(
polylineId: const PolylineId('delivery_route'),
points: routePoints,
width: 5,
color: const Color(0XFF8270DB),
),
};
} else {
_setStraightLinePolyline();
_setApproxEtaIfNeeded();
}
} catch (e) {
debugPrint('Directions error: $e');
_setStraightLinePolyline();
_setApproxEtaIfNeeded();
}
}
void _setStraightLinePolyline() {
if (driverLat == null ||
driverLng == null ||
destinationLat == null ||
destinationLng == null) {
return;
}
polylines = {
Polyline(
polylineId: const PolylineId('delivery_route_fallback'),
points: [
LatLng(driverLat!, driverLng!),
LatLng(destinationLat!, destinationLng!),
],
width: 5,
color: const Color(0XFF8270DB),
),
};
}
void _setApproxEtaIfNeeded() {
if (driverLat == null ||
driverLng == null ||
destinationLat == null ||
destinationLng == null) {
return;
}
final km = _calculateDistanceKm(
driverLat!,
driverLng!,
destinationLat!,
destinationLng!,
);
if (distanceText == '--' || distanceText.isEmpty) {
distanceText = '${km.toStringAsFixed(1)} km';
}
/// Rough city driving estimate at 25 km/h
final mins = ((km / 25) * 60).ceil().clamp(1, 999);
if (etaText == '--' || etaText.isEmpty) {
etaText = '$mins mins';
}
if (durationText == '--' || durationText.isEmpty) {
durationText = '$mins mins';
}
}
Future<void> _moveCameraToBounds() async {
if (!mapReady || _mapController == null) return;
if (driverLat == null ||
driverLng == null ||
destinationLat == null ||
destinationLng == null) {
return;
}
final southwest = LatLng(
min(driverLat!, destinationLat!),
min(driverLng!, destinationLng!),
);
final northeast = LatLng(
max(driverLat!, destinationLat!),
max(driverLng!, destinationLng!),
);
final bounds = LatLngBounds(
southwest: southwest,
northeast: northeast,
);
try {
await _mapController!.animateCamera(
CameraUpdate.newLatLngBounds(bounds, 70),
);
} catch (_) {
await Future.delayed(const Duration(milliseconds: 300));
try {
await _mapController!.animateCamera(
CameraUpdate.newLatLngBounds(bounds, 70),
);
} catch (_) {}
}
}
double _calculateDistanceKm(
double startLat,
double startLng,
double endLat,
double endLng,
) {
const double earthRadius = 6371;
final dLat = _degToRad(endLat - startLat);
final dLng = _degToRad(endLng - startLng);
final a = sin(dLat / 2) * sin(dLat / 2) +
cos(_degToRad(startLat)) *
cos(_degToRad(endLat)) *
sin(dLng / 2) *
sin(dLng / 2);
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
return earthRadius * c;
}
double _degToRad(double deg) => deg * pi / 180;
double? _toDouble(dynamic val) {
if (val == null) return null;
if (val is double) return val;
if (val is int) return val.toDouble();
return double.tryParse(val.toString());
}
String _beautifyStatus(String s) {
final value = s.trim().toLowerCase();
if (value == 'advance_paid' || value == 'accepted') {
return 'Pending';
}
if (value == 'deliveryboy_assigned' || value == 'tanker_assigned') {
return 'Assigned';
}
if (value == 'delivered') {
return 'Completed';
}
return value
.replaceAll('_', ' ')
.split(' ')
.map((e) => e.isEmpty
? e
: '${e[0].toUpperCase()}${e.substring(1)}')
.join(' ');
}
int _statusStepIndex() {
final raw = widget.initialStatus.trim().toLowerCase();
final live = currentStatus.trim().toLowerCase().replaceAll(' ', '_');
final s = live.isNotEmpty ? live : raw;
switch (s) {
case 'accepted':
case 'advance_paid':
return 0;
case 'deliveryboy_assigned':
case 'tanker_assigned':
return 1;
case 'pickup_started':
return 2;
case 'start_loading':
case 'loading_completed':
return 3;
case 'out_for_delivery':
case 'arrived':
return 4;
case 'unloading_started':
case 'unloading_stopped':
case 'payment_pending':
return 5;
case 'delivered':
return 6;
default:
return 0;
}
}
Future<void> _callDriver() async {
if (driverPhone.trim().isEmpty) return;
final uri = Uri.parse('tel:$driverPhone');
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
}
}
Widget _buildTopMapCard() {
final initialTarget = LatLng(
driverLat ?? 17.385,
driverLng ?? 78.486,
);
return Expanded(
child: Stack(
children: [
GoogleMap(
initialCameraPosition: CameraPosition(
target: initialTarget,
zoom: 14,
),
myLocationButtonEnabled: false,
zoomControlsEnabled: false,
compassEnabled: true,
trafficEnabled: true,
markers: markers,
polylines: polylines,
onMapCreated: (controller) async {
_mapController = controller;
mapReady = true;
await _moveCameraToBounds();
},
),
Positioned(
top: 14,
left: 14,
right: 14,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 10,
offset: const Offset(0, 3),
)
],
),
child: Row(
children: [
_miniMetric('ETA', etaText),
const SizedBox(width: 10),
_miniMetric('Distance', distanceText),
const SizedBox(width: 10),
_miniMetric('Status', currentStatus),
],
),
),
),
Positioned(
right: 14,
bottom: 14,
child: FloatingActionButton(
heroTag: 'fit_map_btn',
backgroundColor: const Color(0XFF8270DB),
onPressed: _moveCameraToBounds,
child: const Icon(Icons.center_focus_strong, color: Colors.white),
),
),
],
),
);
}
Widget _miniMetric(String title, String value) {
return Expanded(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
decoration: BoxDecoration(
color: const Color(0XFFF7F7FB),
borderRadius: BorderRadius.circular(14),
),
child: Column(
children: [
Text(
title,
style: const TextStyle(
fontSize: 11,
color: Color(0XFF777777),
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 6),
Text(
value.isEmpty ? '--' : value,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
color: Color(0XFF2D2E30),
fontWeight: FontWeight.w700,
),
),
],
),
),
);
}
Widget _buildBottomPanel() {
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 18),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(26)),
),
child: SingleChildScrollView(
child: Column(
children: [
Container(
width: 44,
height: 5,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(20),
),
),
const SizedBox(height: 16),
Row(
children: [
CircleAvatar(
radius: 24,
backgroundColor: const Color(0XFFEEE9FF),
child: const Icon(
Icons.person,
color: Color(0XFF8270DB),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
driverName.isNotEmpty ? driverName : 'Driver not assigned',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Color(0XFF2D2E30),
),
),
const SizedBox(height: 4),
Text(
tankerName.isNotEmpty
? tankerName
: 'Delivery partner',
style: const TextStyle(
fontSize: 13,
color: Color(0XFF6A6B6D),
fontWeight: FontWeight.w500,
),
),
],
),
),
InkWell(
onTap: driverPhone.trim().isEmpty ? null : _callDriver,
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
),
decoration: BoxDecoration(
color: driverPhone.trim().isEmpty
? Colors.grey.shade300
: const Color(0XFF8270DB),
borderRadius: BorderRadius.circular(16),
),
child: const Row(
children: [
Icon(Icons.call, color: Colors.white, size: 16),
SizedBox(width: 6),
Text(
'Call',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
),
),
],
),
),
)
],
),
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0XFFF7F7FB),
borderRadius: BorderRadius.circular(18),
),
child: Column(
children: [
_infoRow('Order ID', widget.orderId),
const SizedBox(height: 10),
_infoRow('Current Status', currentStatus),
const SizedBox(height: 10),
_infoRow('ETA to Destination', etaText),
const SizedBox(height: 10),
_infoRow('Remaining Distance', distanceText),
if (orderAddress.trim().isNotEmpty) ...[
const SizedBox(height: 10),
_infoRow('Destination', orderAddress),
],
],
),
),
const SizedBox(height: 18),
_buildTimeline(),
],
),
),
);
}
Widget _infoRow(String title, String value) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 130,
child: Text(
title,
style: const TextStyle(
fontSize: 13,
color: Color(0XFF6B6C6E),
fontWeight: FontWeight.w500,
),
),
),
Expanded(
child: Text(
value.isEmpty ? '--' : value,
style: const TextStyle(
fontSize: 13,
color: Color(0XFF222222),
fontWeight: FontWeight.w700,
),
),
),
],
);
}
Widget _buildTimeline() {
final currentIndex = _statusStepIndex();
final steps = [
'Order Confirmed',
'Driver Assigned',
'Pickup Started',
'Loading',
'Out for Delivery',
'Unloading',
'Completed',
];
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0XFFF7F7FB),
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Delivery Progress',
style: TextStyle(
fontSize: 15,
color: Color(0XFF2D2E30),
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 14),
...List.generate(steps.length, (index) {
final done = index <= currentIndex;
final isLast = index == steps.length - 1;
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
children: [
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: done
? const Color(0XFF8270DB)
: Colors.grey.shade300,
shape: BoxShape.circle,
),
child: done
? const Icon(Icons.check,
size: 12, color: Colors.white)
: null,
),
if (!isLast)
Container(
width: 2,
height: 28,
color: done
? const Color(0XFF8270DB)
: Colors.grey.shade300,
),
],
),
const SizedBox(width: 12),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 1),
child: Text(
steps[index],
style: TextStyle(
fontSize: 14,
fontWeight: done ? FontWeight.w700 : FontWeight.w500,
color: done
? const Color(0XFF2D2E30)
: const Color(0XFF8A8B8D),
),
),
),
),
],
);
}),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0XFFF2F2F2),
appBar: AppBar(
elevation: 0,
backgroundColor: Colors.white,
iconTheme: const IconThemeData(color: Color(0XFF2D2E30)),
title: const Text(
'Track Delivery',
style: TextStyle(
color: Color(0XFF2D2E30),
fontSize: 18,
fontWeight: FontWeight.w700,
),
),
),
body: isLoading
? const Center(child: CircularProgressIndicator())
: Column(
children: [
_buildTopMapCard(),
SizedBox(
height: MediaQuery.of(context).size.height * 0.40,
child: _buildBottomPanel(),
),
],
),
);
}
}