part of 'pickups.dart'; // ------------------------------------------------------------------------- // SCREEN 1: PICKUP MAP PREVIEW — CORPORATE REDESIGN // ------------------------------------------------------------------------- class _PickupMapScreen extends StatefulWidget { final Map 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 _markers = []; // ── Four named lines, not a set keyed by id ── // // Google Maps took a `Set` 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 get _routeLayers => [ 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 _mapRepaint = ValueNotifier(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 _routePoints = []; /// 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 _cumulative = []; 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? _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 _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 _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 _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 _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.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 = [_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.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 _prefixByDistance(double head) { if (_routePoints.length < 2) return const []; final (idx, frac) = _atDistance(head); final out = [..._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 points; final List? coreShade; final List? 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 _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( [_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( 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, 16.h, 20.w, 8.h), 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 {}, ).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.w600, 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 ? "Slide to confirm you've arrived" : 'Slide to 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 _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 _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 _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 _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 _markDelivered() async { if (_isNavigating || !mounted) return; final result = await openScreen( 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 _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 _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 verifyResult = {'verified': true}; if (ServiceProfile.active.needsVerification) { final verified = await openScreen>( 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.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>( 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 _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 _startPickupNavigation( Map 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) { 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? shipmentCapture = verificationData['shipment'] is Map ? verificationData['shipment'] as Map : 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>( 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( 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'] = { 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>? 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.w800, 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.w700, 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 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 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 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, ), ), ], ], ), ); } }