879 lines
32 KiB
Dart
879 lines
32 KiB
Dart
part of 'pickups.dart';
|
||
|
||
class _StepPoint {
|
||
final int step;
|
||
final LatLng position;
|
||
_StepPoint(this.step, this.position);
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// MULTI CUSTOMER MAP (Shows all pickup with real step numbers)
|
||
// -------------------------------------------------------------------------
|
||
class MultiCustomerMapScreen extends StatefulWidget {
|
||
final List<Map<String, dynamic>> pickup;
|
||
final Map<String, int> preservedStepNumbers;
|
||
|
||
const MultiCustomerMapScreen({
|
||
super.key,
|
||
required this.pickup,
|
||
this.preservedStepNumbers = const {},
|
||
});
|
||
|
||
@override
|
||
State<MultiCustomerMapScreen> createState() => _MultiCustomerMapScreenState();
|
||
}
|
||
|
||
class _MultiCustomerMapScreenState extends State<MultiCustomerMapScreen>
|
||
with SingleTickerProviderStateMixin {
|
||
final MapController mapController = MapController();
|
||
late final MilerMapCamera _camera;
|
||
List<Marker> markers = <Marker>[];
|
||
|
||
/// The route as known, faint, underneath.
|
||
List<Polyline> casingLines = <Polyline>[];
|
||
|
||
/// The bright line drawing itself in over the casing. Named rather than kept
|
||
/// in a set keyed by 'route_progress': `flutter_map` draws a list in order, so
|
||
/// the order is the z-order and the id had nothing left to do.
|
||
Polyline? progressLine;
|
||
|
||
List<Polyline> get routeLayers => <Polyline>[
|
||
...casingLines,
|
||
if (progressLine != null) progressLine!,
|
||
];
|
||
|
||
/// Bumped whenever a route layer changes mid-animation, so only the map
|
||
/// is rebuilt rather than the whole screen.
|
||
final ValueNotifier<int> _mapRepaint = ValueNotifier<int>(0);
|
||
LatLng? currentLocation;
|
||
bool _isLoading = true;
|
||
bool _mapReady = false;
|
||
final Map<String, Map<String, dynamic>> _pickupMap = {};
|
||
|
||
/// Every routed point, hub to last stop, in riding order.
|
||
///
|
||
/// The segments are fetched one leg at a time, but the *animation* has to run
|
||
/// across the whole route — drawing each leg in turn would restart at every
|
||
/// stop and read as five separate lines rather than one journey.
|
||
final List<LatLng> _fullRoute = [];
|
||
|
||
late final AnimationController _routeAnim;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
// Long enough to read as a route being traced rather than a line appearing;
|
||
// short enough that the rider is not waiting on it to see his stops.
|
||
_camera = MilerMapCamera(controller: mapController, vsync: this);
|
||
_routeAnim = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 1400),
|
||
)..addListener(_onRouteTick);
|
||
if (widget.pickup.isNotEmpty) {
|
||
final firstPickup = widget.pickup.first;
|
||
final dropLat = _parseD(
|
||
firstPickup['droplat'] ?? firstPickup['pickuplat'] ?? 0,
|
||
);
|
||
final dropLon = _parseD(
|
||
firstPickup['droplon'] ?? firstPickup['pickuplong'] ?? 0,
|
||
);
|
||
if (dropLat != 0 && dropLon != 0) {
|
||
currentLocation = LatLng(dropLat, dropLon);
|
||
} else {
|
||
currentLocation = const LatLng(11.018356, 77.012596);
|
||
}
|
||
} else {
|
||
currentLocation = const LatLng(11.018356, 77.012596);
|
||
}
|
||
_loadMapData();
|
||
}
|
||
|
||
double _parseD(dynamic v) {
|
||
if (v == null) return 0.0;
|
||
if (v is num) return v.toDouble();
|
||
return double.tryParse(v.toString()) ?? 0.0;
|
||
}
|
||
|
||
int _getStepNumber(Map<String, dynamic> Booking) {
|
||
final dynamic raw = Booking['step'] ?? Booking['Step'];
|
||
final int step = raw == null
|
||
? 0
|
||
: (raw is num ? raw.toInt() : int.tryParse(raw.toString()) ?? 0);
|
||
return step;
|
||
}
|
||
|
||
String _getOrderKey(Map<String, dynamic> Booking) {
|
||
final pickupId = (Booking['pickupid'] ?? '').toString();
|
||
final orderId = (Booking['orderid'] ?? '').toString();
|
||
return pickupId.isNotEmpty ? 'pickup_$pickupId' : 'order_$orderId';
|
||
}
|
||
|
||
int _getPreservedOrCurrentStep(Map<String, dynamic> Booking) {
|
||
final orderKey = _getOrderKey(Booking);
|
||
if (widget.preservedStepNumbers.containsKey(orderKey)) {
|
||
return widget.preservedStepNumbers[orderKey]!;
|
||
}
|
||
final currentStep = _getStepNumber(Booking);
|
||
return currentStep;
|
||
}
|
||
|
||
Future<void> _loadMapData() async {
|
||
try {
|
||
if (mounted) {
|
||
setState(() {
|
||
_isLoading = false;
|
||
});
|
||
}
|
||
|
||
_getCurrentLocation().then((_) {
|
||
if (mounted) {
|
||
_createPickupMarkers();
|
||
}
|
||
});
|
||
} catch (e) {
|
||
debugPrint('[CUSTOMER_MAP] Error loading map data: $e');
|
||
if (mounted) {
|
||
setState(() => _isLoading = false);
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _getCurrentLocation() async {
|
||
try {
|
||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||
if (!serviceEnabled) {
|
||
final lastPos = await Geolocator.getLastKnownPosition();
|
||
if (lastPos != null && mounted) {
|
||
setState(() {
|
||
currentLocation = LatLng(lastPos.latitude, lastPos.longitude);
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
LocationPermission permission = await Geolocator.checkPermission();
|
||
if (permission == LocationPermission.denied) {
|
||
permission = await Geolocator.requestPermission();
|
||
if (permission == LocationPermission.denied) {
|
||
final lastPos = await Geolocator.getLastKnownPosition();
|
||
if (lastPos != null && mounted) {
|
||
setState(() {
|
||
currentLocation = LatLng(lastPos.latitude, lastPos.longitude);
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
Position? position;
|
||
try {
|
||
position = await Geolocator.getCurrentPosition(
|
||
locationSettings: const LocationSettings(
|
||
accuracy: LocationAccuracy.medium,
|
||
distanceFilter: 0,
|
||
timeLimit: Duration(seconds: 5),
|
||
),
|
||
);
|
||
} catch (e) {
|
||
debugPrint(
|
||
'[CUSTOMER_MAP] Timeout getting location, using last known: $e',
|
||
);
|
||
position = await Geolocator.getLastKnownPosition();
|
||
}
|
||
|
||
final safePosition = position;
|
||
if (safePosition != null && mounted) {
|
||
setState(() {
|
||
currentLocation = LatLng(
|
||
safePosition.latitude,
|
||
safePosition.longitude,
|
||
);
|
||
});
|
||
}
|
||
} catch (e) {
|
||
debugPrint('[CUSTOMER_MAP] Error getting location: $e');
|
||
try {
|
||
final lastPos = await Geolocator.getLastKnownPosition();
|
||
if (lastPos != null && mounted) {
|
||
setState(() {
|
||
currentLocation = LatLng(lastPos.latitude, lastPos.longitude);
|
||
});
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
|
||
int? _getNextPickupStep() {
|
||
// Find the first non-skipped pickup (next pickup from user location)
|
||
for (int i = 0; i < widget.pickup.length; i++) {
|
||
final pickup = widget.pickup[i];
|
||
final status = (pickup['orderstatus']?.toString().toLowerCase() ?? '')
|
||
.trim();
|
||
if (status != 'skipped') {
|
||
final stepNumber = _getPreservedOrCurrentStep(pickup);
|
||
if (stepNumber > 0) {
|
||
return stepNumber;
|
||
} else {
|
||
// Calculate display step for bookings without step
|
||
final ordersWithStepBefore = widget.pickup
|
||
.sublist(0, i)
|
||
.where((o) => _getPreservedOrCurrentStep(o) > 0)
|
||
.length;
|
||
final totalOrdersWithStep = widget.pickup
|
||
.where((o) => _getPreservedOrCurrentStep(o) > 0)
|
||
.length;
|
||
return totalOrdersWithStep + (i - ordersWithStepBefore) + 1;
|
||
}
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
Future<void> _createPickupMarkers() async {
|
||
final tempMarkers = <Marker>[];
|
||
List<_StepPoint> stepPoints = [];
|
||
_pickupMap.clear();
|
||
|
||
final nextStep = _getNextPickupStep();
|
||
|
||
for (int i = 0; i < widget.pickup.length; i++) {
|
||
final pickup = widget.pickup[i];
|
||
final stepNumber = _getPreservedOrCurrentStep(pickup);
|
||
|
||
int displayStep;
|
||
if (stepNumber > 0) {
|
||
displayStep = stepNumber;
|
||
} else {
|
||
final ordersWithStepBefore = widget.pickup
|
||
.sublist(0, i)
|
||
.where((o) => _getPreservedOrCurrentStep(o) > 0)
|
||
.length;
|
||
final totalOrdersWithStep = widget.pickup
|
||
.where((o) => _getPreservedOrCurrentStep(o) > 0)
|
||
.length;
|
||
displayStep = totalOrdersWithStep + (i - ordersWithStepBefore) + 1;
|
||
}
|
||
|
||
final double lat = _parseD(pickup['droplat'] ?? pickup['pickuplat']);
|
||
final double lon = _parseD(pickup['droplon'] ?? pickup['pickuplong']);
|
||
if (lat == 0 || lon == 0) continue;
|
||
|
||
final orderId = (pickup['orderid'] ?? '').toString();
|
||
final status = (pickup['orderstatus']?.toString().toLowerCase() ?? '')
|
||
.trim();
|
||
final isSkipped = status == 'skipped';
|
||
final isNext = displayStep == nextStep;
|
||
|
||
// Save pickup data for dialog
|
||
_pickupMap['pickup_$orderId'] = pickup;
|
||
|
||
// ── A widget, not a bitmap ──
|
||
//
|
||
// This used to await `_createCircularMarkerBitmap`, a hundred lines that
|
||
// opened a `PictureRecorder`, painted a circle and a number onto a canvas,
|
||
// rasterised it to PNG bytes and handed the bytes to the platform — per
|
||
// marker, per rebuild, asynchronously, because a Google Maps marker is a
|
||
// texture and cannot contain a `Text`. A `flutter_map` marker is a widget,
|
||
// so the number is just a number, in the app's own font.
|
||
//
|
||
// The tap that opened the customer sheet moves onto the widget too. The
|
||
// `InfoWindow` that carried the name is gone with it: it only appeared on
|
||
// tap, covered the route it was anchored to, and said what the sheet the
|
||
// same tap opens says properly.
|
||
tempMarkers.add(
|
||
milerMarker(
|
||
point: LatLng(lat, lon),
|
||
width: MilerNumberPin.size + 8,
|
||
height: MilerNumberPin.size + MilerNumberPin.tail,
|
||
child: GestureDetector(
|
||
onTap: () => _showCustomerDetailsSheet(pickup, displayStep),
|
||
child: MilerNumberPin(
|
||
number: displayStep,
|
||
color: isSkipped
|
||
? ColorConstants.warning
|
||
: isNext
|
||
? ColorConstants.primary
|
||
: ColorConstants.secondaryText,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
|
||
stepPoints.add(_StepPoint(displayStep, LatLng(lat, lon)));
|
||
}
|
||
|
||
if (currentLocation != null) {
|
||
tempMarkers.add(
|
||
milerMarker(
|
||
point: currentLocation!,
|
||
width: MilerRiderDot.size,
|
||
height: MilerRiderDot.size,
|
||
child: const MilerRiderDot(),
|
||
),
|
||
);
|
||
stepPoints.insert(0, _StepPoint(0, currentLocation!));
|
||
}
|
||
|
||
stepPoints.sort((a, b) => a.step.compareTo(b.step));
|
||
|
||
final newPolylines = <Polyline>[];
|
||
_fullRoute.clear();
|
||
|
||
for (int i = 0; i < stepPoints.length - 1; i++) {
|
||
final start = stepPoints[i].position;
|
||
final end = stepPoints[i + 1].position;
|
||
|
||
// OSRM instead of Directions — same answer, no key. See [MilerRouter].
|
||
final routePoints = await MilerRouter.route(start, end);
|
||
|
||
if (routePoints.isNotEmpty) {
|
||
_fullRoute.addAll(routePoints);
|
||
newPolylines.add(
|
||
Polyline(
|
||
points: routePoints,
|
||
strokeWidth: 6,
|
||
// The brand accent, matching the single-stop map. This was
|
||
// `Colors.blue`, so the two map screens drew the same route in two
|
||
// different colours.
|
||
color: ColorConstants.primary,
|
||
strokeCap: StrokeCap.round,
|
||
strokeJoin: StrokeJoin.round,
|
||
),
|
||
);
|
||
} else {
|
||
// A leg the router could not resolve still has to enter the animated
|
||
// path, or the trace stops dead at the first unroutable stop.
|
||
_fullRoute.addAll([start, end]);
|
||
newPolylines.add(
|
||
Polyline(
|
||
points: [start, end],
|
||
strokeWidth: 4,
|
||
// Straight-line fallback — lighter, because it is a guess at the
|
||
// route rather than the route.
|
||
color: ColorConstants.primary.withValues(alpha: 0.35),
|
||
pattern: StrokePattern.dashed(segments: const [20, 12]),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
if (mounted) {
|
||
setState(() {
|
||
markers = tempMarkers;
|
||
// The resolved segments become the faint casing — the route as known —
|
||
// and the bright line is then traced over them. Same two-layer treatment
|
||
// the single-stop map uses, so the two screens animate identically.
|
||
casingLines = [
|
||
for (final p in newPolylines)
|
||
Polyline(
|
||
points: p.points,
|
||
color: ColorConstants.primary.withValues(alpha: 0.18),
|
||
strokeWidth: 9,
|
||
pattern: p.pattern,
|
||
strokeCap: StrokeCap.round,
|
||
strokeJoin: StrokeJoin.round,
|
||
),
|
||
];
|
||
});
|
||
if (_fullRoute.length > 1) {
|
||
_buildDistanceTable();
|
||
_routeAnim.forward(from: 0);
|
||
}
|
||
}
|
||
|
||
await Future.delayed(const Duration(milliseconds: 200));
|
||
_fitBoundsToMarkersAndPolylines();
|
||
}
|
||
|
||
/// Distance from the route's start to each point in [_fullRoute].
|
||
///
|
||
/// The animation walks this table rather than the point list, and that is the
|
||
/// difference between the head moving and the head stuttering. A routing API
|
||
/// puts vertices where the road bends, so a roundabout can carry twenty of
|
||
/// them across thirty metres while a bypass carries two across a kilometre.
|
||
/// Stepping by index spends equal time on each, which makes the head crawl
|
||
/// through every junction and leap down every straight.
|
||
List<double> _cumulative = <double>[];
|
||
|
||
void _buildDistanceTable() {
|
||
_cumulative = List<double>.filled(_fullRoute.length, 0);
|
||
if (_fullRoute.length < 2) return;
|
||
|
||
// Longitude degrees shrink with latitude, so scale them before measuring or
|
||
// an east–west leg is over-weighted. Plane geometry is ample here: the table
|
||
// only has to get the *ratios* between legs right.
|
||
final meanLat =
|
||
_fullRoute.map((p) => p.latitude).reduce((a, b) => a + b) /
|
||
_fullRoute.length;
|
||
final lonScale = math.cos(meanLat * math.pi / 180).abs().clamp(0.05, 1.0);
|
||
|
||
for (var i = 1; i < _fullRoute.length; i++) {
|
||
final a = _fullRoute[i - 1], b = _fullRoute[i];
|
||
final dy = b.latitude - a.latitude;
|
||
final dx = (b.longitude - a.longitude) * lonScale;
|
||
_cumulative[i] = _cumulative[i - 1] + math.sqrt(dy * dy + dx * dx);
|
||
}
|
||
}
|
||
|
||
/// The point [f] of the way along the route *by length*, as a segment index
|
||
/// plus a fraction into that segment.
|
||
(int, double) _atDistance(double f) {
|
||
final total = _cumulative.isEmpty ? 0.0 : _cumulative.last;
|
||
if (total <= 0) return (0, 0);
|
||
|
||
final target = (f.clamp(0.0, 1.0)) * total;
|
||
var lo = 0, hi = _cumulative.length - 1;
|
||
while (lo < hi) {
|
||
final mid = (lo + hi) >> 1;
|
||
if (_cumulative[mid] <= target) {
|
||
lo = mid + 1;
|
||
} else {
|
||
hi = mid;
|
||
}
|
||
}
|
||
final i = (lo - 1).clamp(0, _fullRoute.length - 2);
|
||
final span = _cumulative[i + 1] - _cumulative[i];
|
||
return (
|
||
i,
|
||
span <= 0 ? 0.0 : ((target - _cumulative[i]) / span).clamp(0.0, 1.0),
|
||
);
|
||
}
|
||
|
||
/// Reveals the bright line progressively, with an interpolated leading tip.
|
||
void _onRouteTick() {
|
||
if (!mounted || _fullRoute.length < 2) return;
|
||
if (_cumulative.length != _fullRoute.length) _buildDistanceTable();
|
||
|
||
final t = Curves.easeInOutCubic.transform(_routeAnim.value);
|
||
final (idx, frac) = _atDistance(t);
|
||
|
||
final shown = <LatLng>[..._fullRoute.sublist(0, idx + 1)];
|
||
if (idx < _fullRoute.length - 1) {
|
||
final a = _fullRoute[idx];
|
||
final b = _fullRoute[idx + 1];
|
||
shown.add(
|
||
LatLng(
|
||
a.latitude + (b.latitude - a.latitude) * frac,
|
||
a.longitude + (b.longitude - a.longitude) * frac,
|
||
),
|
||
);
|
||
}
|
||
|
||
progressLine = Polyline(
|
||
points: shown,
|
||
color: ColorConstants.primary,
|
||
strokeWidth: 6,
|
||
strokeCap: StrokeCap.round,
|
||
strokeJoin: StrokeJoin.round,
|
||
);
|
||
|
||
// Only the map rebuilds — a `setState` here redrew the whole screen, sheet
|
||
// and all, sixty times a second to move one line.
|
||
_mapRepaint.value++;
|
||
}
|
||
|
||
/// Frames every stop and every metre of route.
|
||
///
|
||
/// The retry-with-delay dance this replaces existed because
|
||
/// `GoogleMapController.animateCamera` threw until the platform view had
|
||
/// finished creating itself. `flutter_map`'s camera is available as soon as
|
||
/// the map is in the tree.
|
||
Future<void> _fitBoundsToMarkersAndPolylines() async {
|
||
if (!mounted) return;
|
||
|
||
final points = <LatLng>[
|
||
for (final m in markers) m.point,
|
||
for (final poly in routeLayers) ...poly.points,
|
||
];
|
||
if (points.isEmpty) return;
|
||
|
||
await _camera.fit(points, padding: const EdgeInsets.all(64));
|
||
}
|
||
|
||
void _showCustomerDetailsSheet(Map<String, dynamic> pickup, int stepNumber) {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
sheetAnimationStyle: kMilerSheetStyle,
|
||
isScrollControlled: true,
|
||
isDismissible: true,
|
||
enableDrag: true,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||
),
|
||
builder: (BuildContext context) {
|
||
final customerName = (pickup['pickupcustomer'] ?? 'Customer')
|
||
.toString();
|
||
final address = (pickup['pickupaddress'] ?? 'Address not available')
|
||
.toString();
|
||
final phone = (pickup['pickupcontactno'] ?? '').toString();
|
||
final orderId = (pickup['orderid'] ?? '').toString();
|
||
final status = (pickup['orderstatus']?.toString().toLowerCase() ?? '')
|
||
.trim();
|
||
final isSkipped = status == 'skipped';
|
||
|
||
return SafeArea(
|
||
top: false,
|
||
left: false,
|
||
right: false,
|
||
bottom: true,
|
||
child: Padding(
|
||
padding: EdgeInsets.only(
|
||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||
),
|
||
child: Container(
|
||
padding: const EdgeInsets.all(24),
|
||
child: SingleChildScrollView(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// Header with title and close icon
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
'Customer Details',
|
||
style: TextStyle(
|
||
fontSize: 22.sp,
|
||
fontWeight: FontWeight.bold,
|
||
fontFamily: FontConstants.fontFamily,
|
||
color: ColorConstants.primary,
|
||
),
|
||
),
|
||
),
|
||
InkWell(
|
||
onTap: () => Navigator.of(context).pop(),
|
||
child: Container(
|
||
padding: const EdgeInsets.all(4),
|
||
decoration: BoxDecoration(
|
||
color: ColorConstants.dividerColor,
|
||
shape: BoxShape.circle,
|
||
),
|
||
// A close affordance, not a destructive action.
|
||
// This was a 34px red `Icons.cancel` — the loudest
|
||
// thing in the sheet, competing with the call button
|
||
// and reading as "delete this stop".
|
||
child: Icon(
|
||
Icons.close_rounded,
|
||
size: 20.sp,
|
||
color: ColorConstants.slateText,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
|
||
const Divider(thickness: 1.5),
|
||
|
||
// Step number and status
|
||
Row(
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 12,
|
||
vertical: 6,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: isSkipped
|
||
? ColorConstants.warning
|
||
: ColorConstants.primary,
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||
),
|
||
child: Text(
|
||
'Step $stepNumber',
|
||
style: TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
color: Colors.white,
|
||
fontWeight: FontWeight.bold,
|
||
fontSize: 14.sp,
|
||
),
|
||
),
|
||
),
|
||
if (isSkipped) ...[
|
||
const SizedBox(width: 8),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 12,
|
||
vertical: 6,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: ColorConstants.warning,
|
||
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
|
||
),
|
||
child: Text(
|
||
'SKIPPED',
|
||
style: TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
color: Colors.white,
|
||
fontWeight: FontWeight.bold,
|
||
fontSize: 12.sp,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
// Customer name
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Icon(
|
||
Icons.person,
|
||
size: 20,
|
||
color: ColorConstants.secondaryText,
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'Customer Name',
|
||
style: TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
fontSize: 16.sp,
|
||
color: ColorConstants.slateText,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
customerName,
|
||
style: TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
fontSize: 18.sp,
|
||
fontWeight: FontWeight.w600,
|
||
color: ColorConstants.secondaryText,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
// Pickup address
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Icon(
|
||
Icons.location_on,
|
||
size: 20,
|
||
color: ColorConstants.secondaryText,
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'Recipient Address',
|
||
style: TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
fontSize: 16.sp,
|
||
color: ColorConstants.slateText,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
address,
|
||
style: TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
fontSize: 18.sp,
|
||
fontWeight: FontWeight.w600,
|
||
color: ColorConstants.secondaryText,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
// Phone number
|
||
if (phone.isNotEmpty)
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Icon(
|
||
Icons.phone,
|
||
size: 20,
|
||
color: ColorConstants.secondaryText,
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'Contact Number',
|
||
style: TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
fontSize: 16.sp,
|
||
color: ColorConstants.slateText,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
phone,
|
||
style: TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
fontSize: 18.sp,
|
||
fontWeight: FontWeight.w600,
|
||
color: ColorConstants.secondaryText,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
// Booking ID
|
||
Text(
|
||
'Booking ID: $orderId',
|
||
style: TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
fontSize: 16.sp,
|
||
color: ColorConstants.slateText,
|
||
fontStyle: FontStyle.italic,
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
// Call button
|
||
MilerButton(
|
||
label: 'Call',
|
||
icon: Icons.phone,
|
||
color: ColorConstants.acceptGreen,
|
||
onPressed: () async {
|
||
// Close bottom sheet first
|
||
Navigator.of(context).pop();
|
||
|
||
// Small delay to ensure bottom sheet is closed
|
||
await Future.delayed(const Duration(milliseconds: 300));
|
||
|
||
// Then make the call
|
||
final bool success = await launchPhoneDialer(phone);
|
||
if (!success && context.mounted) {
|
||
AppFeedback.error(context, 'Could not open the dialer');
|
||
}
|
||
},
|
||
),
|
||
const SizedBox(height: 8),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
// The class had no dispose before the route animation was added — a ticker
|
||
// outliving its State keeps the whole subtree alive and fires into a widget
|
||
// that is no longer mounted.
|
||
_routeAnim.dispose();
|
||
_mapRepaint.dispose();
|
||
_camera.dispose();
|
||
mapController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
LatLng initialTarget;
|
||
double initialZoom = 13;
|
||
|
||
if (currentLocation != null) {
|
||
initialTarget = currentLocation!;
|
||
} else if (widget.pickup.isNotEmpty) {
|
||
final firstPickup = widget.pickup.first;
|
||
final lat = _parseD(firstPickup['droplat'] ?? firstPickup['pickuplat']);
|
||
final lon = _parseD(firstPickup['droplon'] ?? firstPickup['pickuplong']);
|
||
initialTarget = (lat != 0 && lon != 0)
|
||
? LatLng(lat, lon)
|
||
: const LatLng(11.0168, 76.9558);
|
||
} else {
|
||
initialTarget = const LatLng(11.0168, 76.9558);
|
||
}
|
||
|
||
return Scaffold(
|
||
// ── A light app bar, not a red slab ──
|
||
//
|
||
// This was a solid brand-red bar with white text, 80pt tall, built out of
|
||
// a PreferredSize wrapping an AppBar whose `leading` was an infinitely
|
||
// wide Row holding both the back arrow and the title. Two problems: a
|
||
// saturated red band across the top of a map competes with the route drawn
|
||
// on it — the map is the content, and the chrome was louder than it — and
|
||
// every other pushed screen in the app has a white bar, so this one read
|
||
// as a different app.
|
||
//
|
||
// Inverted, and rebuilt as an ordinary AppBar: white surface, brand red on
|
||
// the title where it costs nothing, a real `leading` slot, and the same
|
||
// hairline the rest of the app uses to close a bar.
|
||
appBar: AppBar(
|
||
// The app-wide frosted brand wash — see [milerGlassSurface].
|
||
backgroundColor: Colors.transparent,
|
||
flexibleSpace: milerGlassSurface(),
|
||
surfaceTintColor: Colors.transparent,
|
||
elevation: 0,
|
||
scrolledUnderElevation: 0,
|
||
titleSpacing: 0,
|
||
leading: IconButton(
|
||
tooltip: 'Back',
|
||
icon: Icon(
|
||
Icons.arrow_back_rounded,
|
||
color: ColorConstants.slateText,
|
||
size: 22.sp,
|
||
),
|
||
onPressed: () => Navigator.pop(context),
|
||
),
|
||
title: Text(
|
||
'Route map',
|
||
style: TextStyle(
|
||
fontFamily: FontConstants.fontFamily,
|
||
fontSize: 19.sp,
|
||
color: ColorConstants.primary,
|
||
fontWeight: FontWeight.w800,
|
||
letterSpacing: -0.3,
|
||
),
|
||
),
|
||
centerTitle: false,
|
||
),
|
||
body: _isLoading
|
||
? const Center(child: CircularProgressIndicator())
|
||
: AfterEntrance(
|
||
placeholder: const MapPlaceholder(),
|
||
builder: (context) => ValueListenableBuilder<int>(
|
||
valueListenable: _mapRepaint,
|
||
builder: (context, _, _) => MilerMap(
|
||
controller: mapController,
|
||
initialCenter: initialTarget,
|
||
initialZoom: initialZoom,
|
||
polylines: routeLayers,
|
||
markers: markers,
|
||
onReady: () {
|
||
_mapReady = true;
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (_mapReady) _fitBoundsToMarkersAndPolylines();
|
||
});
|
||
},
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|