first commit
This commit is contained in:
878
lib/views/Dashboard/pickups/multi_map.dart
Normal file
878
lib/views/Dashboard/pickups/multi_map.dart
Normal file
@@ -0,0 +1,878 @@
|
||||
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();
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user