Files
doormile_milderapp/lib/views/Dashboard/pickups/map.dart
2026-09-09 12:55:23 +05:30

2743 lines
113 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
part of 'pickups.dart';
// -------------------------------------------------------------------------
// SCREEN 1: PICKUP MAP PREVIEW — CORPORATE REDESIGN
// -------------------------------------------------------------------------
class _PickupMapScreen extends StatefulWidget {
final Map<String, dynamic> pickup;
final _MyPickupsState? parentState;
const _PickupMapScreen({required this.pickup, this.parentState});
@override
State<_PickupMapScreen> createState() => _PickupMapScreenState();
}
class _PickupMapScreenState extends State<_PickupMapScreen>
with TickerProviderStateMixin, WidgetsBindingObserver {
final MapController _mapController = MapController();
/// Eased camera moves. `flutter_map`'s controller only teleports; the route
/// choreography here depends on the camera landing before the line draws.
late final MilerMapCamera _camera;
late LatLng _pickupLocation, _dropLocation;
/// The ends of the line currently drawn. See [_routeOrigin].
late LatLng _routeStart, _routeEnd;
/// True once the drawn route starts from the rider rather than from the stop.
bool _routedFromRider = false;
final List<Marker> _markers = <Marker>[];
// ── Four named lines, not a set keyed by id ──
//
// Google Maps took a `Set<Polyline>` and sorted it by `zIndex`, so the layers
// were addressed by string ids — 'route_casing', 'route_pulse_glow' — and
// every frame did a `removeWhere` over the set to replace one of them.
// `flutter_map` draws a list in order, so the order *is* the z-order and the
// ids have nothing left to do. Naming the four layers says what the screen is
// made of, and swapping one is an assignment rather than a search.
Polyline? _casingLine; // the whole route, faint underneath
Polyline? _progressLine; // the bright line, drawing itself in
Polyline? _flowHalo; // the flow's soft halo
Polyline? _flowCore; // the flow's bright core
List<Polyline> get _routeLayers => <Polyline>[
if (_casingLine != null) _casingLine!,
if (_progressLine != null) _progressLine!,
if (_flowHalo != null) _flowHalo!,
if (_flowCore != null) _flowCore!,
];
/// Bumped whenever a route layer changes during an animation, so that only the
/// map is rebuilt.
///
/// Calling `setState` for this instead rebuilt the entire screen sixty times a
/// second — the header, the draggable sheet and every row inside it — to move
/// one line on the map. On a mid-range phone that alone missed frames, and a
/// travelling highlight that misses frames is exactly what "stuck" looks like.
final ValueNotifier<int> _mapRepaint = ValueNotifier<int>(0);
bool _isLoadingRoute = true;
bool _isNavigating = false;
bool _handlingArrival = false;
/// Clears the in-flight flag **and rebuilds**.
///
/// `_buildPrimaryAction` renders a spinner in place of the one button
/// while this is true, and every exit from `_startPickupNavigation` except
/// the happy one used to clear it with a bare assignment. No rebuild followed,
/// so the map sat there spinning with no buttons — a screen that looks hung
/// is indistinguishable from a confirm that did nothing.
void _endNavigating() {
if (!mounted) {
_isNavigating = false;
return;
}
setState(() => _isNavigating = false);
}
bool _hasOpenedNavigation = false;
// Uber-style route reveal: the full route sits as a faint "casing" and the
// bright line draws itself in from origin to destination.
List<LatLng> _routePoints = <LatLng>[];
/// Distance from the route's start to each point, in the same arbitrary unit.
///
/// Both animations are parameterised by *distance* along this table rather
/// than by point index, and that is the whole difference between the effect
/// working and not. A routing API returns vertices where the road bends: a
/// tight corner can carry twenty points across thirty metres while a highway
/// straight carries two across a kilometre. Stepping by index therefore made
/// the head crawl through every junction and leap down every straight — which
/// looks like stuttering, because it is.
List<double> _cumulative = <double>[];
late final AnimationController _routeAnim;
/// ── The route flow ──
///
/// The draw-in happens once and is over. This is what keeps the line reading
/// as a *direction of travel* rather than as a static shape.
///
/// It used to be a comet: an 18%-of-the-route window sliding from one end to
/// the other. At any moment the rider saw a short white dash a few hundred
/// metres long somewhere in the middle of his journey, and a dash moving over
/// a line reads as a decoration attached to the map rather than as anything
/// about his trip. On a long route it was worse — 18% of 9 km is a smear that
/// never visibly connects the two ends.
///
/// Now it is one continuous stream: white grows from the rider's own position,
/// follows every bend of the real geometry to the stop, holds for a beat, and
/// then fades from the destination back down the line to him. That is the
/// shape of the fact the animation is stating — *this route, from you, to
/// there* — and it is what Uber's own route flow does.
late final AnimationController _flowAnim;
/// Where the cycle changes phase, as fractions of [_flowAnim].
///
/// Recomputed with the controller's duration whenever a route arrives, since
/// the grow phase is timed by distance (see [_restartFlow]) while the hold and
/// the fade are fixed.
double _growShare = 0.68;
double _holdShare = 0.06;
// ── Live distance / ETA ──
// The rider is moving while this screen is open, so a distance captured on
// entry is stale within a block. A position stream keeps the strip honest;
// `distanceFilter` throttles it to every 15 m so it isn't a per-fix rebuild
// storm on a phone that is also running Google Maps.
StreamSubscription<Position>? _positionSub;
Position? _livePosition;
// Corporate Red Theme Colors
static Color get kPrimary => ColorConstants.primary;
/// The halo around the flowing stroke.
///
/// A dark white, painted wider than the core and underneath it, so the bright
/// stroke has something to sit in and reads as light falling on the road
/// rather than as a white sticker laid along it.
///
/// Kept warm rather than neutral grey: the route underneath is red, and a cool
/// grey over it turns muddy at the alpha the halo runs at.
static const Color _flowHaloColour = Color(0xFFA89E9E);
/// Whether the platform has asked for reduced motion.
///
/// Read from the widget tree rather than cached, because a rider can change
/// it in system settings while this screen is open — and the looping stream
/// is exactly the kind of thing somebody turns that setting on to stop.
bool get _reducedMotion =>
MediaQuery.maybeDisableAnimationsOf(context) ?? false;
/// How fast the stream travels, in metres of real route per second of
/// animation. ~1.9 km/s puts an ordinary 3 km leg at a little over a second
/// and a half, which is a glance rather than something waited out.
static const double _flowMetresPerSecond = 1900;
/// Grow-phase bounds. See [_restartFlow] for why the speed is what is held
/// constant and these are only the extremes.
///
/// Both moved down with the speed. Leaving the floor where it was would have
/// meant every short leg — the ones a first-mile rider actually runs — pinned
/// to the clamp and therefore unchanged, which is where the line read slow in
/// the first place.
static const double _flowGrowMinMs = 1000;
static const double _flowGrowMaxMs = 3300;
/// The beat at the destination before the line drains.
static const int _flowHoldMs = 220;
/// How long the stream takes to drain back to the rider. Kept a shade under
/// the grow floor: a drain slower than the stroke that produced it makes the
/// whole cycle feel like it is sagging at the end.
static const int _flowFadeMs = 700;
/// The bright stroke, at its brightest. 0.62 was why it "looked very lightly"
/// — over a 6pt brand-red line a 62% white is a pink smear.
/// ── Toned down: a direction cue, not something to watch ──
///
/// This ran at 0.96 — effectively solid white — over a brand-red route on a
/// pale map. Held at arm's length the brightest, fastest-moving thing on the
/// screen was the animation, not the destination pin and not the ETA, and a
/// rider's eye goes to movement before it goes to anything else.
///
/// The stream still says *this way*, which is its whole job. It just no
/// longer says it louder than the two facts the screen exists to deliver.
static const double _flowCoreAlpha = 0.55;
static const double _flowHaloAlpha = 0.24;
static Color get kBackground => ColorConstants.surfaceContainerLow;
static Color get kTextPrimary => ColorConstants.onSurface;
static const Color kCardBg = Colors.white;
@override
void initState() {
super.initState();
_camera = MilerMapCamera(controller: _mapController, vsync: this);
_routeAnim =
AnimationController(
vsync: this,
// 1400 → 1800. The line covers the whole journey, and at 1.4s over a 7km
// route it read as a wipe rather than as something travelling.
duration: const Duration(milliseconds: 1800),
)
..addListener(_onRouteAnimTick)
// The pulse only starts once the route has finished drawing — running
// both at once would put two heads on the same line.
..addStatusListener((status) {
if (status == AnimationStatus.completed && mounted) {
_restartFlow();
}
});
// Duration is set per route — see [_restartFlow].
_flowAnim = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2400),
)..addListener(_onFlowTick);
WidgetsBinding.instance.addObserver(this);
_resolveLocations();
_setMarkers();
_createRealRoute();
_startLiveTracking();
_stampDeparture();
// NOTE: We intentionally do NOT auto-launch Google Maps here. "Start pickup"
// opens this preview (map + customer + Navigate / I've arrived). Google Maps
// only opens when the rider explicitly taps "Navigate".
}
/// Records the moment the rider set off for this stop.
///
/// Opening this screen *is* setting off: it is what "Start pickup" leads to,
/// and from here on he is riding. Paired with the arrival stamp written when
/// he confirms he is at the door, it is what lets Activity answer "how long
/// did that stop take me" — a figure the app was measuring nowhere despite
/// billing per kilometre on the same journey.
///
/// Written once. A rider who backs out to check the list and comes back is on
/// the same errand, and re-stamping would quietly erase the ride he has
/// already done.
Future<void> _stampDeparture() async {
final pickupId = (widget.pickup['pickupid'] ?? '').toString();
if (pickupId.isEmpty) return;
try {
final prefs = await SharedPreferences.getInstance();
final key = kStopStartedKey(pickupId);
if (prefs.getString(key) == null) {
await prefs.setString(key, DateTime.now().toIso8601String());
}
} catch (e) {
debugPrint('[MAP] Could not stamp departure: $e');
}
}
/// Launches Google Maps turn-by-turn navigation to the pickup location.
/// Tries the native navigation intent first, then falls back to the web URL.
Future<bool> _openGoogleMapsNavigation() async {
if (_hasOpenedNavigation) return true;
try {
double? originLat, originLng;
try {
final pos = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.medium,
timeLimit: Duration(seconds: 3),
),
).timeout(const Duration(seconds: 3));
originLat = pos.latitude;
originLng = pos.longitude;
} catch (_) {}
final destLat = _pickupLocation.latitude;
final destLng = _pickupLocation.longitude;
final nativeUri = Uri.parse(
'google.navigation:q=$destLat,$destLng&mode=d',
);
final originParam = (originLat != null && originLng != null)
? '&origin=$originLat,$originLng'
: '';
final webUri = Uri.parse(
'https://www.google.com/maps/dir/?api=1$originParam'
'&destination=$destLat,$destLng&travelmode=driving&dir_action=navigate',
);
bool launched = false;
try {
launched = await launchUrl(
nativeUri,
mode: LaunchMode.externalApplication,
);
} catch (e) {
debugPrint('[MAP_NAV] Native intent failed: $e');
}
if (!launched) {
try {
launched = await launchUrl(
webUri,
mode: LaunchMode.externalApplication,
);
} catch (e) {
debugPrint('[MAP_NAV] Web intent failed: $e');
}
}
if (launched && mounted) {
setState(() => _hasOpenedNavigation = true);
return true;
}
} catch (e) {
debugPrint('[MAP_NAV] Error opening navigation: $e');
}
return false;
}
/// Follows the rider while the stop screen is open so the distance and ETA
/// count down as he rides. Best-effort: if permission or GPS is unavailable
/// the strip falls back to the last known booking coordinates rather than
/// disappearing.
void _startLiveTracking() {
try {
_positionSub =
Geolocator.getPositionStream(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
distanceFilter: 15,
),
).listen(
(pos) {
if (!mounted) return;
setState(() => _livePosition = pos);
// The first fix is what turns "somewhere near the stop" into a
// route the rider can actually ride — see [_routeOrigin]. Drawn
// once, not on every fix: re-routing every 15 m would burn
// Directions quota and make the line twitch under his thumb.
if (!_routedFromRider) _createRealRoute();
},
onError: (e) => debugPrint('[MAP_LIVE] Position stream error: $e'),
);
} catch (e) {
debugPrint('[MAP_LIVE] Could not start position stream: $e');
}
}
/// Where the drawn route starts.
///
/// ── Why this is the rider and not the pickup ──
///
/// The line used to run pickup → drop: the *parcel's* journey, from the
/// customer's door to wherever it is going afterwards. That is a fact about
/// the booking, not about the next twenty minutes of the rider's life — and
/// it contradicted everything else on the screen, because the ETA, the
/// distance and the Navigate button were all measured rider → stop. He was
/// shown "8 min · 2.4 km" over a line that went somewhere else entirely, and
/// on a stop where the drop was miles away the camera framed a route that did
/// not contain him.
///
/// Falls back to the pickup location when there is no fix yet, so the first
/// frame still draws something honest; the first real fix re-routes.
LatLng? get _routeOrigin {
final live = _livePosition;
if (live != null) return LatLng(live.latitude, live.longitude);
final lat = _parseD(widget.pickup['riderslat']);
final lng = _parseD(widget.pickup['riderslon']);
if (lat != 0 && lng != 0) return LatLng(lat, lng);
return null;
}
/// Metres from the rider to this stop, live where possible.
double? get _liveMetersToStop {
final lat = _livePosition?.latitude;
final lng = _livePosition?.longitude;
if (lat != null && lng != null) {
return RouteMetricsHelper.distanceMeters(
lat,
lng,
_pickupLocation.latitude,
_pickupLocation.longitude,
);
}
return RouteMetricsHelper.metersToStop(widget.pickup);
}
@override
void dispose() {
_positionSub?.cancel();
WidgetsBinding.instance.removeObserver(this);
_routeAnim.dispose();
_flowAnim.dispose();
_mapRepaint.dispose();
_camera.dispose();
_mapController.dispose();
super.dispose();
}
void _resolveLocations() {
final d = widget.pickup;
final double pickLat = _parseD(d['pickuplat'] ?? d['PickupLat']);
final double pickLon = _parseD(d['pickuplon'] ?? d['PickupLon']);
final double dropLat = _parseD(
d['droplat'] ?? d['DropLat'] ?? d['PickupyLat'],
);
final double dropLon = _parseD(
d['droplon'] ?? d['DropLon'] ?? d['PickupyLong'],
);
final double riderLat = _parseD(d['riderslat']);
final double riderLon = _parseD(d['riderslon']);
final bool hasPickup = pickLat != 0 && pickLon != 0;
final bool hasDrop = dropLat != 0 && dropLon != 0;
// ── Which end of the journey this screen is about ──
//
// `_pickupLocation` is the stop the rider is being sent to, and on a milk
// run that is not always the pickup. Once an order is collected the next
// journey is to the **customer**, and opening the kitchen again would send
// him back for a bag already in his box.
//
// The rule lives in [MilkRun.navigatesToCustomer] so this screen, the
// detail sheet and the card cannot disagree. A logistics stop is never
// affected: its collected parcel goes to a hub, not to a door.
final bool toCustomer = MilkRun.navigatesToCustomer(d);
_pickupLocation = (toCustomer && hasDrop)
? LatLng(dropLat, dropLon)
: hasPickup
? LatLng(pickLat, pickLon)
: (riderLat != 0 && riderLon != 0
? LatLng(riderLat, riderLon)
: const LatLng(10.998356, 76.977596));
_dropLocation = hasDrop
? LatLng(dropLat, dropLon)
: const LatLng(11.004556, 76.967696);
// Sensible ends before the first route attempt has decided them, so nothing
// downstream can read them uninitialised.
_routeStart = _pickupLocation;
_routeEnd = _dropLocation;
}
double _parseD(dynamic v) {
if (v == null) return 0.0;
if (v is num) return v.toDouble();
return double.tryParse(v.toString()) ?? 0.0;
}
/// Two plain pins: where the rider is going, and where the parcel ends up.
///
/// The address callouts that briefly lived here are gone. On a screen whose
/// point is the route, a label large enough to read is also large enough to
/// cover the line it is anchored to — and the sheet below already carries the
/// customer, the full address and the live ETA, at a size that does not have
/// to compete with map tiles. The pins mark the ends; the sheet says what
/// they are.
void _setMarkers() {
// The leg, not the payload — see [MilkRun.workingKind].
final kind = MilkRun.workingKind(widget.pickup);
// ── One pin per PLACE, not per concept ──
//
// On the delivery leg the navigation target IS the drop, so the accent
// pin and the green flag were the same coordinates twice — two teardrops
// stacked into one smudge at one end of the route, while the route's
// other end (the rider) had no marker at all. The flag now appears only
// when the parcel's final drop is genuinely a different place from the
// stop being navigated to (a logistics leg whose payload carries both);
// the rider's own end is marked live in [_liveMarkers].
final double gap = RouteMetricsHelper.distanceMeters(
_pickupLocation.latitude,
_pickupLocation.longitude,
_dropLocation.latitude,
_dropLocation.longitude,
);
_markers
..clear()
..addAll([
milerMarker(
point: _pickupLocation,
width: MilerPin.size + 8,
height: MilerPin.size + MilerPin.tail,
// The stop takes the colour its card carried, not a stock hue. A red
// teardrop and a green one told the rider "map pin" twice; the accent
// tells him which of the three kinds of stop this is.
child: MilerPin(
color: kind.accent,
icon: kind.icon,
emphasised: true,
),
),
if (gap > 30)
milerMarker(
point: _dropLocation,
width: MilerPin.size + 8,
height: MilerPin.size + MilerPin.tail,
child: MilerPin(
color: ColorConstants.acceptGreen,
icon: LucideIcons.flag,
),
),
]);
}
/// The stored pins plus the rider's own dot, read fresh each build so the
/// dot rides the position stream. The route line used to run from the
/// rider's fix to the stop with a pin at only ONE of those ends — a stroke
/// leaving the frame into nothing is the map claiming the journey starts
/// nowhere.
List<Marker> _liveMarkers() {
final live = _livePosition;
return [
..._markers,
if (live != null)
milerMarker(
point: LatLng(live.latitude, live.longitude),
width: MilerRiderDot.size,
height: MilerRiderDot.size,
child: const MilerRiderDot(),
),
];
}
/// Swaps the stock teardrops for callouts carrying the address.
///
/// ── Why a bitmap and not a widget ──
///
/// Google Maps markers are textures, not part of the Flutter tree, so a label
/// beside a pin cannot be a `Text`. The alternative the plugin offers is
/// `InfoWindow`, which only appears on tap and covers the route it is anchored
/// to — which is why it was doing no work here and has been removed.
///
/// Uber and Rapido both draw the address *into* the marker so it is readable
/// without touching anything: on a map, the question "which building is this"
/// is the whole reason the pin exists.
/// Stops the travelling highlight whenever the screen is not being looked at.
///
/// The pulse repeats for as long as this route is open, and a rider can sit on
/// this screen for a whole leg. A looping animation is one frame of work every
/// 16ms — harmless while he is watching it, pure drain the moment he switches
/// to Google Maps for turn-by-turn, which is exactly what the Navigate button
/// does. It resumes on return, so he never sees the difference.
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (!mounted) return;
final visible = state == AppLifecycleState.resumed;
if (!visible && _flowAnim.isAnimating) {
_flowAnim.stop();
} else if (visible &&
!_flowAnim.isAnimating &&
_routeAnim.status == AnimationStatus.completed) {
_restartFlow();
}
}
Future<void> _createRealRoute() async {
// Rider → stop where we know where he is; the stop's own leg otherwise.
// Recorded so the first GPS fix knows whether it still owes us a redraw.
final origin = _routeOrigin;
_routeStart = origin ?? _pickupLocation;
_routeEnd = origin != null ? _pickupLocation : _dropLocation;
_routedFromRider = origin != null;
// ── No key to be missing ──
//
// This used to check `ApiConstants.hasMapsKey` and give up before asking,
// because the Directions API needed a credential the app did not have. OSRM
// needs none, so the only question left is whether the network answered.
try {
final points = await MilerRouter.route(_routeStart, _routeEnd);
if (points.isEmpty) {
_useDirectLine();
return;
}
_routePoints = points;
_buildDistanceTable();
if (!mounted) return;
setState(() {
// Faint full-route "casing" (same red, low opacity) gives depth so
// the bright line reads as drawing *over* the known path.
_casingLine = Polyline(
points: _routePoints,
color: kPrimary.withValues(alpha: 0.18),
strokeWidth: 9,
strokeCap: StrokeCap.round,
strokeJoin: StrokeJoin.round,
);
_isLoadingRoute = false;
});
// ── Camera first, then the line ──
//
// These used to fire together, so the route drew itself while the map
// was still flying to frame it — by the time the camera settled the
// line was already finished, which is why it looked static. Uber lands
// the camera, lets it rest for a beat, and only then traces the route.
unawaited(
_fitMapToRoute().then((_) async {
if (!mounted) return;
await Future<void>.delayed(const Duration(milliseconds: 250));
if (mounted) _routeAnim.forward(from: 0);
}),
);
} catch (e) {
debugPrint('[MAP] Route error: $e');
_useDirectLine();
}
}
/// Draws the straight line between the two ends when the road route cannot
/// be had.
///
/// ── Why draw anything at all ──
///
/// The route request failing used to clear the spinner and leave the map
/// bare. That is the worst of both: the screen looks finished, so the rider
/// reads the absence of a line as "there is no route", and the one thing the
/// map could still tell him honestly — which direction the stop is in, and
/// roughly how far — was thrown away with the error.
///
/// Dashed rather than solid, which is the whole of the disclosure: it cannot
/// be mistaken for turn-by-turn guidance at a glance, and it is the only
/// signal available now that the screen has no chrome to carry a message.
/// Navigate still hands off to Google Maps, which routes with its own key and
/// is unaffected by whatever failed here. The cause is in the log.
void _useDirectLine() {
_routePoints = <LatLng>[_routeStart, _routeEnd];
_buildDistanceTable();
if (!mounted) {
_isLoadingRoute = false;
return;
}
setState(() {
_casingLine = Polyline(
points: _routePoints,
color: kPrimary.withValues(alpha: 0.35),
strokeWidth: 5,
// Dashed, so it reads as an approximation rather than a road.
pattern: StrokePattern.dashed(segments: const [24, 14]),
strokeCap: StrokeCap.round,
);
_isLoadingRoute = false;
});
_fitMapToRoute();
}
/// Measures the route so both animations can move at a constant speed.
///
/// Longitude is scaled by cos(latitude) so a degree east is worth what it is
/// actually worth at this latitude — without it a north-south leg and an
/// east-west leg of the same real length would be measured differently, and
/// the head would change pace when the road turned a corner.
void _buildDistanceTable() {
_cumulative = List<double>.filled(_routePoints.length, 0);
if (_routePoints.length < 2) return;
final meanLat =
(_routePoints.first.latitude + _routePoints.last.latitude) / 2;
final lonScale = math.cos(meanLat * math.pi / 180).abs();
var run = 0.0;
for (var i = 1; i < _routePoints.length; i++) {
final a = _routePoints[i - 1];
final b = _routePoints[i];
final dy = b.latitude - a.latitude;
final dx = (b.longitude - a.longitude) * lonScale;
run += math.sqrt(dx * dx + dy * dy);
_cumulative[i] = run;
}
}
/// The segment index and the fraction across it at [f] of the route's LENGTH.
///
/// Binary search rather than a scan: this runs several times a frame, and a
/// linear walk over a few thousand vertices is exactly the kind of per-frame
/// cost that shows up as jank on a mid-range phone.
(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) >> 1;
if (_cumulative[mid] <= target) {
lo = mid;
} else {
hi = mid - 1;
}
}
final i = lo.clamp(0, _routePoints.length - 2);
final span = _cumulative[i + 1] - _cumulative[i];
return (i, span <= 0 ? 0.0 : (target - _cumulative[i]) / span);
}
LatLng _pointOn(int i, double frac) {
final a = _routePoints[i];
final b = _routePoints[math.min(i + 1, _routePoints.length - 1)];
return LatLng(
a.latitude + (b.latitude - a.latitude) * frac,
a.longitude + (b.longitude - a.longitude) * frac,
);
}
/// Everything from the rider up to [head] of the route's LENGTH, with the
/// leading end interpolated between vertices.
///
/// The interpolated tip is what keeps the growing end from snapping vertex to
/// vertex: without it the head would jump to the nearest recorded point each
/// frame, which on a dense corner is a visible twitch.
List<LatLng> _prefixByDistance(double head) {
if (_routePoints.length < 2) return const <LatLng>[];
final (idx, frac) = _atDistance(head);
final out = <LatLng>[..._routePoints.sublist(0, idx + 1)];
if (idx < _routePoints.length - 1) out.add(_pointOn(idx, frac));
return out;
}
/// Reveals the bright red route line progressively, once, as an entrance.
///
/// Eased, unlike the flow that follows it: this is the line arriving, and an
/// entrance is allowed to accelerate. See [_onFlowTick] for why travel is not.
void _onRouteAnimTick() {
if (!mounted || _routePoints.length < 2) return;
final double t = Curves.easeInOutCubic.transform(_routeAnim.value);
_progressLine = Polyline(
points: _prefixByDistance(t),
color: kPrimary,
// Heavier than the 5 it was, so the drawn line reads as the route and
// the casing beneath it as the ground it covers.
strokeWidth: 6,
strokeCap: StrokeCap.round,
strokeJoin: StrokeJoin.round,
);
_mapRepaint.value++;
}
/// Total route length in metres.
///
/// [_cumulative] is in latitude-degrees with longitude already scaled by
/// cos(lat), so one unit is one degree of latitude — 111.32 km — everywhere on
/// the route. Good to a fraction of a percent at city scale, and free, which
/// matters because this is only choosing an animation duration.
double get _routeMetres =>
_cumulative.isEmpty ? 0 : _cumulative.last * 111320.0;
/// Starts the flow cycle, timed for this particular route.
///
/// ── Why the duration is not a constant ──
///
/// A fixed grow time makes the stream crawl on a 600 m hop and tear across a
/// 9 km one — the same animation reading as two different speeds, which is
/// exactly what "constant speed regardless of route length" rules out. So the
/// grow phase is timed by distance at [_flowMetresPerSecond] and the stream
/// moves at one pace on every stop the rider takes all day.
///
/// Clamped at both ends only so the extremes stay watchable: under the floor a
/// very short leg would flash, and over the ceiling a cross-city route would
/// hold the rider's eye for longer than a glance at a map is worth. Between
/// them — which is every ordinary first-mile leg — the speed is genuinely
/// constant.
void _restartFlow() {
if (!mounted || _routePoints.length < 2) return;
// ── Reduced motion means no loop at all ──
//
// A stream that runs for as long as the screen is open is the one thing on
// here a rider with a vestibular disorder cannot look away from — the map
// is the screen. When the platform asks for reduced motion the route is
// simply drawn, complete and still: every piece of navigation information
// survives, and the only thing lost is the part that was decoration.
if (_reducedMotion) {
_flowAnim.stop();
_flowHalo = null;
_flowCore = null;
_mapRepaint.value++;
return;
}
final growMs = (_routeMetres / _flowMetresPerSecond * 1000)
.clamp(_flowGrowMinMs, _flowGrowMaxMs)
.round();
final totalMs = growMs + _flowHoldMs + _flowFadeMs;
_growShare = growMs / totalMs;
_holdShare = _flowHoldMs / totalMs;
_flowAnim
..duration = Duration(milliseconds: totalMs)
..repeat();
}
/// One cycle of the flow: grow → hold → fade.
///
/// 0 ──────────────── grow ────────────────▶ hold ▶─── fade ───▶ 1
/// white leaves the rider reaches the stop drains back
///
/// **Grow** is linear on purpose, and sliced by *length* rather than by vertex
/// index. The draw-in is eased because it is an entrance; this is travel, and
/// an eased stream appears to slow down in the middle of the road for no
/// reason. Slicing by length is what keeps it at one pace through a dense
/// junction and a long straight alike — see [_cumulative].
///
/// **Fade** is a gradient, not a uniform alpha. A whole line dimming at once
/// is a light being switched off; a front travelling back down the route from
/// the stop to the rider is the stream draining, which is the same motion in
/// reverse and reads as one continuous idea. `flutter_map` will shade a
/// polyline between its two endpoints, so the ramp costs nothing but the
/// stops.
void _onFlowTick() {
if (!mounted || _routePoints.length < 2 || _cumulative.isEmpty) return;
final frame = routeFlowFrame(
_flowAnim.value,
growShare: _growShare,
holdShare: _holdShare,
);
final List<LatLng> points;
final List<Color>? coreShade;
final List<Color>? haloShade;
double flatAlpha = 1;
if (frame.phase == RouteFlowPhase.grow) {
final head = frame.progress;
points = _prefixByDistance(head);
// A short ramp off the rider's pin so the stream starts rather than
// appears. Without it the first frame is a full-strength stub.
flatAlpha = (head / 0.06).clamp(0.0, 1.0);
coreShade = null;
haloShade = null;
} else if (frame.phase == RouteFlowPhase.hold) {
// Arrived. A beat at full strength, so reaching the stop registers as an
// event instead of being the frame the fade happens to start on.
points = _routePoints;
coreShade = null;
haloShade = null;
} else {
final f = frame.progress;
points = _routePoints;
coreShade = [
for (final x in kRouteFlowStops)
Colors.white.withValues(
alpha: _flowCoreAlpha * routeFlowFadeAlpha(f, x),
),
];
haloShade = [
for (final x in kRouteFlowStops)
_flowHaloColour.withValues(
alpha: _flowHaloAlpha * routeFlowFadeAlpha(f, x),
),
];
// Nothing left to draw. Clearing rather than returning early matters: a
// bail-out leaves the previous frame's geometry on the map, which is what
// makes a loop look like it freezes before it restarts.
if (coreShade.every((c) => c.a < 0.01)) {
if (_flowHalo != null || _flowCore != null) {
_flowHalo = null;
_flowCore = null;
_mapRepaint.value++;
}
return;
}
}
if (points.length < 2) {
if (_flowHalo != null || _flowCore != null) {
_flowHalo = null;
_flowCore = null;
_mapRepaint.value++;
}
return;
}
// Two concentric strokes on the same geometry — a soft halo and a bright
// core — rather than a chain of bands at descending widths. Chained bands
// show their joins as beads, which is the "dotted line" this used to be.
//
// The core is deliberately narrower than the red beneath it (4 against 6) so
// the route stays red at its edges the whole way through. A white line as
// wide as the route does not flow through it; it replaces it.
_flowHalo = Polyline(
points: points,
color: _flowHaloColour.withValues(alpha: _flowHaloAlpha * flatAlpha),
gradientColors: haloShade,
colorsStop: haloShade == null ? null : kRouteFlowStops,
strokeWidth: 10,
strokeCap: StrokeCap.round,
strokeJoin: StrokeJoin.round,
);
_flowCore = Polyline(
points: points,
color: Colors.white.withValues(alpha: _flowCoreAlpha * flatAlpha),
gradientColors: coreShade,
colorsStop: coreShade == null ? null : kRouteFlowStops,
strokeWidth: 4,
strokeCap: StrokeCap.round,
strokeJoin: StrokeJoin.round,
);
// Only the map repaints — see [_mapRepaint].
_mapRepaint.value++;
}
/// Frames the leg that is actually drawn.
///
/// Framed on the line the rider is on — his position to the stop once GPS has
/// a fix, not the booking's pickup → drop pair. The camera used to fit a route
/// the rider was not on.
///
/// The ten-attempt retry loop this replaces existed because
/// `GoogleMapController.animateCamera` threw until the platform view had
/// finished creating itself. `flutter_map` is a Flutter widget: its camera is
/// available as soon as the map is in the tree, so there is nothing to retry.
Future<void> _fitMapToRoute() async {
if (!mounted) return;
// ── The frame is measured from the sheet, not from a remembered number ──
//
// `300.h` was the sheet's height on one device. On a 320×568 phone it is
// more than half the viewport and the route was framed into a sliver; at
// 2.0× text, where the sheet has to grow, it was short and the stop pin
// ended up underneath it. Both are the same bug: two numbers describing
// one thing, kept in step by hand.
//
// Derived instead — the working extent of the sheet plus a margin, and the
// top inset clears the status bar and the Back control rather than
// assuming 90 does.
final media = MediaQuery.of(context);
final viewport = media.size.height;
final top = media.padding.top + 72;
await _camera.fit(
<LatLng>[_routeStart, _routeEnd],
// The sheet is lifted by the gesture bar, so the space it covers is
// taller than the bare fraction — see [_sheetLift]. Framing against the
// old figure would tuck the route's near end under the sheet.
padding: EdgeInsets.fromLTRB(
48,
top,
48,
viewport * (_sheetWorking + _sheetLift(media)) + 24,
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: kBackground,
body: Stack(
children: [
// Held back until the push has landed — building a Google Maps
// platform view costs the UI thread enough to eat the slide-in
// whole. See [AfterEntrance].
AfterEntrance(
placeholder: const MapPlaceholder(),
builder: (context) =>
// Rebuilt on its own, sixty times a second, without touching
// the header or the sheet — see [_mapRepaint].
ValueListenableBuilder<int>(
valueListenable: _mapRepaint,
builder: (context, _, _) => MilerMap(
controller: _mapController,
initialCenter: _pickupLocation,
initialZoom: 14,
polylines: _routeLayers,
markers: _liveMarkers(),
onReady: () {
if (!_isLoadingRoute && _routeLayers.isNotEmpty) {
_fitMapToRoute();
}
},
),
),
),
// ── The map is never blanked while it is still useful ──
//
// This dimmed the whole screen behind a spinner while the road route
// loaded. But the map is already showing the right place, the stop
// pin is already on it, and if routing fails the screen falls back
// to a dashed direct line and carries on — so at no point is the
// view actually unusable. Dimming it said "wait" about something the
// rider did not have to wait for, on the one screen he opens while
// moving.
//
// A small mark at the top edge instead: enough to explain why the
// line has not appeared, small enough to ignore.
if (_isLoadingRoute)
Positioned(
top: MediaQuery.of(context).padding.top + 20,
right: 20.w,
child: IgnorePointer(
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 12.w,
vertical: 8.h,
),
decoration: BoxDecoration(
color: ColorConstants.pureSurface.withValues(alpha: 0.92),
borderRadius: BorderRadius.circular(
DesignConstants.radiusFull,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 12.w,
height: 12.w,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(kPrimary),
),
),
SizedBox(width: 8.w),
Text(
'Finding the route',
style: MilerType.micro.copyWith(
fontSize: 12.5.sp,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
),
_buildHeader(),
_buildDraggableBottomSheet(),
],
),
);
}
Widget _buildHeader() {
return Positioned(
top: MediaQuery.of(context).padding.top + 12,
left: 16,
right: 16,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
decoration: BoxDecoration(
color: ColorConstants.pureSurface,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
// ── The one control over the map, at a real size ──
//
// `constraints: BoxConstraints()` removes `IconButton`'s own 48×48
// floor, and with a 16pt glyph and 12pt padding the target was
// 40×40 — under the accessibility minimum, on a control pressed
// with a glove, at a junction, one-handed. The *visible* circle
// can stay compact; the thing the finger has to find cannot.
//
// The glyph goes up to 18 as well: 16 is small for the only
// affordance on a full-screen map.
child: Semantics(
button: true,
label: 'Back to the queue',
child: IconButton(
tooltip: 'Back',
onPressed: () => Navigator.pop(context),
icon: Icon(
LucideIcons.chevronLeft,
color: kTextPrimary,
size: 18,
),
padding: const EdgeInsets.all(12),
constraints: BoxConstraints(
minWidth: ButtonSizes.minTapTarget,
minHeight: ButtonSizes.minTapTarget,
),
),
),
),
// The floating "Pickup Location" / "Delivery Location" pill that used
// to sit here is gone, along with the 44pt spacer that existed only to
// centre it against the back button.
//
// It was a title for the map rather than information about the stop:
// the sheet below already names the customer, prints the address and
// carries the live ETA, and the rider arrived here from a card that
// told him the stop type. Floating chrome over a map costs the one
// thing a map screen is for — visible map.
],
),
);
}
/// The three resting heights of the sheet, as fractions of the viewport.
///
/// ── Why these are constants and not literals at the call site ──
///
/// The camera has to know how much of the screen the sheet covers, or it
/// frames a route into the space the sheet is sitting on. That was a
/// hardcoded `300.h` in `_fitMapToRoute` — a number that agreed with `0.45`
/// only on the phone it was measured on, and silently disagreed on every
/// other height, and would have gone on disagreeing after any change to the
/// sheet.
///
/// Three stops and no more: a glance, the working height, and the detail
/// view. A drag with many arbitrary rest points feels loose and gives the
/// rider nothing to aim at.
/// The system's own bar at the foot of the screen, as a fraction of the
/// viewport — so the three snap fractions below can be lifted clear of it.
///
/// The snaps are fractions and the bar is an absolute height, so the two only
/// meet through the viewport. Clamped because a fraction derived from a
/// measurement should never be able to swallow the sheet on a device that
/// reports something strange.
double _sheetLift(MediaQueryData media) {
final double height = media.size.height;
if (height <= 0) return 0;
final double bar = math.max(
media.viewInsets.bottom,
media.viewPadding.bottom,
);
return (bar / height).clamp(0.0, 0.15);
}
static const double _sheetGlance = 0.35;
static const double _sheetWorking = 0.45;
static const double _sheetDetail = 0.75;
Widget _buildDraggableBottomSheet() {
// ── Every stop is lifted by the height of the gesture bar ──
//
// Padding the content alone would have kept the sheet the same height and
// simply moved everything in it up, costing the rider a bar's worth of the
// stop he is reading. Growing the sheet by the same amount puts the glass
// *behind* the bar and leaves the visible content area exactly the size it
// was — which is what "the sheet is touching the navigation" actually asks
// for. The two work together: this makes room, the list's bottom padding
// keeps the CTA out of it.
final double lift = _sheetLift(MediaQuery.of(context));
final double glance = (_sheetGlance + lift).clamp(0.0, 1.0);
final double working = (_sheetWorking + lift).clamp(0.0, 1.0);
final double detail = (_sheetDetail + lift).clamp(0.0, 1.0);
return DraggableScrollableSheet(
initialChildSize: working,
minChildSize: glance,
maxChildSize: detail,
// Snap to the three, so the sheet always settles somewhere meant.
snap: true,
snapSizes: [glance, working, detail],
// ── Frosted, not opaque ──
//
// A solid white slab over a map takes away 45% of the one thing this
// screen exists to show, permanently. Frosted, the rider keeps the block
// his stop is on in view while he reads the address printed over it. See
// [milerGlassSheet].
builder: (context, scrollController) => milerGlassSheet(
child: Column(
children: [
const MilerSheetHandle(),
Expanded(
// Content settles in (fade + gentle rise) as the route finishes
// loading, so the sheet feels alive alongside the drawing line.
// 500ms was long enough to notice on a screen the rider opens
// between stops — the route's own entrance is allowed to take
// its time because it is *explaining* the line appearing, but
// ordinary content settling should be over before he has
// finished looking at it. `motionState` is the app's own token
// for a state change in place.
child: AnimatedSlide(
duration: DesignConstants.motionState,
curve: Curves.easeOutCubic,
offset: _isLoadingRoute ? const Offset(0, 0.06) : Offset.zero,
child: AnimatedOpacity(
duration: DesignConstants.motionState,
opacity: _isLoadingRoute ? 0.0 : 1.0,
// ── One sheet, not a stack of cards ──
//
// This was two containers: a filled customer card, then an
// outlined white one holding the ETA and the address. An
// outline drawn around white, on a white sheet, is a line the
// rider has to read past to reach the only thing he came here
// for — and it is the opposite of what the rest of the app
// does, which is to fill a panel and never draw its edge (see
// [ColorConstants.cardSurface]).
//
// A bottom sheet is already a container. Everything inside it
// is one column: the ETA leads because it is the live fact,
// one hairline separates it from the stop it belongs to, and
// the actions close it. This is the shape Uber's arrival sheet
// has, and it is why that sheet reads at arm's length.
child: ListView(
controller: scrollController,
// ── The gesture bar sat on top of the CTA ──
//
// Eight points of bottom padding, on a sheet anchored to
// the bottom of the screen: the last thing in this list is
// [_buildPrimaryAction] — the slide control that closes the
// stop — and on a gesture-navigation phone the system's own
// bar was drawn across it. The rider's swipe either missed
// the control or went to the OS.
//
// The glass still bleeds to the screen edge, which is what
// it is for; what has to clear the bar is the *content*.
// Same rule the sheet kit applies to every modal sheet —
// `max(keyboard, gesture bar)`, never the sum, because the
// keyboard replaces the bar rather than stacking with it.
// This sheet is hand-built rather than presented through
// [MilerSheetScaffold], so it never inherited that rule.
padding: EdgeInsets.fromLTRB(
20.w,
16.h,
20.w,
8.h +
math.max(
MediaQuery.viewInsetsOf(context).bottom,
MediaQuery.viewPaddingOf(context).bottom,
),
),
children: [
_LiveEtaHeader(
meters: _liveMetersToStop,
speedMps: _livePosition?.speed,
live: _livePosition != null,
liveLabel: _isDeliveryLeg ? 'Picked up' : 'Active',
),
SizedBox(height: 18.h),
// ── Where the stop is in its life, as a line ──
//
// The sheet said how far away the door was and what to
// press, and never *where in the job* the rider was. The
// hairline that used to sit here was doing nothing but
// separating two blocks; a three-step rail separates
// them and answers the question at the same time — which
// is the whole difference between chrome and
// information.
_StageRail(
labels: _isDeliveryLeg
? const ['Collected', 'On the way', 'Delivered']
: const ['Accepted', 'On the way', 'Picked up'],
// Carrying it · riding · handing over, drawn. See
// [_StageRail] for why the words came off.
icons: _isDeliveryLeg
? const [
LucideIcons.package,
LucideIcons.bike,
LucideIcons.house,
]
: const [
LucideIcons.clipboardCheck,
LucideIcons.bike,
LucideIcons.package,
],
current:
(_isDeliveryLeg ? _setOff : _hasOpenedNavigation)
? 1
: 0,
),
SizedBox(height: 18.h),
// The rail answers "where am I in this job"; everything
// below answers "whose job is it". Two subjects, one
// hairline.
Divider(
height: 1,
thickness: 1,
color: ColorConstants.borderSubtle,
),
SizedBox(height: 16.h),
_buildStopBrief(),
SizedBox(height: 20.h),
_buildPrimaryAction(),
],
),
),
),
),
],
),
),
);
}
/// Who the stop is, and where — as rows on the sheet, not as cards on it.
///
/// ── The name used to be printed twice ──
///
/// A filled customer card carried `pickupcustomer` as its title, and the route
/// node inside the outlined box below carried the same string again as *its*
/// title. Two boxes, two borders and one fact stated twice, on the screen with
/// the least room for any of it: the sheet opens at 45% of the height and the
/// map owns the rest.
///
/// The block itself is [_StopBrief], shared with Update Status and Skip. Only
/// the status pill is local to this screen.
/// ── The status chip is gone; the phone takes its place ──
///
/// The chip printed the stop's rung — `Picked up`, `Accepted` — in the
/// trailing corner of the identity row, and it was the wrong fact in the
/// wrong place twice over. The rider is *on this screen because* of that
/// rung; the sheet he opened, the button at the foot of it and the map behind
/// it all say the same thing. And stacking the call button under it left the
/// two on different centre lines, so the corner never lined up with the name
/// it sits beside.
///
/// One control in that corner, vertically centred against the identity block:
/// the way to ring the person whose address is on the left. Calling is the
/// secondary operational action on this screen — navigation and the state
/// transition stay primary at the foot — so it is a quiet green tile, not a
/// labelled button.
///
/// Omitted entirely when the stop carries no number, rather than shown
/// disabled: a dead control in a corner is worse than an empty corner.
Widget _buildStopBrief() {
// ── The contact belongs to the leg, not to a key name ──
//
// This read `pickupcontactno` first on every leg, so a rider standing at a
// customer's door was offered a control the app announced as "Call
// customer" while its precedence was written for a kitchen. On today's
// payload there is only one number and it happens to be right both ways
// (see [StopContact]) — which is exactly the kind of accident that stops
// being an accident the day a drop contact ships.
//
// `MilkRun.navigatesToCustomer` is the same reader the destination, the
// route and the copy on this screen already use, so the number, the label
// and the pin cannot disagree about which leg this is.
final contact = StopContact.forLeg(
widget.pickup,
delivery: MilkRun.navigatesToCustomer(widget.pickup),
);
// ── One control in this corner, and it is the phone ──
//
// A navigate tile sat beside it for a while. Two discs in the corner of an
// identity row is a toolbar, and the second one duplicated what the slide
// at the foot of the sheet already does — sliding Start delivery hands off
// to Maps by itself. Calling is the only thing here the primary control
// cannot do.
return _StopBrief(
pickup: widget.pickup,
trailing: contact.isEmpty
? null
: _CallButton(
phone: contact.number,
size: 46.w,
round: true,
semanticLabel: contact.action,
),
);
}
/// True when the job at this stop is the hand-over rather than the
/// collection — the leg that starts and ends with this sheet's one button.
bool get _isDeliveryLeg => MilkRun.workingKind(
widget.pickup,
collectedIds: widget.parentState?._collectedIds ?? const <String>{},
).isDelivery;
/// True once the rider has set off from **this screen** — slid Start
/// delivery, or opened navigation on the collection leg.
///
/// ── Why this is not read from the consignment ──
///
/// It was, and it was wrong in the common case. `pickup-complete` already
/// leaves a hyperlocal consignment `Out_for_Delivery` on today's backend, so
/// the row arrives at this screen with nothing left to release — and the
/// sheet opened straight on *Slide to deliver*, skipping the setting-off
/// step entirely. A rider who has not left the kitchen was being offered the
/// control that closes the order.
///
/// The consignment answers "may this be delivered", which is [_startDelivery]'s
/// business and is asked there. What the sheet needs to know is whether *he*
/// has set off, and only this screen sees that.
bool _setOff = false;
/// ─────────────────────────────────────────────────────────────────────
/// ONE BUTTON, AND IT IS ALWAYS THE NEXT THING TO DO
///
/// The foot of this sheet used to carry two controls side by side — a
/// **Navigate** that leaves the app, and beside it whatever advanced the
/// stop. That is one control too many on the screen a rider looks at while
/// moving: two filled buttons of similar weight, only one of which is the
/// thing he came here to do, and the answer to "what now?" written twice.
///
/// There is one primary control now, and which one it is depends only on
/// where the stop stands:
///
/// ```
/// delivery not set off → ⟶ slide to start ride
/// (releases the load, then opens Maps)
/// on the road → ⟶ slide to deliver (photo optional)
/// pickup not started → ⟶ slide to start ride (opens Maps)
/// on the road → ⟶ slide to say you have arrived
/// ```
///
/// ── And all four of them slide ──
///
/// The delivery leg slid and the collection leg tapped, on the reasoning that
/// only a delivery's two acts are writes the rider cannot take back, and that
/// a slide is friction worth paying only for those.
///
/// The reasoning was about the *write*. What decides the control is the
/// *hand*: this is the one sheet in the app that is looked at while moving,
/// with the phone on a mount or held against a bar, and a 56pt tap target at
/// the foot of it is how any of these four get fired by a knuckle, a glove or
/// a pocket. Arriving is not a small mistake either — it stamps a time the
/// hub schedules against, and it is the one a bumped screen will fire while
/// the rider is still two streets away.
///
/// So the foot of this sheet is one control with one gesture, whichever leg
/// is running. That is worth more than matching each act's weight exactly:
/// a rider who has to work out which of two shapes today's stop is wearing
/// before he can act is paying more than the slide ever costs him. Both
/// remain reachable by a screen reader's double tap — see [MilerSlideAction].
/// ─────────────────────────────────────────────────────────────────────
Widget _buildPrimaryAction() {
if (_isNavigating) {
// The same footprint as the control it stands in for — see
// [MilerSlideAction.height]. A busy state a size smaller than the thing it
// replaces makes the sheet jump every time the rider commits.
return Container(
height: MilerSlideAction.height,
decoration: BoxDecoration(
color: ColorConstants.acceptGreen,
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
),
child: const Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2.5,
),
),
),
);
}
// ── A delivery has no arrival step ──
//
// There is no endpoint that records reaching a customer's door (see
// [MilkRun.deliveryArrivalIsLocalOnly]), so a button for it would write
// nothing. Once he sets off the stop is simply **active**, and the only
// question left is whether it was handed over.
if (_isDeliveryLeg) {
final onTheRoad = _setOff;
final slide = MilerSlideAction(
// The act, in the rider's words. "Start delivery" is what he is about
// to do; the track says how to do it.
label: onTheRoad ? 'Order delivered' : 'Slide to start ride',
icon: onTheRoad ? LucideIcons.check : LucideIcons.chevronRight,
color: ColorConstants.acceptGreen,
onCommit: onTheRoad ? _markDelivered : _startDelivery,
);
if (!onTheRoad) return slide;
// ── The other two answers, at the moment he learns them ──
//
// The slider says *delivered*, because that is what a rider intends. It
// is not always what happens: the customer is out, the gate is locked,
// the order is called off while he is standing there.
//
// Those two outcomes existed, but only inside [DeliveryProofPage] —
// behind a slide he had to complete first and a grey link under the
// button after that. So the way to report a delivery that did **not**
// happen was to commit to one that did, and a rider who could not find
// it either abandoned the screen or slid *delivered* over a parcel still
// in his box, which is the one lie the console cannot recover from.
//
// It sits under the slider now, quiet and second — the exception must be
// reachable without being as easy to hit as the happy path. Same sheet
// as the proof page's, so the two cannot word it differently. See
// [DeliveryProofPage.askOutcome].
return Column(
mainAxisSize: MainAxisSize.min,
children: [
slide,
SizedBox(height: 4.h),
TextButton(
onPressed: _isNavigating ? null : _reportCannotDeliver,
style: TextButton.styleFrom(
minimumSize: Size(double.infinity, ButtonSizes.minTapTarget),
padding: EdgeInsets.zero,
),
child: Text(
"Couldn't deliver? Skip or cancel",
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.1,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
),
],
);
}
// The collection leg. Same shape, same gesture, and the same rule: set off,
// then say you are there. What arriving *asks for* is [_handleArrived]'s
// business.
final onTheWay = _hasOpenedNavigation;
return MilerSlideAction(
// ── It says he is there, not what happened ──
//
// This read "I've handed over" on a meal run, and then opened a sheet
// asking whether the food was delivered, not delivered or skipped. The
// control had already answered its own question, so the two disagreed
// whenever the answer was anything but the happy one: a rider at a
// locked gate had to say *I've handed over* to be able to report that he
// had not.
// "Slide to start ride" on both legs, and it is the same act on both: the
// rider is setting off. Naming it after the app that opens — navigate —
// described the side effect rather than the thing he is doing, and it
// read as a different job from the delivery leg's identical step.
label: onTheWay ? "Arrived" : 'Start ride',
icon: onTheWay ? LucideIcons.check : LucideIcons.navigation,
// Green advances the job, maroon is brand and navigation.
color: onTheWay ? ColorConstants.acceptGreen : ColorConstants.primary,
onCommit: () async {
if (!onTheWay) {
_hasOpenedNavigation = false;
await _openGoogleMapsNavigation();
if (mounted) setState(() {});
return;
}
debugPrint('[ARRIVED] Slid to arrive on map screen');
await _handleArrived();
},
);
}
/// Posts the arrival and records it locally.
///
/// Deliberately the same two acts `_advanceStop` performs on Home, through
/// the same controller method, so the two entry points into the ARRIVED rung
/// cannot drift: one posts `reached` and one does not is precisely the state
/// this app was in.
///
/// Never throws and never blocks. See the call site.
/// Returns false only when the **server refused** the arrival. A network
/// failure returns true: see the call site.
Future<bool> _recordArrival() async {
final d = widget.pickup;
final orderId = (d['orderid'] ?? d['OrderId'] ?? '').toString();
final pickupId =
int.tryParse(
'${d['PickupId'] ?? d['pickupId'] ?? d['pickupid'] ?? 0}',
) ??
0;
if (pickupId <= 0) return true;
try {
final dc = Get.put(PickupsController(), permanent: true);
final ok = await dc.updateArrivedStatus(
pickupId: pickupId,
orderHeaderId:
int.tryParse('${d['orderheaderid'] ?? d['OrderHeaderId'] ?? 0}') ??
0,
pickupLat: _parseD(d['pickuplat'] ?? d['PickupLat']).toStringAsFixed(6),
pickupLng: _parseD(d['pickuplon'] ?? d['PickupLon']).toStringAsFixed(6),
);
if (!ok && dc.lastArrivalRefusal != null) {
if (mounted) AppFeedback.error(context, dc.lastArrivalRefusal!);
return false;
}
// The rung the rider sees, on the row he is looking at and in the store
// that survives the refresh this flow triggers. `reached` does not
// persist on every deployment yet — see `getArrivedOrderIds` — and the
// precedence rule means this can only ever speak where the server has
// said nothing further along.
d['orderstatus'] = 'arrived';
if (orderId.isNotEmpty) {
await addArrivedOrderIds([orderId]);
await stampOrderEvent(orderId, OrderEvent.arrivedAtPickup);
}
if (mounted) setState(() {});
return true;
} catch (e) {
// A thrown exception is this app's own fault, not the hub's answer. The
// rider carries on for the same reason a dead network lets him.
debugPrint('[ARRIVED] could not record arrival: $e');
return true;
}
}
/// Sends the verification page's parcel photo through the signed-upload
/// route, and hands back the public URL — or `''` if anything went wrong.
///
/// ── Why this is keyed on the booking ──
///
/// It runs before `pickup-complete`, so the consignment does not exist yet.
/// `/miler/uploads/sign` takes `purpose: "pickup_proof"` with a `bookingid`
/// for exactly this window; see [MilerApi.uploadProof].
///
/// ── And why it never throws ──
///
/// The parcel is in the rider's hands whatever the network did. Every failure
/// here — no photo, a dead signature, a refused PUT — resolves to an empty
/// string, which is what this call site was sending unconditionally until
/// now. The local copy stays on the device either way.
Future<String> _uploadParcelProof(String path, int bookingId) async {
if (path.isEmpty || bookingId <= 0) return '';
try {
final url = await MilerApi.uploadProof(
File(path),
purpose: MilerApi.proofPickup,
consignmentId: bookingId,
);
if (url == null || url.isEmpty) {
ApiConfig.logGap(
'uploads/sign',
'the parcel photo for booking $bookingId could not be uploaded; the '
'pickup is being recorded without one and the copy stays on the '
'device.',
);
return '';
}
return url;
} catch (e) {
debugPrint('[PICKUP] parcel proof upload failed: $e');
return '';
}
}
/// Puts the load on the road: releases the consignment, records the round
/// starting, then hands off to turn-by-turn.
///
/// The write comes first and the hand-off only follows a write that landed.
/// The other order — open Maps, then post — is how the hub ends up watching
/// a rider ride to a door for a parcel it still believes is on the counter.
Future<void> _startDelivery() async {
if (_isNavigating || !mounted) return;
setState(() => _isNavigating = true);
bool released;
try {
final parent = widget.parentState;
// `startRound` is the Deliveries bar's own path — the server release
// plus the local bookkeeping (`Out_for_Delivery`, the order event, the
// set the queue reads). Reusing it is what stops a delivery started
// from the map and one started from the list recording different things.
released = parent != null
? (await parent.startRound([widget.pickup])) > 0
: await releaseForDelivery(widget.pickup);
} catch (e) {
debugPrint('[DELIVERY] could not start: $e');
released = false;
}
if (!mounted) return;
setState(() {
_isNavigating = false;
_setOff = released;
});
if (!released) {
// ── The reason, not a guess ──
//
// This printed "This parcel isn't ready to go out yet. Check your
// connection and try again" for every failure — including a parcel that
// is sitting in a hub two cities away and will never be his to deliver,
// and a stop whose consignment reference was never recorded. Both of
// those a rider can slide at forever.
//
// `releaseForDelivery` now says which it was. See [lastReleaseFailure].
AppFeedback.error(
context,
lastReleaseFailure ??
"This parcel isn't ready to go out yet. Check your connection and "
'try again.',
);
return;
}
HapticFeedback.mediumImpact();
_hasOpenedNavigation = false;
await _openGoogleMapsNavigation();
}
/// Records a stop that could not be handed over.
///
/// The same chooser the proof page shows, reached without having to slide
/// *delivered* first. No photograph travels with either outcome — there is
/// nothing to photograph about a delivery that did not happen — so this goes
/// straight to the write [_markDelivered] reaches through the proof page.
/// See [DeliveryProofPage.askOutcome].
Future<void> _reportCannotDeliver() async {
if (_isNavigating || !mounted) return;
final outcome = await DeliveryProofPage.askOutcome(context);
if (!mounted || outcome == null) return;
await _closeDelivery(switch (outcome) {
ProofOutcome.delivered => DeliveryOutcome.delivered,
ProofOutcome.skipped => DeliveryOutcome.skipped,
ProofOutcome.cancelled => DeliveryOutcome.cancelled,
});
}
/// The end of the stop, whichever of the three it is.
///
/// The slide says *deliver* because that is what the rider intends. What
/// actually happened is known at the door, so [DeliveryProofPage] collects
/// all three answers rather than only the happy one: it comes back
/// **delivered** with a photo (or `''` for a deliberate completion without
/// one), **skipped**, **cancelled**, or null when he backs out and keeps the
/// stop.
///
/// The photo is offered, never demanded — a camera that will not open must
/// not strand a rider holding a parcel he has handed over.
Future<void> _markDelivered() async {
if (_isNavigating || !mounted) return;
final result = await openScreen<ProofResult>(
context,
DeliveryProofPage(
customer: _customerName(),
orderId: (widget.pickup['orderid'] ?? '').toString(),
),
);
if (!mounted || result == null) return;
// One vocabulary maps to the other. The proof page is a view and must not
// depend on the pickups library's own enum; this is the one place the two
// meet, so a third outcome added on either side fails to compile here
// rather than silently recording the wrong thing.
final outcome = switch (result.outcome) {
ProofOutcome.delivered => DeliveryOutcome.delivered,
ProofOutcome.skipped => DeliveryOutcome.skipped,
ProofOutcome.cancelled => DeliveryOutcome.cancelled,
};
await _closeDelivery(outcome, proofPath: result.proofPath);
}
/// The name shown on the proof page, so the rider can see which hand-over
/// he is about to commit to.
String _customerName() {
for (final k in const [
'dropcustomer',
'pickupcustomer',
'PickupCustomer',
'name',
]) {
final v = (widget.pickup[k] ?? '').toString().trim();
if (v.isNotEmpty) return v;
}
return 'Customer';
}
/// Ends the delivery, then leaves the screen with the outcome.
///
/// The write and the local bookkeeping are [closeDelivery]'s — the same path
/// the confirmation sheet uses for a pickup — so a delivery closed from here
/// and one closed from anywhere else cannot record different things. This
/// method is only the navigation around it.
Future<void> _closeDelivery(
DeliveryOutcome outcome, {
String proofPath = '',
}) async {
if (_isNavigating) return;
setState(() => _isNavigating = true);
final navigator = Navigator.of(context);
final result = await closeDelivery(
context,
stop: widget.pickup,
parentState: widget.parentState,
outcome: outcome,
proofPath: proofPath,
);
if (result == null) {
// Refused or failed — [closeDelivery] has already said why, and the rider
// keeps the stop and this screen.
if (mounted) setState(() => _isNavigating = false);
return;
}
// Said after the pop, and context-free, because the screen that would have
// shown it is the one being closed. Without it the row simply vanishes from
// the list, which reads as the tap having lost the stop rather than
// finished it.
navigator.pop(result);
AppFeedback.successGlobal(
'${outcome.pastTense} · ${MilkRun.sourceNameOf(widget.pickup)}',
);
}
Future<bool> _handleArrived() async {
if (_handlingArrival || _isNavigating || !mounted) return false;
_handlingArrival = true;
try {
// ── What arriving asks for depends on the line ──
//
// On a **milk run** it asks for nothing. The rider is at a door with one
// bag that was paid for by subscription; the proof-of-work page — parcel
// ticks, weight, condition, OTP, photo, review — is a two-minute form
// standing between him and a doorbell, and the confirmation sheet at the
// end of this method already asks the one question the stop has an answer
// to. The result keeps the page's shape so nothing downstream changes.
//
// On **logistics** it asks for everything, because this is the moment the
// shipment comes into existence: what is in the parcel, where it is
// going, what it weighs, what it costs. Skipping it is what left the
// backend routing consignments from a pin the customer dropped last week
// and billing them on an estimate nobody checked.
// ── The arrival is recorded HERE, before anything else ──
//
// It was not recorded at all. This method — the "I've arrived" control at
// the foot of the map sheet, and the only arrival control on the
// logistics line — went straight from the press into the verification
// form and then into `_startPickupNavigation`. `updateArrivedStatus` was
// called from `homepage.dart` and nowhere else, so on this path:
//
// • `POST /miler/bookings/:id/reached` never fired,
// • the local ARRIVED record was never written,
// • and the rung went straight from ACCEPTED to whatever came next.
//
// That is the whole of "tapping Arrived does not make it Arrived". It was
// never a status-mapping problem: there was no transition to map.
//
// ── It blocks on a refusal, and only on a refusal ──
//
// Two failures, opposite handling. A rider with no signal at a kitchen
// door must carry on — stranding him over the network is one broken
// endpoint becoming a stopped operation, and arrival is his own report of
// where he is standing. A rider whose arrival the **server rejected on a
// business rule** must not be walked into the verification form: he is
// not arrived, the hub has said so, and everything downstream would be
// built on a rung that does not exist.
//
// `PickupsController.lastArrivalRefusal` is the distinction, drawn from
// the HTTP code rather than from message prose.
if (!await _recordArrival()) {
_handlingArrival = false;
return false;
}
Map<String, dynamic> verifyResult = <String, dynamic>{'verified': true};
if (ServiceProfile.active.needsVerification) {
final verified = await openScreen<Map<String, dynamic>>(
context,
StopVerificationPage(pickup: widget.pickup),
);
// Backed out of the form: he is not finishing this stop, and the map
// stays exactly where it was.
if (verified == null || verified['verified'] != true) {
_handlingArrival = false;
return false;
}
verifyResult = verified;
}
if (!mounted) {
_handlingArrival = false;
return false;
}
// ── The shipment desk ──
//
// FROM · TO · WEIGHT · DISTANCE · PRICE, in one conversation with the
// customer. Its answers ride along in the same map, and
// `_startPickupNavigation` sends them — addresses first, because
// `pickup-complete` routes the consignment from them.
if (ServiceProfile.active.capturesShipmentAddresses) {
final seeded = Map<String, dynamic>.from(widget.pickup);
// Don't ask for the weight twice: the verification page has just taken
// it, so the desk opens with it filled in.
final weighed = (verifyResult['weight'] ?? '').toString().trim();
if (weighed.isNotEmpty) seeded['weight'] = weighed;
final captured = await openScreen<Map<String, dynamic>>(
context,
ShipmentCapturePage(pickup: seeded),
);
if (captured == null || captured['captured'] != true) {
_handlingArrival = false;
return false;
}
verifyResult = {...verifyResult, 'shipment': captured};
// ── The quote becomes the amount due ──
//
// Stamped onto the stop rather than threaded through the sheet as a
// new argument, because `collectionamt` is already the one field every
// downstream reader consults for "what does the customer owe?" —
// `stopCollectionAmount`, the payment page's total, the short-payment
// path, the completion record. Writing the quote there means the fare
// the rider showed the customer is the fare the payment screen asks
// for, with no second source to drift from it.
//
// A booking that arrived with an estimate is overwritten deliberately:
// the estimate was computed from what the customer guessed, and the
// rider has just weighed it.
final quoted = (captured['price'] as num?)?.toDouble() ?? 0;
if (quoted > 0) {
widget.pickup['collectionamt'] = quoted;
widget.pickup['shipmentquote'] = captured;
}
}
if (!mounted) {
_handlingArrival = false;
return false;
}
await _startPickupNavigation(verifyResult);
} catch (e) {
if (mounted) {
AppFeedback.error(context, 'Could not open that stop — try again');
}
_handlingArrival = false;
return false;
}
_handlingArrival = false;
return true;
}
/// Takes the money, then creates the shipment. Returns null on success, or a
/// message the review page shows without leaving the screen.
///
/// ── The order is the contract's, not a preference ──
///
/// `pickup-complete` is the pivot: it converts the booking into a consignment
/// and mints a tracking number. A payment recorded against a booking that has
/// already become a consignment has nothing to attach to — so the money goes
/// first, every time.
///
/// ── Why a failure here must not look like success ──
///
/// Almost every other status write in this app is optimistic, because a rider
/// who watches a completed stop bounce back stops trusting the button. This
/// one is the exception: it is the moment a shipment comes into existence and
/// the moment cash changes hands. Playing the "order created" animation over
/// a failed create would send the rider away believing an order exists, with
/// the customer's money in his pocket and nothing on the hub's screen.
Future<String?> _payAndCreateOrder({
required int pickupId,
required int orderHeaderId,
required ShipmentPayMethod method,
required double amount,
required String riderLat,
required String riderLng,
required String orderId,
/// The verification page's parcel photo, on disk. Threaded in rather than
/// re-read, because this method runs from the review screen's callback and
/// the verification result lives one frame up in
/// [_startPickupNavigation].
required String parcelPhotoPath,
}) async {
final dc = Get.put(PickupsController(), permanent: true);
// ── Step 3: the money ──
//
// Skipped only when nothing is owed. `submitPayment` refuses a zero amount
// server-side anyway, and a prepaid shipment still has to be created.
if (amount > 0) {
final payRes = await UpdatePickupProvider().submitPayment(pickupId, {
'paid': true,
'method': method.wireName,
'amountCollected': amount,
});
if (payRes?['status'] != true) {
return 'Payment could not be recorded — '
'${(payRes?['message'] ?? 'check your connection and try again')}';
}
}
// ── Step 4: the shipment exists from here ──
//
// Routed through the controller rather than `MilerApi.pickupComplete`
// directly, so the geofence, the rider-kilometre calculation and the
// punctuality bonus all still run — and so `lastTrackingNo` is populated
// for the receipt on the success screen.
final ok = await dc.updatePickedupStatus(
pickupId: pickupId,
orderHeaderId: orderHeaderId,
pickupLocationId:
int.tryParse('${widget.pickup['pickuplocationid'] ?? 0}') ?? 0,
smsPickup: 0,
ridersLat: riderLat,
ridersLng: riderLng,
pickupLat: _parseD(
widget.pickup['pickuplat'] ?? widget.pickup['PickupLat'],
).toStringAsFixed(6),
pickupLng: _parseD(
widget.pickup['pickuplon'] ?? widget.pickup['PickupLon'],
).toStringAsFixed(6),
notes: 'Order initiated',
collectionAmount: amount,
collectedAmount: amount,
collectionStatus: amount <= 0
? 0
: (method == ShipmentPayMethod.upi ? 2 : 1),
orderId: orderId,
// ── The parcel photo leaves the phone now ──
//
// This was `''`. The verification page takes a photo of the parcels and
// nothing was ever done with it: the single-stop logistics path sent an
// empty string, so a disputed first-mile collection had the rider's word
// and a count and no picture.
//
// It could not be fixed here before — `/miler/uploads/sign` documented
// only `consignmentid`, and this runs *before* `pickup-complete`, so
// there is no consignment yet. The backend confirmed on 25 Aug that
// `purpose: "pickup_proof"` accepts a **bookingid**, which is the one
// thing this moment does have.
//
// Best-effort, deliberately: a failed upload costs the hub a picture and
// must never cost the rider his stop. `proofImage` is a URL the server
// treats as optional, so an empty one is the same call it was making
// before.
proofImage: await _uploadParcelProof(parcelPhotoPath, pickupId),
);
if (!ok) {
// Refused, not failed: he is not at the address, so nothing was sent.
// The geofence message is the useful one — it says how far off he is.
final blocked = dc.lastBlockedReason;
return blocked ??
'The order could not be created. Check your connection and try again.';
}
return null;
}
Future<void> _startPickupNavigation(
Map<String, dynamic> verificationData,
) async {
if (_isNavigating || !mounted) return;
setState(() => _isNavigating = true);
try {
final dc = Get.put(PickupsController(), permanent: true);
final d = widget.pickup;
final int pickupId =
int.tryParse(
'${d['PickupId'] ?? d['pickupId'] ?? d['pickupid'] ?? 0}',
) ??
0;
final String orderId = (d['orderid'] ?? d['OrderId'] ?? '').toString();
final int orderHeaderId =
int.tryParse('${d['orderheaderid'] ?? d['OrderHeaderId'] ?? 0}') ?? 0;
if (pickupId > 0) {
final prefs = await SharedPreferences.getInstance();
// He is at the door: verification has just passed. Read back at
// completion — see [addCompletedBookings].
await prefs.setString(
kStopArrivedKey(pickupId),
DateTime.now().toIso8601String(),
);
final rawEta = d['eta'];
int etaMinutes = int.tryParse(rawEta?.toString() ?? '0') ?? 0;
if (etaMinutes > 0) {
final now = DateTime.now();
final endTime =
now.add(Duration(minutes: etaMinutes)).millisecondsSinceEpoch ~/
1000;
await prefs.setInt('eta_endtime_$orderId', endTime);
}
}
String riderLatStr = '0', riderLngStr = '0';
try {
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.medium,
timeLimit: Duration(seconds: 3),
),
).timeout(const Duration(seconds: 3));
riderLatStr = position.latitude.toStringAsFixed(6);
riderLngStr = position.longitude.toStringAsFixed(6);
} catch (_) {}
if (!mounted) {
_endNavigating();
return;
}
// Mark this pickup active — the rider has arrived and verified the
// parcel. (Done before the confirm sheet so live tracking / active
// state is in place while confirming.)
final _MyPickupsState? parentState =
widget.parentState ??
context.findAncestorStateOfType<_MyPickupsState>();
if (pickupId > 0 && orderId.isNotEmpty) {
dc.updateActiveStatus(
pickupId: pickupId,
orderHeaderId: orderHeaderId,
ridersLat: riderLatStr,
ridersLng: riderLngStr,
orderId: orderId,
);
if (parentState != null) {
parentState._activePickupOrderId = orderId;
// ── `active` is not a rung, and it was being written as one ──
//
// This said `d['orderstatus'] = 'active'`, and that is where the
// rider's "Picked" turned into "Active". Two separate facts were
// being written into one field:
//
// • **which stop is live** — `_activePickupOrderId`, right above,
// which is what the LIVE mark actually reads; and
// • **how far up the pickup ladder this stop is** — `orderstatus`.
//
// `updateActiveStatus` reinforces the point: it posts
// `setAvailability('On_Pickup')`, which is a fact about the *rider*,
// not about the booking. There is no rider endpoint that sets a
// booking to Active — `Pickup_Scheduled` is system-set — so `active`
// here was never a server state at all. It was app-generated, and it
// outranked the real rung on every screen that read `orderstatus`.
//
// The honest rung at this moment is ARRIVED: he is at the door and
// has just completed verification. `pickup-complete` moves it to
// PICKED, and the completed store is what carries that.
d['orderstatus'] = 'arrived';
await parentState._startPickupPosting(d);
parentState.setState(() {});
}
}
if (!mounted) {
_endNavigating();
return;
}
// ── Step 2 of the v1 pickup flow: what he actually took ──
//
// This is the last moment the parcel's real weight can be recorded.
// `pickup-complete` (step 4, a few lines below) recomputes the chargeable
// weight from whatever `parcel` submitted, and once the booking has
// become a consignment there is nothing left to attach a measurement to.
// The rider typed it on the verification screen thirty seconds ago and
// the app used to throw it away.
//
// Not fatal if it fails: the stop still completes and bills on the
// customer's booked estimate instead of the measured figure. Blocking the
// rider at a doorstep over a billing detail would be the worse trade.
// ── Step 1b, and it must go FIRST ──
//
// `pickup-complete` (step 4) builds the consignment from the booking's
// addresses — its routing hub and its pricing zone both come from them —
// and the handler refuses an address change once that conversion has
// happened. So the FROM and TO the rider just confirmed have exactly one
// window in which they can land, and it is here, before anything else.
//
// Unlike the parcel step below, a failure here is worth stopping for: the
// shipment would be routed and priced from an address the rider has just
// been told is wrong, and neither he nor the customer would ever see the
// discrepancy.
final shipment = verificationData['shipment'];
if (pickupId > 0 && shipment is Map<String, dynamic>) {
final addrRes = await UpdatePickupProvider().submitAddresses(
pickupId,
shipment,
);
if (addrRes?['status'] != true) {
debugPrint('[ADDRESS] booking $pickupId: not recorded — $addrRes');
if (mounted) {
AppFeedback.error(
context,
'Could not save the addresses — the shipment would be routed to '
'the wrong place. Check your connection and try again.',
);
}
_endNavigating();
return;
}
}
if (!mounted) {
_endNavigating();
return;
}
if (pickupId > 0) {
final parcelRes = await UpdatePickupProvider().submitParcels(
pickupId,
verificationData,
);
if (parcelRes?['status'] != true) {
debugPrint('[PARCEL] booking $pickupId: not recorded — $parcelRes');
}
}
if (!mounted) {
_endNavigating();
return;
}
// ── Two ways to close a stop, and they are genuinely different jobs ──
//
// **Logistics** ends by *creating a shipment*: the rider has captured the
// addresses, the weight and the fare, so the last screen is one review of
// all of it with the amount on the button — `Pay ₹150` — and the order is
// raised when he presses it. That used to be a confirm sheet plus a
// separate payment page plus the verify page's own review: three screens
// showing the same facts, with the number he quoted and the number he
// collected two taps apart.
//
// **A milk run** ends by *confirming a hand-over*. There is no money, no
// shipment to raise and nothing to price, so it keeps the sheet, which
// asks the one question that stop has an answer to.
final Map<String, dynamic>? shipmentCapture =
verificationData['shipment'] is Map<String, dynamic>
? verificationData['shipment'] as Map<String, dynamic>
: null;
// Both paths hand back the same outcome map, so everything below —
// the compliance stamp, the completed record, the next-stop list and the
// hand-off screen — runs once and unchanged for either.
final dynamic result =
(ServiceProfile.active.initiatesShipment && shipmentCapture != null)
? await openScreen<Map<String, dynamic>>(
context,
ShipmentReviewPage(
pickup: widget.pickup,
shipment: shipmentCapture,
trackingNo: () => dc.lastTrackingNo.value,
onConfirm: (method, amount) => _payAndCreateOrder(
pickupId: pickupId,
orderHeaderId: orderHeaderId,
method: method,
amount: amount,
riderLat: riderLatStr,
riderLng: riderLngStr,
orderId: orderId,
parcelPhotoPath: (verificationData['parcelImage'] ?? '')
.toString(),
),
),
swipeToGoBack: false,
)
// Through the kit. The `shape: …Radius.circular(24)` here was dead
// weight *and* a contradiction: the route background is transparent,
// so the shape clipped nothing, and 24 is not the radius the glass
// actually draws. A number that has no effect is worse than a wrong
// one — the next person reads it as the system's value.
: await showMilerSheet<dynamic>(
context,
builder: (sheetContext) => _PickupBottomSheet(
pickup: widget.pickup,
parentState: parentState,
),
);
if (!mounted) {
_endNavigating();
return;
}
// The sheet returns an outcome map on picked-up / cancelled. We (a normal
// page route) show the success screen with its "Move to next stop"
// button.
debugPrint('[CONFIRM] sheet returned: $result');
if (result is Map &&
(result['outcome'] == 'completed' ||
result['outcome'] == 'cancelled')) {
// ── The bookkeeping must never cost the rider his next stop ──
//
// Removing the finished stop from the live list and reading the
// remainder used to sit inside the same try as the navigation below,
// so anything that threw in here — a disposed parent, a prefs write —
// was caught by the outer handler, which logged and set a flag. The
// stop was completed on the server and the rider was left standing on
// the map screen with nothing to press, which reads exactly like a
// dead Confirm button.
//
// The stop is finished either way, so the hand-off screen opens either
// way; a failure here costs at most a stale "next stops" list, which
// that screen re-fetches for itself.
final bool cancelled = result['outcome'] == 'cancelled';
// ── Stamp the compliance while it is still knowable ──
//
// The ETA deadline lives in a prefs key the next stop overwrites, and
// the distance is measured from the previous stop's position, which has
// also moved on. `PickupsController` has just computed both to decide
// the bonus; this is the one moment they can be written down. See
// [StopCompliance].
try {
widget.pickup['compliance'] = StopCompliance(
onTime: dc.lastOnTime.value,
lateBy: dc.lastLateBy.value,
actualKm: dc.lastRiderKms.value > 0 ? dc.lastRiderKms.value : null,
plannedKm: double.tryParse((widget.pickup['kms'] ?? '').toString()),
).toJson();
// The clock this stop was promised by, out of the same prefs key the
// next stop overwrites. Kept beside the verdict because "on time" and
// "by when" are two different questions and the record could only
// answer the first.
final promised = dc.lastEtaDeadline.value;
if (promised != null) {
widget.pickup['etadeadline'] = promised.toIso8601String();
}
// ── What was actually handled, and what was actually taken ──
//
// The weight the rider measured, the parcels he counted and the money
// he collected exist only in the two sheets he has just closed. They
// are the answers to every question asked of a finished stop
// afterwards — "was that the 4.5 kg one?", "did he take the cash?" —
// and without this they die with the sheets.
widget.pickup['proof'] = <String, dynamic>{
if ((verificationData['weight'] ?? '').toString().trim().isNotEmpty)
'weight': verificationData['weight'],
if (verificationData['pickup'] is Map)
'collected': (verificationData['pickup'] as Map)['collected'],
if (verificationData['delivery'] is Map)
'handedover': (verificationData['delivery'] as Map)['handedOver'],
if (result['paymentMethod'] != null)
'paymentmethod': result['paymentMethod'],
if (result['amountCollected'] != null)
'amountcollected': result['amountCollected'],
};
} catch (e) {
debugPrint('[CONFIRM] could not stamp completion detail: $e');
}
List<Map<String, dynamic>>? remaining;
try {
if (parentState != null) {
parentState.markPickupFinished(widget.pickup, cancelled: cancelled);
remaining = parentState.remainingPickups();
} else {
// Reached without the Bookings state behind it — from Home's live
// banner, or after a rebuild. The stop is still finished, and
// Activity still has to know.
await addCompletedBookings([widget.pickup], cancelled: cancelled);
}
} catch (e) {
debugPrint('[CONFIRM] post-completion bookkeeping failed: $e');
}
if (!mounted) {
_endNavigating();
return;
}
replaceWithSheet(
context,
PickupsDone(
isCancelled: cancelled,
isDelivery: result['isDelivery'] == true,
bonusPoints: (result['bonusPoints'] as int?) ?? 0,
paymentMethod: result['paymentMethod'] as String?,
remainingOrders: remaining,
parentState: parentState,
),
);
} else if (result == true) {
// Skipped. The stop is parked, so leave the map and go back to the list
// one route at a time rather than unwinding to the app root.
Navigator.of(context).pop();
}
// Dismissed (null): stay on the map. Swiping a sheet away is a change of
// mind about the sheet, not about the stop — this used to
// `popUntil(isFirst)`, which threw the rider all the way out to the tab
// shell and lost the stop he was working.
_endNavigating();
} catch (e) {
debugPrint('[ERROR] _startPickupNavigation: $e');
_endNavigating();
// Never fail silently here. Everything in this method sits between the
// rider pressing a button and the screen changing, so a swallowed
// exception looks like a dead button and he will press it again.
if (mounted) {
AppFeedback.error(context, "Couldn't finish that stop — try again");
}
}
}
}
// ── Live ETA header (Uber-style) ─────────────────────────────────────────────
//
// Previously this was a yellow warning-coloured box. Two things were wrong
// with that: amber is the app's WARNING colour, so a perfectly healthy stop
// looked like a problem; and boxing the number inside a tinted container made
// it compete with the address block underneath instead of leading it.
//
// Uber's pattern, and the one used here: the arrival time is simply the
// biggest thing on the card. No container, no fill, no border — hierarchy is
// carried by type size alone, and a hairline divider separates it from the
// address. The number is the headline; everything else is a caption.
//
// 8 min ● LIVE
// 2.4 km away · Arrive by 4:35 PM
// ──────────────────────────────────────────────
// PICKUP LOCATION …
class _LiveEtaHeader extends StatelessWidget {
final double? meters;
final double? speedMps;
final bool live;
/// What the green dot beside the ETA says.
///
/// It read **Active**, which named the *position stream* — a fact about this
/// app's plumbing, not about the job. On the delivery leg the rider is
/// carrying the parcel, and the useful word for the state he is in is the
/// one the hub uses for it: **Picked up**. The dot still only appears while a
/// fix is actually feeding the ETA; a stale reading that looks live is worse
/// than no reading.
final String liveLabel;
const _LiveEtaHeader({
required this.meters,
required this.speedMps,
required this.live,
this.liveLabel = 'Active',
});
@override
Widget build(BuildContext context) {
final travel = RouteMetricsHelper.travelTime(
meters,
liveSpeedMps: speedMps,
);
final distance = RouteMetricsHelper.formatDistance(meters);
final arrival = RouteMetricsHelper.formatEta(
meters,
liveSpeedMps: speedMps,
);
final known = travel > Duration.zero;
// ── Two facts, and the clock time is not one of them ──
//
// This said `12 min` and under it `1.2 km away · Arrive by 10:42`. Three
// quantities and a preposition, for a rider glancing at a phone on a
// handlebar. The arrival time is the one to go: it is the only figure here
// he has to *convert* — read a clock, subtract now — to get back the number
// already printed above it at 30 points. Distance stays, because near and
// far is a different question from soon and late.
//
// Both survive in full for a screen reader, where nothing is competing for
// the glance.
return Semantics(
label: known
? '${RouteMetricsHelper.formatDuration(travel)} away, '
'$distance, arriving about $arrival'
'${live ? ', $liveLabel' : ''}'
: 'Working out how far away this stop is',
excludeSemantics: true,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// A live badge only when a position stream is actually feeding this.
// A stale ETA that looks live is worse than no ETA.
//
// ── The word came off it ──
//
// It was a tinted pill reading `Picked up` / `Active` beside the
// numeral. What it means is "this number is live", and a pulsing dot
// says that to anybody, in any language, without being read. The word
// moved into the semantics label above.
if (live) ...[_LiveDot(), SizedBox(width: 10.w)],
Expanded(
child: Text(
known
? RouteMetricsHelper.formatDuration(travel)
: 'Calculating…',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: known ? 34.sp : 20.sp,
height: 1.0,
fontWeight: FontWeight.w900,
letterSpacing: -1.4,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
SizedBox(width: 10.w),
// The distance, as a figure rather than a sentence. `1.2 km` is a
// quantity a rider reads the way he reads the minutes beside it.
if (known)
Text(
distance,
maxLines: 1,
style: TextStyle(
fontSize: 17.sp,
height: 1.0,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
);
}
}
/// The mark that says a number on screen is live.
///
/// A ring that breathes out from a solid centre — the shape every map app uses
/// for "you are here", which is what makes it readable without a caption. It
/// replaced a tinted pill carrying the word *Active*; see [_LiveEtaHeader].
class _LiveDot extends StatefulWidget {
@override
State<_LiveDot> createState() => _LiveDotState();
}
class _LiveDotState extends State<_LiveDot>
with SingleTickerProviderStateMixin {
late final AnimationController _c = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1600),
)..repeat();
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: 18.w,
height: 18.w,
child: AnimatedBuilder(
animation: _c,
builder: (context, _) {
final t = _c.value;
return Stack(
alignment: Alignment.center,
children: [
// The halo, expanding and fading — one cycle per period, so it
// reads as a beat rather than as a wobble.
Opacity(
opacity: (1 - t) * 0.35,
child: Container(
width: 18.w * (0.45 + t * 0.55),
height: 18.w * (0.45 + t * 0.55),
decoration: const BoxDecoration(
color: ColorConstants.acceptGreen,
shape: BoxShape.circle,
),
),
),
Container(
width: 9.w,
height: 9.w,
decoration: const BoxDecoration(
color: ColorConstants.acceptGreen,
shape: BoxShape.circle,
),
),
],
);
},
),
);
}
}
/// ── The flow cycle, as arithmetic ──
///
/// Top-level and pure so the behaviour can be asserted directly. The animation
/// itself sits behind a live route, a `TickerProvider` and a map, none of which
/// a widget test can stand up — but "the white line starts at the rider, reaches
/// the stop, then drains back to him" is a claim about numbers, and this is
/// where it is held. See `test/route_flow_test.dart`.
/// Which part of the cycle a frame is in.
enum RouteFlowPhase {
/// White growing from the rider towards the stop.
grow,
/// Arrived, holding at full strength.
hold,
/// Draining back from the stop towards the rider.
fade,
}
/// Where in the cycle [t] falls, and how far through that phase it is.
///
/// [progress] is always 0→1 *within* the phase, so callers never re-derive the
/// phase boundaries — which is how the grow and the fade drifted apart when this
/// was inline.
({RouteFlowPhase phase, double progress}) routeFlowFrame(
double t, {
required double growShare,
required double holdShare,
}) {
final clamped = t.clamp(0.0, 1.0);
if (growShare > 0 && clamped <= growShare) {
return (phase: RouteFlowPhase.grow, progress: clamped / growShare);
}
if (clamped <= growShare + holdShare) {
final span = holdShare;
return (
phase: RouteFlowPhase.hold,
progress: span <= 0 ? 1.0 : (clamped - growShare) / span,
);
}
final span = 1 - growShare - holdShare;
return (
phase: RouteFlowPhase.fade,
progress: span <= 0 ? 1.0 : (clamped - growShare - holdShare) / span,
);
}
/// Gradient stops the fade is sampled at, along the route.
const List<double> kRouteFlowStops = [0, 0.25, 0.5, 0.75, 1];
/// Softness of the draining front, as a share of the route's length. A hard
/// edge travelling down the line reads as a wipe; this is what makes it a fade.
const double kRouteFlowFadeRamp = 0.35;
/// Opacity multiplier at position [x] along the route (0 = rider, 1 = stop)
/// when the fade is [f] through.
///
/// The front starts just past the stop and travels to just past the rider, so
/// the line is whole at f=0 and gone at f=1 rather than already clipped at
/// either end — which is what made the old loop appear to restart early.
double routeFlowFadeAlpha(
double f,
double x, {
double ramp = kRouteFlowFadeRamp,
}) {
final front = (1 + ramp) - f.clamp(0.0, 1.0) * (1 + 2 * ramp);
return ((front - x) / ramp).clamp(0.0, 1.0);
}
/// ── The stop's life, as three beats ──
///
/// Filled behind the rider, hollow ahead of him, and the beat he is on wearing
/// the accent. Deliberately not a percentage or a spinner: three named states
/// is the whole vocabulary a stop has, and naming them is what lets a rider who
/// picked the phone up mid-shift know what he already did.
class _StageRail extends StatelessWidget {
/// What each beat is called. Kept for screen readers and **not drawn** — see
/// the note on [icons].
final List<String> labels;
/// ── The rail lost its captions ──
///
/// Each beat carried a 10.5pt word under its dot: `Accepted · On the way ·
/// Picked up`. Three words, at the smallest size on the sheet, on the one
/// screen a rider looks at while moving — and the rail already says the same
/// thing with its own shape, which is what a progress rail is *for*. A dot
/// that is filled is done, the ringed one is now, the hollow ones are next;
/// none of that needs reading, and at 10.5pt none of it was being read.
///
/// So each beat is a glyph instead: what the rider is doing, drawn. The words
/// survive in [labels] for a screen reader, where they cost nothing.
final List<IconData> icons;
/// Which beat is current, 0-based. Everything before it is done.
final int current;
const _StageRail({
required this.labels,
required this.icons,
required this.current,
});
@override
Widget build(BuildContext context) {
return Semantics(
label: 'Step ${current + 1} of ${labels.length}: ${labels[current]}',
excludeSemantics: true,
child: Row(
children: [
for (var i = 0; i < icons.length; i++) ...[
if (i > 0)
Expanded(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 6.w),
child: Container(
height: 3,
decoration: BoxDecoration(
color: i <= current
? ColorConstants.acceptGreen
: ColorConstants.borderStrong,
borderRadius: BorderRadius.circular(
DesignConstants.radiusFull,
),
),
),
),
),
// Bigger than the 12pt dot it replaces, because it has to carry a
// glyph now — and because a rail read from a mount at arm's length
// was never going to be readable at 12.
AnimatedContainer(
duration: DesignConstants.motionState,
curve: Curves.easeOut,
width: 34.w,
height: 34.w,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i < current
? ColorConstants.acceptGreen
: i == current
? ColorConstants.tint(ColorConstants.primary, 0.12)
: ColorConstants.pureSurface,
border: Border.all(
color: i < current
? ColorConstants.acceptGreen
: i == current
? ColorConstants.primary
: ColorConstants.borderStrong,
width: i == current ? 2 : 1.5,
),
),
child: Icon(
// A finished beat is a tick, not the thing it was — the rider
// is looking for what is left, and a done step that still wears
// its own glyph competes with the one that is not done.
i < current ? LucideIcons.check : icons[i],
size: 17.sp,
color: i < current
? ColorConstants.onAccent
: i == current
? ColorConstants.primary
: ColorConstants.secondaryText,
),
),
],
],
),
);
}
}