Files
2026-08-11 13:16:33 +05:30

1588 lines
61 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**.
///
/// `_buildSwipeButton` renders a spinner in place of Navigate / I've arrived
/// while this is true, and every exit from `_startPickupNavigation` except
/// the happy one used to clear it with a bare assignment. No rebuild followed,
/// so the map sat there spinning with no buttons — a screen that looks hung
/// is indistinguishable from a confirm that did nothing.
void _endNavigating() {
if (!mounted) {
_isNavigating = false;
return;
}
setState(() => _isNavigating = false);
}
bool _hasOpenedNavigation = false;
// Uber-style route reveal: the full route sits as a faint "casing" and the
// bright line draws itself in from origin to destination.
List<LatLng> _routePoints = <LatLng>[];
/// Distance from the route's start to each point, in the same arbitrary unit.
///
/// Both animations are parameterised by *distance* along this table rather
/// than by point index, and that is the whole difference between the effect
/// working and not. A routing API returns vertices where the road bends: a
/// tight corner can carry twenty points across thirty metres while a highway
/// straight carries two across a kilometre. Stepping by index therefore made
/// the head crawl through every junction and leap down every straight — which
/// looks like stuttering, because it is.
List<double> _cumulative = <double>[];
late final AnimationController _routeAnim;
/// ── The route flow ──
///
/// The draw-in happens once and is over. This is what keeps the line reading
/// as a *direction of travel* rather than as a static shape.
///
/// It used to be a comet: an 18%-of-the-route window sliding from one end to
/// the other. At any moment the rider saw a short white dash a few hundred
/// metres long somewhere in the middle of his journey, and a dash moving over
/// a line reads as a decoration attached to the map rather than as anything
/// about his trip. On a long route it was worse — 18% of 9 km is a smear that
/// never visibly connects the two ends.
///
/// Now it is one continuous stream: white grows from the rider's own position,
/// follows every bend of the real geometry to the stop, holds for a beat, and
/// then fades from the destination back down the line to him. That is the
/// shape of the fact the animation is stating — *this route, from you, to
/// there* — and it is what Uber's own route flow does.
late final AnimationController _flowAnim;
/// Where the cycle changes phase, as fractions of [_flowAnim].
///
/// Recomputed with the controller's duration whenever a route arrives, since
/// the grow phase is timed by distance (see [_restartFlow]) while the hold and
/// the fade are fixed.
double _growShare = 0.68;
double _holdShare = 0.06;
// ── Live distance / ETA ──
// The rider is moving while this screen is open, so a distance captured on
// entry is stale within a block. A position stream keeps the strip honest;
// `distanceFilter` throttles it to every 15 m so it isn't a per-fix rebuild
// storm on a phone that is also running Google Maps.
StreamSubscription<Position>? _positionSub;
Position? _livePosition;
// Corporate Red Theme Colors
static Color get kPrimary => ColorConstants.primary;
/// The halo around the flowing stroke.
///
/// A dark white, painted wider than the core and underneath it, so the bright
/// stroke has something to sit in and reads as light falling on the road
/// rather than as a white sticker laid along it.
///
/// Kept warm rather than neutral grey: the route underneath is red, and a cool
/// grey over it turns muddy at the alpha the halo runs at.
static const Color _flowHaloColour = Color(0xFFA89E9E);
/// 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.
static const double _flowCoreAlpha = 0.96;
static const double _flowHaloAlpha = 0.42;
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;
_pickupLocation = 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() {
final kind = stopKindOf(widget.pickup);
_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,
),
),
milerMarker(
point: _dropLocation,
width: MilerPin.size + 8,
height: MilerPin.size + MilerPin.tail,
child: MilerPin(
color: ColorConstants.acceptGreen,
icon: Icons.flag_rounded,
),
),
]);
}
/// 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;
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;
await _camera.fit(
<LatLng>[_routeStart, _routeEnd],
// Bottom-heavy: the sheet covers the lower 45% of the screen, so a route
// centred in the *viewport* would sit centred under the sheet.
padding: EdgeInsets.fromLTRB(48, 90, 48, 300.h),
);
}
@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: _markers,
onReady: () {
if (!_isLoadingRoute && _routeLayers.isNotEmpty) {
_fitMapToRoute();
}
},
),
),
),
if (_isLoadingRoute)
Container(
color: Colors.black.withValues(alpha: 0.1),
child: Center(child: CircularProgressIndicator(color: kPrimary)),
),
_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),
),
],
),
child: IconButton(
tooltip: 'Back',
onPressed: () => Navigator.pop(context),
icon: Icon(
Icons.arrow_back_ios_new,
color: kTextPrimary,
size: 16,
),
padding: const EdgeInsets.all(12),
constraints: const BoxConstraints(),
),
),
// 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.
],
),
);
}
Widget _buildDraggableBottomSheet() {
return DraggableScrollableSheet(
initialChildSize: 0.45,
minChildSize: 0.35,
maxChildSize: 0.75,
// ── 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 SizedBox(height: 12),
Container(
width: 36,
height: 4,
decoration: BoxDecoration(
color: ColorConstants.borderSubtle,
borderRadius: BorderRadius.circular(DesignConstants.radiusLg),
),
),
Expanded(
// Content settles in (fade + gentle rise) as the route finishes
// loading, so the sheet feels alive alongside the drawing line.
child: AnimatedSlide(
duration: const Duration(milliseconds: 500),
curve: Curves.easeOutCubic,
offset: _isLoadingRoute ? const Offset(0, 0.06) : Offset.zero,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 500),
opacity: _isLoadingRoute ? 0.0 : 1.0,
// ── One sheet, not a stack of cards ──
//
// This was two containers: a filled customer card, then an
// outlined white one holding the ETA and the address. An
// outline drawn around white, on a white sheet, is a line the
// rider has to read past to reach the only thing he came here
// for — and it is the opposite of what the rest of the app
// does, which is to fill a panel and never draw its edge (see
// [ColorConstants.cardSurface]).
//
// A bottom sheet is already a container. Everything inside it
// is one column: the ETA leads because it is the live fact,
// one hairline separates it from the stop it belongs to, and
// the actions close it. This is the shape Uber's arrival sheet
// has, and it is why that sheet reads at arm's length.
child: ListView(
controller: scrollController,
padding: EdgeInsets.fromLTRB(20.w, 18.h, 20.w, 8.h),
children: [
_LiveEtaHeader(
meters: _liveMetersToStop,
speedMps: _livePosition?.speed,
live: _livePosition != null,
),
SizedBox(height: 16.h),
Divider(
height: 1,
thickness: 1,
color: ColorConstants.borderSubtle,
),
SizedBox(height: 16.h),
_buildStopBrief(),
SizedBox(height: 24.h),
_buildSwipeButton(),
],
),
),
),
),
],
),
),
);
}
/// Who the stop is, and where — as rows on the sheet, not as cards on it.
///
/// ── The name used to be printed twice ──
///
/// A filled customer card carried `pickupcustomer` as its title, and the route
/// node inside the outlined box below carried the same string again as *its*
/// title. Two boxes, two borders and one fact stated twice, on the screen with
/// the least room for any of it: the sheet opens at 45% of the height and the
/// map owns the rest.
///
/// The block itself is [_StopBrief], shared with Update Status and Skip. Only
/// the status pill is local to this screen.
Widget _buildStopBrief() {
final status = (widget.pickup['orderstatus'] ?? '')
.toString()
.toUpperCase();
return _StopBrief(
pickup: widget.pickup,
trailing: Flexible(
child: Container(
padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 5.h),
decoration: BoxDecoration(
color: ColorConstants.acceptGreen.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(DesignConstants.radiusFull),
),
child: Text(
status.isNotEmpty ? status : 'PENDING',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontFamily: FontConstants.fontFamily,
color: ColorConstants.acceptGreen,
fontSize: 10.sp,
fontWeight: FontWeight.w800,
letterSpacing: 0.3,
),
),
),
),
);
}
Widget _buildSwipeButton() {
if (_isNavigating) {
return Container(
height: ButtonSizes.primary,
decoration: BoxDecoration(
color: ColorConstants.acceptGreen,
borderRadius: BorderRadius.circular(ButtonSizes.radius),
),
child: const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2.5,
),
),
),
);
}
// Two clear actions: re-launch navigation (in case the rider dismissed
// Google Maps) and confirm arrival. Slides are reserved for money.
//
// ── Both green, only one filled ──
//
// These were both `kPrimary` — the brand maroon — which put the forward
// action of the whole flow in the same hue as Reject, Cancel and every
// destructive confirmation in the app. One rule holds now: green advances
// the job, maroon is brand and navigation, red destroys.
//
// Navigate stays *outlined* because it is the secondary of the pair — a way
// to re-open Maps, not the thing this screen is for — but it takes the same
// green so the two read as one family rather than as two unrelated
// decisions.
return SizedBox(
height: ButtonSizes.primary,
width: double.infinity,
child: Row(
children: [
Expanded(
flex: 4,
child: MilerButton(
label: 'Navigate',
icon: Icons.navigation_rounded,
variant: MilerButtonVariant.outlined,
color: ColorConstants.acceptGreen,
onPressed: () {
_hasOpenedNavigation = false;
_openGoogleMapsNavigation();
},
),
),
const SizedBox(width: 12),
Expanded(
flex: 6,
child: MilerButton(
label: "I've arrived",
icon: Icons.check_circle_rounded,
color: ColorConstants.acceptGreen,
onPressed: () async {
debugPrint('[ARRIVED] Tapped "I have arrived" on map screen');
await _handleArrived();
},
),
),
],
),
);
}
Future<bool> _handleArrived() async {
if (_handlingArrival || _isNavigating || !mounted) return false;
_handlingArrival = true;
try {
final verifyResult = await openSheet<Map<String, dynamic>>(
context,
StopVerificationPage(pickup: widget.pickup),
);
if (verifyResult == null ||
verifyResult['verified'] != true ||
!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;
}
Future<void> _startPickupNavigation(
Map<String, dynamic> verificationData,
) async {
if (_isNavigating || !mounted) return;
setState(() => _isNavigating = true);
try {
final dc = Get.put(PickupsController(), permanent: true);
final d = widget.pickup;
final int pickupId =
int.tryParse(
'${d['PickupId'] ?? d['pickupId'] ?? d['pickupid'] ?? 0}',
) ??
0;
final String orderId = (d['orderid'] ?? d['OrderId'] ?? '').toString();
final int orderHeaderId =
int.tryParse('${d['orderheaderid'] ?? d['OrderHeaderId'] ?? 0}') ?? 0;
if (pickupId > 0) {
final prefs = await SharedPreferences.getInstance();
// He is at the door: verification has just passed. Read back at
// completion — see [addCompletedBookings].
await prefs.setString(
kStopArrivedKey(pickupId),
DateTime.now().toIso8601String(),
);
final rawEta = d['eta'];
int etaMinutes = int.tryParse(rawEta?.toString() ?? '0') ?? 0;
if (etaMinutes > 0) {
final now = DateTime.now();
final endTime =
now.add(Duration(minutes: etaMinutes)).millisecondsSinceEpoch ~/
1000;
await prefs.setInt('eta_endtime_$orderId', endTime);
}
}
String riderLatStr = '0', riderLngStr = '0';
try {
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.medium,
timeLimit: Duration(seconds: 3),
),
).timeout(const Duration(seconds: 3));
riderLatStr = position.latitude.toStringAsFixed(6);
riderLngStr = position.longitude.toStringAsFixed(6);
} catch (_) {}
if (!mounted) {
_endNavigating();
return;
}
// Mark this pickup active — the rider has arrived and verified the
// parcel. (Done before the confirm sheet so live tracking / active
// state is in place while confirming.)
final _MyPickupsState? parentState =
widget.parentState ??
context.findAncestorStateOfType<_MyPickupsState>();
if (pickupId > 0 && orderId.isNotEmpty) {
dc.updateActiveStatus(
pickupId: pickupId,
orderHeaderId: orderHeaderId,
ridersLat: riderLatStr,
ridersLng: riderLngStr,
orderId: orderId,
);
if (parentState != null) {
parentState._activePickupOrderId = orderId;
d['orderstatus'] = 'active';
await parentState._startPickupPosting(d);
parentState.setState(() {});
}
}
if (!mounted) {
_endNavigating();
return;
}
// ── Step 2 of the v1 pickup flow: what he actually took ──
//
// This is the last moment the parcel's real weight can be recorded.
// `pickup-complete` (step 4, a few lines below) recomputes the chargeable
// weight from whatever `parcel` submitted, and once the booking has
// become a consignment there is nothing left to attach a measurement to.
// The rider typed it on the verification screen thirty seconds ago and
// the app used to throw it away.
//
// Not fatal if it fails: the stop still completes and bills on the
// customer's booked estimate instead of the measured figure. Blocking the
// rider at a doorstep over a billing detail would be the worse trade.
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;
}
// Milk-run model: there is no per-stop drop leg. After verifying the
// parcel the rider just confirms this pickup and moves to the next
// stop — so open the confirm/payment sheet directly instead of a
// redundant "navigate to drop" screen. On completion we return to the
// pickup list (a confirmed stop drops off; a dismissed one stays for
// retry).
final dynamic result = await showModalBottomSheet<dynamic>(
context: context,
sheetAnimationStyle: kMilerSheetStyle,
isScrollControlled: true,
backgroundColor: Colors.transparent,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
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();
// ── What was actually handled, and what was actually taken ──
//
// The weight the rider measured, the parcels he counted and the money
// he collected exist only in the two sheets he has just closed. They
// are the answers to every question asked of a finished stop
// afterwards — "was that the 4.5 kg one?", "did he take the cash?" —
// and without this they die with the sheets.
widget.pickup['proof'] = <String, dynamic>{
if ((verificationData['weight'] ?? '').toString().trim().isNotEmpty)
'weight': verificationData['weight'],
if (verificationData['pickup'] is Map)
'collected': (verificationData['pickup'] as Map)['collected'],
if (verificationData['delivery'] is Map)
'handedover': (verificationData['delivery'] as Map)['handedOver'],
if (result['paymentMethod'] != null)
'paymentmethod': result['paymentMethod'],
if (result['amountCollected'] != null)
'amountcollected': result['amountCollected'],
};
} catch (e) {
debugPrint('[CONFIRM] could not stamp completion detail: $e');
}
List<Map<String, dynamic>>? remaining;
try {
if (parentState != null) {
parentState.markPickupFinished(widget.pickup, cancelled: cancelled);
remaining = parentState.remainingPickups();
} else {
// Reached without the Bookings state behind it — from Home's live
// banner, or after a rebuild. The stop is still finished, and
// Activity still has to know.
await addCompletedBookings([widget.pickup], cancelled: cancelled);
}
} catch (e) {
debugPrint('[CONFIRM] post-completion bookkeeping failed: $e');
}
if (!mounted) {
_endNavigating();
return;
}
replaceWithSheet(
context,
PickupsDone(
isCancelled: cancelled,
isDelivery: result['isDelivery'] == true,
bonusPoints: (result['bonusPoints'] as int?) ?? 0,
paymentMethod: result['paymentMethod'] as String?,
remainingOrders: remaining,
parentState: parentState,
),
);
} else if (result == true) {
// Skipped. The stop is parked, so leave the map and go back to the list
// one route at a time rather than unwinding to the app root.
Navigator.of(context).pop();
}
// Dismissed (null): stay on the map. Swiping a sheet away is a change of
// mind about the sheet, not about the stop — this used to
// `popUntil(isFirst)`, which threw the rider all the way out to the tab
// shell and lost the stop he was working.
_endNavigating();
} catch (e) {
debugPrint('[ERROR] _startPickupNavigation: $e');
_endNavigating();
// Never fail silently here. Everything in this method sits between the
// rider pressing a button and the screen changing, so a swallowed
// exception looks like a dead button and he will press it again.
if (mounted) {
AppFeedback.error(context, "Couldn't finish that stop — try again");
}
}
}
}
// ── Live ETA header (Uber-style) ─────────────────────────────────────────────
//
// Previously this was a yellow warning-coloured box. Two things were wrong
// with that: amber is the app's WARNING colour, so a perfectly healthy stop
// looked like a problem; and boxing the number inside a tinted container made
// it compete with the address block underneath instead of leading it.
//
// Uber's pattern, and the one used here: the arrival time is simply the
// biggest thing on the card. No container, no fill, no border — hierarchy is
// carried by type size alone, and a hairline divider separates it from the
// address. The number is the headline; everything else is a caption.
//
// 8 min ● LIVE
// 2.4 km away · Arrive by 4:35 PM
// ──────────────────────────────────────────────
// PICKUP LOCATION …
class _LiveEtaHeader extends StatelessWidget {
final double? meters;
final double? speedMps;
final bool live;
const _LiveEtaHeader({
required this.meters,
required this.speedMps,
required this.live,
});
@override
Widget build(BuildContext context) {
final travel = RouteMetricsHelper.travelTime(
meters,
liveSpeedMps: speedMps,
);
final distance = RouteMetricsHelper.formatDistance(meters);
final arrival = RouteMetricsHelper.formatEta(
meters,
liveSpeedMps: speedMps,
);
final known = travel > Duration.zero;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// The headline: minutes away, at display size.
//
// Expanded, not a bare Text with a Spacer after it. At display size
// "Calculating…" is already 20pt, and the row overflowed from about
// 1.3x system text upward — striping the sheet's own headline, on
// the screen a rider reads at arm's length precisely because he has
// the font turned up.
Expanded(
child: Text(
known
? RouteMetricsHelper.formatDuration(travel)
: 'Calculating…',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: known ? 30 : 20,
height: 1.05,
fontWeight: FontWeight.w800,
letterSpacing: -1.0,
color: ColorConstants.slateText,
fontFamily: FontConstants.fontFamily,
),
),
),
const SizedBox(width: 10),
// A live badge only when a position stream is actually feeding
// this. A stale ETA that looks live is worse than no ETA.
if (live)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
color: ColorConstants.acceptGreen,
shape: BoxShape.circle,
),
),
const SizedBox(width: 5),
Text(
'LIVE',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w800,
letterSpacing: 0.8,
color: ColorConstants.acceptGreen,
fontFamily: FontConstants.fontFamily,
),
),
],
),
],
),
const SizedBox(height: 3),
Text(
known
? '$distance away · Arrive by $arrival'
: 'Waiting for a GPS fix',
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w700,
letterSpacing: -0.1,
color: ColorConstants.secondaryText,
fontFamily: FontConstants.fontFamily,
),
),
],
);
}
}
/// ── The flow cycle, as arithmetic ──
///
/// Top-level and pure so the behaviour can be asserted directly. The animation
/// itself sits behind a live route, a `TickerProvider` and a map, none of which
/// a widget test can stand up — but "the white line starts at the rider, reaches
/// the stop, then drains back to him" is a claim about numbers, and this is
/// where it is held. See `test/route_flow_test.dart`.
/// Which part of the cycle a frame is in.
enum RouteFlowPhase {
/// White growing from the rider towards the stop.
grow,
/// Arrived, holding at full strength.
hold,
/// Draining back from the stop towards the rider.
fade,
}
/// Where in the cycle [t] falls, and how far through that phase it is.
///
/// [progress] is always 0→1 *within* the phase, so callers never re-derive the
/// phase boundaries — which is how the grow and the fade drifted apart when this
/// was inline.
({RouteFlowPhase phase, double progress}) routeFlowFrame(
double t, {
required double growShare,
required double holdShare,
}) {
final clamped = t.clamp(0.0, 1.0);
if (growShare > 0 && clamped <= growShare) {
return (phase: RouteFlowPhase.grow, progress: clamped / growShare);
}
if (clamped <= growShare + holdShare) {
final span = holdShare;
return (
phase: RouteFlowPhase.hold,
progress: span <= 0 ? 1.0 : (clamped - growShare) / span,
);
}
final span = 1 - growShare - holdShare;
return (
phase: RouteFlowPhase.fade,
progress: span <= 0 ? 1.0 : (clamped - growShare - holdShare) / span,
);
}
/// Gradient stops the fade is sampled at, along the route.
const List<double> kRouteFlowStops = [0, 0.25, 0.5, 0.75, 1];
/// Softness of the draining front, as a share of the route's length. A hard
/// edge travelling down the line reads as a wipe; this is what makes it a fade.
const double kRouteFlowFadeRamp = 0.35;
/// Opacity multiplier at position [x] along the route (0 = rider, 1 = stop)
/// when the fade is [f] through.
///
/// The front starts just past the stop and travels to just past the rider, so
/// the line is whole at f=0 and gone at f=1 rather than already clipped at
/// either end — which is what made the old loop appear to restart early.
double routeFlowFadeAlpha(
double f,
double x, {
double ramp = kRouteFlowFadeRamp,
}) {
final front = (1 + ramp) - f.clamp(0.0, 1.0) * (1 + 2 * ramp);
return ((front - x) / ramp).clamp(0.0, 1.0);
}