Files
doormile_milderapp/lib/views/Dashboard/pickups/map.dart
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 05:40:35 +05:30

2463 lines
97 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**.
///
/// `_buildSwipeButton` renders a spinner in place of Navigate / I've arrived
/// 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],
padding: EdgeInsets.fromLTRB(48, top, 48, viewport * _sheetWorking + 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.w600,
),
),
],
),
),
),
),
_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.
static const double _sheetGlance = 0.35;
static const double _sheetWorking = 0.45;
static const double _sheetDetail = 0.75;
Widget _buildDraggableBottomSheet() {
return DraggableScrollableSheet(
initialChildSize: _sheetWorking,
minChildSize: _sheetGlance,
maxChildSize: _sheetDetail,
// Snap to the three, so the sheet always settles somewhere meant.
snap: true,
snapSizes: const [_sheetGlance, _sheetWorking, _sheetDetail],
// ── 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,
padding: EdgeInsets.fromLTRB(20.w, 18.h, 20.w, 8.h),
children: [
_LiveEtaHeader(
meters: _liveMetersToStop,
speedMps: _livePosition?.speed,
live: _livePosition != null,
),
SizedBox(height: 16.h),
Divider(
height: 1,
thickness: 1,
color: ColorConstants.borderSubtle,
),
SizedBox(height: 16.h),
_buildStopBrief(),
SizedBox(height: 24.h),
_buildSwipeButton(),
],
),
),
),
),
],
),
),
);
}
/// 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),
);
return _StopBrief(
pickup: widget.pickup,
trailing: contact.isEmpty
? null
: _CallButton(
phone: contact.number,
size: 46.w,
semanticLabel: contact.action,
),
);
}
Widget _buildSwipeButton() {
if (_isNavigating) {
return Container(
height: ButtonSizes.primary,
decoration: BoxDecoration(
color: ColorConstants.acceptGreen,
borderRadius: BorderRadius.circular(ButtonSizes.radius),
),
child: const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2.5,
),
),
),
);
}
// ── A delivery has no arrival step ──
//
// On the delivery leg this row used to be Navigate + **I've arrived**, and
// the arrival half was doing nothing: there is no endpoint that records
// reaching a customer's door (see [MilkRun.deliveryArrivalIsLocalOnly]), so
// the button wrote nothing and existed only to reveal a confirmation sheet
// — which then asked the rider to confirm a *pickup* for a bag already in
// his box.
//
// Once he sets off the stop is simply **active**. The only question left is
// how it ended, and that is one deliberate control: slide to open, then
// Delivered / Skip / Cancelled. See [_DeliveryStatusSlider].
if (MilkRun.workingKind(
widget.pickup,
collectedIds: widget.parentState?._collectedIds ?? const <String>{},
).isDelivery) {
return _DeliveryStatusSlider(
pickup: widget.pickup,
onNavigate: () {
_hasOpenedNavigation = false;
_openGoogleMapsNavigation();
},
onChoose: (outcome, {String proofPath = ''}) =>
_closeDelivery(outcome, proofPath: proofPath),
);
}
// Two clear actions: re-launch navigation (in case the rider dismissed
// Google Maps) and confirm arrival. Slides are reserved for money.
//
// ── Both green, only one filled ──
//
// These were both `kPrimary` — the brand maroon — which put the forward
// action of the whole flow in the same hue as Reject, Cancel and every
// destructive confirmation in the app. One rule holds now: green advances
// the job, maroon is brand and navigation, red destroys.
//
// Navigate stays *outlined* because it is the secondary of the pair — a way
// to re-open Maps, not the thing this screen is for — but it takes the same
// green so the two read as one family rather than as two unrelated
// decisions.
// ── Calling the customer, from the screen he is driving on ──
//
// The number was reachable from the booking card and from the Home stop
// sheet, and then not from this screen — the one the rider is actually
// looking at between accepting a stop and reaching it, and the exact moment
// he needs it: a gate code, a wrong door, "I'm outside". Getting to it meant
// leaving the map, going back to the list and finding the row again.
//
// Same control as the card's, so it looks and behaves identically wherever
// he finds it — neutral rather than green, because calling is an affordance,
// not a state. See [_CallButton].
//
// Call has moved up to the identity block — see `_buildStopBrief`. This row
// is about the journey, and two controls that both concern it read better
// than three that do not.
return SizedBox(
height: ButtonSizes.primary,
width: double.infinity,
child: Row(
children: [
Expanded(
flex: 4,
child: MilerButton(
// ── Navigation is brand, and brand is red ──
//
// It was an outlined green button beside a filled green one — two
// controls in one hue where one takes the rider somewhere and the
// other closes the job, distinguished only by whether they were
// filled. On a map screen, at arm's length, that is not a
// distinction.
//
// The app's own rule already said where this belongs: *green
// advances the job, maroon is brand and navigation*. Navigate is
// navigation. Filled, because on the screen the rider opens to
// drive somewhere it is the primary action, and the pair now
// differs in colour rather than in weight.
label: 'Navigate',
icon: LucideIcons.navigation,
color: ColorConstants.primary,
onPressed: () {
_hasOpenedNavigation = false;
_openGoogleMapsNavigation();
},
),
),
const SizedBox(width: 12),
Expanded(
flex: 6,
child: MilerButton(
// ── 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 button 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 press *I've handed
// over* to be able to report that he had not.
//
// One label for both lines now. Arriving is a fact he knows at
// the moment he presses it; the outcome is the next screen's
// question, and it is the only question that screen asks.
label: "I've arrived",
icon: LucideIcons.circleCheck,
color: ColorConstants.acceptGreen,
onPressed: () async {
debugPrint('[ARRIVED] Tapped arrival on map screen');
await _handleArrived();
},
),
),
],
),
);
}
/// 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.
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,
}) 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,
proofImage: '',
);
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;
d['orderstatus'] = 'active';
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,
),
),
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;
const _LiveEtaHeader({
required this.meters,
required this.speedMps,
required this.live,
});
@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;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// The headline: minutes away, at display size.
//
// Expanded, not a bare Text with a Spacer after it. At display size
// "Calculating…" is already 20pt, and the row overflowed from about
// 1.3x system text upward — striping the sheet's own headline, on
// the screen a rider reads at arm's length precisely because he has
// the font turned up.
Expanded(
child: Text(
known
? RouteMetricsHelper.formatDuration(travel)
: 'Calculating…',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: known ? 30 : 20,
height: 1.05,
fontWeight: FontWeight.w700,
letterSpacing: -1.0,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
const SizedBox(width: 10),
// A live badge only when a position stream is actually feeding
// this. A stale ETA that looks live is worse than no ETA.
if (live)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
color: ColorConstants.acceptGreen,
shape: BoxShape.circle,
),
),
const SizedBox(width: 5),
Text(
'Active',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
color: ColorConstants.acceptGreen,
fontFamily: FontConstants.fontFamily,
),
),
],
),
],
),
const SizedBox(height: 3),
Text(
known
? '$distance away · Arrive by $arrival'
: 'Waiting for a GPS fix',
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w600,
letterSpacing: -0.1,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
);
}
}
/// ── 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);
}
/// ─────────────────────────────────────────────────────────────────────────
/// UPDATE STATUS — slide to open, then choose
///
/// The delivery leg's one control, and the last thing the rider touches at a
/// door. Two states in one footprint:
///
/// ```
/// closed: [ Navigate ] [ ⟶ slide to update status ]
/// open: [ Delivered ] [ Skip ] [ Cancelled ]
/// ```
///
/// ── Why a slide and not three buttons ──
///
/// The three outcomes are irreversible and one of them is *Cancelled*. A row of
/// tap targets on a phone in a jacket pocket, on a screen the rider is holding
/// while walking, is how a delivery gets cancelled by a knuckle. The slide is
/// the same gesture the app already reserves for consequential writes, and it
/// costs nothing at the one moment the rider is deliberately stopping to record
/// an outcome.
///
/// It opens rather than commits, because *which* outcome is still a question —
/// a slide that committed would need three slides.
class _DeliveryStatusSlider extends StatefulWidget {
final VoidCallback onNavigate;
final void Function(DeliveryOutcome outcome, {String proofPath}) onChoose;
/// The stop being closed — named on the proof page so the rider can see
/// which hand-over he is about to commit to.
final Map<String, dynamic> pickup;
const _DeliveryStatusSlider({
required this.onNavigate,
required this.onChoose,
required this.pickup,
});
@override
State<_DeliveryStatusSlider> createState() => _DeliveryStatusSliderState();
}
class _DeliveryStatusSliderState extends State<_DeliveryStatusSlider> {
/// How far along the track the thumb has been dragged, 0..1.
double _progress = 0;
/// The fraction of the track that counts as "slid".
static const double _commitAt = 0.72;
void _settle() {
if (_progress >= _commitAt) {
HapticFeedback.mediumImpact();
_askOutcome();
} else {
setState(() => _progress = 0);
}
}
/// ── The question is a SHEET, not a panel in a sheet ──
///
/// The outcomes used to render inline at the foot of the map's draggable
/// sheet — a white card inside frosted glass, with its own hand-rolled
/// close button, needing the host sheet animated taller just to be seen.
/// Three workarounds for one wrong container. Every other question this
/// app asks arrives on the kit's glass — handle, header, choice rows, one
/// entrance curve — and this, the most consequential question on the map,
/// was the one place still improvising. Dismissal is the handle and the
/// barrier, the same as everywhere; the slider resets either way.
Future<void> _askOutcome() async {
setState(() => _progress = 1);
final outcome = await showMilerSheet<DeliveryOutcome>(
context,
builder: (ctx) => MilerSheetScaffold(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const MilerSheetHeader(
title: 'How did this stop end?',
subtitle: 'This is recorded with the office straight away.',
),
SizedBox(height: 16.h),
MilerButton(
label: DeliveryOutcome.delivered.label,
icon: DeliveryOutcome.delivered.icon,
color: ColorConstants.acceptGreen,
height: ButtonSizes.primary,
onPressed: () => Navigator.pop(ctx, DeliveryOutcome.delivered),
),
Divider(height: 24.h, color: ColorConstants.borderSubtle),
_OutcomeRow(
outcome: DeliveryOutcome.skipped,
caption: 'Keep it on board and try again later',
onTap: () => Navigator.pop(ctx, DeliveryOutcome.skipped),
),
_OutcomeRow(
outcome: DeliveryOutcome.cancelled,
caption: 'This order will not be delivered',
onTap: () => Navigator.pop(ctx, DeliveryOutcome.cancelled),
),
SizedBox(height: 4.h),
],
),
),
);
if (!mounted) return;
setState(() => _progress = 0);
if (outcome == null) return;
// ── A delivery is proved before it is recorded ──
//
// Delivered is the one outcome somebody may dispute weeks later, so it
// goes through the proof page: photo, preview, then **Mark as
// delivered** on that same screen. The other two outcomes are the
// rider reporting he could *not* hand over, and there is nothing to
// photograph.
//
// Three answers come back and they are not the same: a path (proof
// taken), `''` (completed deliberately without one — the camera failed
// or the customer objected), and null (he backed out, so nothing is
// recorded and the stop stays his).
if (outcome == DeliveryOutcome.delivered) {
final proof = await openScreen<String>(
context,
DeliveryProofPage(
customer: _customerName(),
orderId: (widget.pickup['orderid'] ?? '').toString(),
),
);
if (!mounted || proof == null) return;
widget.onChoose(outcome, proofPath: proof);
return;
}
widget.onChoose(outcome);
}
/// The name the proof page shows, so the rider can see which hand-over he
/// is closing before he commits to it.
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';
}
@override
Widget build(BuildContext context) => _closed(context);
Widget _closed(BuildContext context) {
return SizedBox(
height: ButtonSizes.primary,
width: double.infinity,
child: Row(
children: [
Expanded(
flex: 4,
child: MilerButton(
label: 'Navigate',
icon: LucideIcons.navigation,
color: ColorConstants.primary,
onPressed: widget.onNavigate,
),
),
const SizedBox(width: 12),
Expanded(flex: 6, child: _track(context)),
],
),
);
}
Widget _track(BuildContext context) => LayoutBuilder(
builder: (context, constraints) {
final double travel = (constraints.maxWidth - _thumb).clamp(
1.0,
double.infinity,
);
// ── A drag-only control is a control some riders cannot use ──
//
// The slide exists to make finishing a stop deliberate, and that is
// right: it is the gesture that opens the outcome question. But a bare
// `GestureDetector` announces nothing and offers no path at all to
// anyone driving the screen with TalkBack, or to a rider whose gloves
// will not register a drag on a cold morning.
//
// The deliberateness is preserved exactly, because the slide never
// committed anything: it opens `How did this stop end?`, and the rider
// still has to choose there. So the accessible action opens the same
// question — one extra confirmed step either way, and nobody is locked
// out of finishing their own delivery.
return Semantics(
button: true,
label: 'Finish this stop',
hint: 'Slide, or double tap, to choose how the stop ended',
onTap: _askOutcome,
excludeSemantics: true,
child: GestureDetector(
onHorizontalDragUpdate: (d) => setState(
() => _progress = (_progress + d.delta.dx / travel).clamp(0.0, 1.0),
),
onHorizontalDragEnd: (_) => _settle(),
onHorizontalDragCancel: _settle,
child: Container(
decoration: BoxDecoration(
color: ColorConstants.acceptGreen.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(ButtonSizes.radius),
),
child: Stack(
alignment: Alignment.center,
children: [
// The label fades as the thumb covers it, rather than sitting
// under the thumb being half-read.
Opacity(
opacity: (1 - _progress * 1.6).clamp(0.0, 1.0),
child: Padding(
padding: EdgeInsets.only(left: _thumb * 0.6),
child: Text(
'Update status',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
color: ColorConstants.acceptGreen,
fontFamily: FontConstants.fontFamily,
),
),
),
),
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: EdgeInsets.only(left: travel * _progress),
child: Container(
width: _thumb,
height: _thumb,
decoration: BoxDecoration(
color: ColorConstants.acceptGreen,
borderRadius: BorderRadius.circular(ButtonSizes.radius),
),
child: Icon(
LucideIcons.chevronRight,
color: Colors.white,
size: 26.sp,
),
),
),
),
],
),
),
),
);
},
);
double get _thumb => ButtonSizes.primary - 8;
}
/// One exception, as a row: glyph in a tinted disc, what it is, what it means.
class _OutcomeRow extends StatelessWidget {
final DeliveryOutcome outcome;
final String caption;
final VoidCallback onTap;
const _OutcomeRow({
required this.outcome,
required this.caption,
required this.onTap,
});
@override
Widget build(BuildContext context) => InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
child: Padding(
padding: EdgeInsets.symmetric(vertical: 9.h, horizontal: 4.w),
child: Row(
children: [
Container(
width: 34.w,
height: 34.w,
decoration: BoxDecoration(
color: outcome.colour.withValues(alpha: 0.10),
shape: BoxShape.circle,
),
child: Icon(outcome.icon, size: 18.sp, color: outcome.colour),
),
SizedBox(width: 12.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
outcome.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.5.sp,
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
color: ColorConstants.onSurface,
fontFamily: FontConstants.fontFamily,
),
),
SizedBox(height: 1.h),
Text(
caption,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.sp,
height: 1.3,
fontWeight: FontWeight.w500,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
),
),
Icon(
LucideIcons.chevronRight,
size: 20.sp,
color: ColorConstants.borderStrong,
),
],
),
),
);
}