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