Miler rider app: surface system, visible design language, backend lifecycle
Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,9 @@ import 'dart:math' as math;
|
||||
|
||||
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
|
||||
import 'package:miler/data/route_order.dart';
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
import 'package:miler/Models/stop_status.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// A TRIP — one slot's worth of work, as a single unit.
|
||||
@@ -24,6 +27,147 @@ import 'package:miler/views/Dashboard/pickups/route_metrics.dart';
|
||||
/// This class is pure — no Flutter, no I/O — so the arithmetic is unit-tested
|
||||
/// and safe to call from `build`.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// WHICH TRIP A STOP IS ON
|
||||
///
|
||||
/// A rider's day is three trips, and which one an order lands on is decided by
|
||||
/// **when the work is due**, not by how many buckets happen to have filled up:
|
||||
///
|
||||
/// morning → Trip 1
|
||||
/// afternoon → Trip 2
|
||||
/// evening → Trip 3
|
||||
///
|
||||
/// ── What this replaces ──
|
||||
///
|
||||
/// Stops were bucketed into rolling three-hour windows aligned to the clock —
|
||||
/// 09:00–12:00, 12:00–15:00, and so on — which produces up to eight buckets a
|
||||
/// day, folded down to three at the end. Two consequences, both wrong on a
|
||||
/// real morning: an 08:30 stop and an 11:00 stop are both *the morning run*
|
||||
/// and landed on different trips, and which trip a stop appeared on depended
|
||||
/// on what else was in the day. A rider with only afternoon work saw it as
|
||||
/// "Trip 1", so his Trip 2 and the hub's Trip 2 were different things.
|
||||
///
|
||||
/// The boundaries are fixed here rather than derived, because they are a
|
||||
/// business fact about the shift and not something to infer from the data.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
enum DayPart {
|
||||
morning('Morning'),
|
||||
afternoon('Afternoon'),
|
||||
evening('Evening');
|
||||
|
||||
const DayPart(this.label);
|
||||
|
||||
/// What this part of the day is called, on a tab and in a sentence.
|
||||
final String label;
|
||||
|
||||
/// Afternoon begins at noon.
|
||||
static const int afternoonFromHour = 12;
|
||||
|
||||
/// Evening begins at 5pm.
|
||||
static const int eveningFromHour = 17;
|
||||
|
||||
/// The day part [at] falls in.
|
||||
static DayPart of(DateTime at) {
|
||||
if (at.hour < afternoonFromHour) return DayPart.morning;
|
||||
if (at.hour < eveningFromHour) return DayPart.afternoon;
|
||||
return DayPart.evening;
|
||||
}
|
||||
|
||||
/// The window this part covers on [day].
|
||||
static (DateTime, DateTime) windowOn(DateTime day, DayPart part) {
|
||||
final midnight = DateTime(day.year, day.month, day.day);
|
||||
return switch (part) {
|
||||
DayPart.morning => (
|
||||
midnight,
|
||||
midnight.add(const Duration(hours: afternoonFromHour)),
|
||||
),
|
||||
DayPart.afternoon => (
|
||||
midnight.add(const Duration(hours: afternoonFromHour)),
|
||||
midnight.add(const Duration(hours: eveningFromHour)),
|
||||
),
|
||||
DayPart.evening => (
|
||||
midnight.add(const Duration(hours: eveningFromHour)),
|
||||
midnight.add(const Duration(days: 1)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/// True when [from]–[to] is exactly one day part's window, which is how
|
||||
/// [Trip.slotLabel] knows to say "Morning" instead of a clock range.
|
||||
static bool spans(DateTime from, DateTime to) {
|
||||
final (start, end) = windowOn(from, of(from));
|
||||
return from.isAtSameMomentAs(start) && to.isAtSameMomentAs(end);
|
||||
}
|
||||
}
|
||||
|
||||
/// The three slots of a day, whether or not the rider has work in each.
|
||||
///
|
||||
/// Trip 2 is the afternoon even on a day with no morning work — the number is
|
||||
/// the day part, so the rider's "Trip 2" and the hub's are the same run.
|
||||
extension TripSlots on List<Trip> {
|
||||
/// The day laid out as slots: index 0 is the morning, 1 the afternoon, 2 the
|
||||
/// evening, and an empty slot is a part of the day with no work in it.
|
||||
///
|
||||
/// Dated trips claim their own part. Anything undated — a tenant that sends
|
||||
/// no times, demo data, a trip whose stops carry only addresses — falls back
|
||||
/// to the first free slot, in order, because a trip that cannot be placed
|
||||
/// still has to be reachable. Guessing a part from the wall clock instead
|
||||
/// would quietly move such a trip from Trip 1 to Trip 2 at noon.
|
||||
List<Trip?> get slots {
|
||||
final out = List<Trip?>.filled(DayPart.values.length, null, growable: true);
|
||||
|
||||
// ── Day parts place the trips only when they can place all of them ──
|
||||
//
|
||||
// A trip claims the slot its part names — that is what makes an
|
||||
// afternoon-only day read as Trip 2 rather than Trip 1. It works because
|
||||
// the common day is one run per part.
|
||||
//
|
||||
// When two trips want the *same* part it stops working, and the first
|
||||
// attempt at patching it made things worse: the second trip took the first
|
||||
// free slot anywhere, so on an evening with two evening runs the tabs came
|
||||
// out `[2, 0]` — trip one under Trip 3, trip two under Trip 1, inverted,
|
||||
// and only between 5pm and midnight. A layout that depends on the hour the
|
||||
// rider opened the screen is worse than one that ignores day parts.
|
||||
//
|
||||
// So it is all or nothing. Every trip has its own part, or the run is laid
|
||||
// out in the order it arrived — which is already sorted, and which is the
|
||||
// honest answer when the data does not fit the model.
|
||||
final parts = <int>{};
|
||||
var distinct = true;
|
||||
for (final t in this) {
|
||||
final p = t.dayPart?.index;
|
||||
if (p == null || !parts.add(p)) {
|
||||
distinct = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (distinct) {
|
||||
for (final t in this) {
|
||||
out[t.dayPart!.index] = t;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
for (var i = 0; i < length; i++) {
|
||||
if (i < out.length) {
|
||||
out[i] = this[i];
|
||||
} else {
|
||||
out.add(this[i]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// The trip in slot [index] (0 = morning), or null when nothing is assigned
|
||||
/// to that part of the day.
|
||||
Trip? tripAt(int index) {
|
||||
final s = slots;
|
||||
return index >= 0 && index < s.length ? s[index] : null;
|
||||
}
|
||||
}
|
||||
|
||||
class Trip {
|
||||
/// Stable identity for this trip (backend slot/trip id, or a derived key).
|
||||
final String id;
|
||||
@@ -82,6 +226,10 @@ class Trip {
|
||||
final s = slotStart;
|
||||
final e = slotEnd;
|
||||
if (s == null || e == null) return "Today's route";
|
||||
// A day part is named, not spelled out as a clock range: "Morning" is what
|
||||
// the rider and the hub both call it, and "5:00 AM – 12:00 PM" says the
|
||||
// same thing in seven characters more and one reading step.
|
||||
if (DayPart.spans(s, e)) return DayPart.of(s).label;
|
||||
return '${RouteMetricsHelper.formatClock(s)} – '
|
||||
'${RouteMetricsHelper.formatClock(e)}';
|
||||
}
|
||||
@@ -167,8 +315,43 @@ class Trip {
|
||||
rejectedIds: rejectedIds,
|
||||
).any((s) => s.needsDecision);
|
||||
|
||||
/// Short tab label — `Trip 1`.
|
||||
static String tabLabel(int index) => 'Trip ${index + 1}';
|
||||
/// Short tab label for a **slot** — `Trip 1`.
|
||||
///
|
||||
/// The slot is the day part, not a running count: slot 0 is the morning
|
||||
/// whether or not the rider has morning work. See [DayPart].
|
||||
static String tabLabel(int index) =>
|
||||
'Trip ${index + 1}'; // slot index is the day part's index
|
||||
|
||||
/// Which part of the day this trip belongs to — **null when nothing on it is
|
||||
/// dated**.
|
||||
///
|
||||
/// Taken from the slot the grouping put it in. A trip the backend named with
|
||||
/// its own `tripid` still gets one, from whatever window its stops fall in.
|
||||
///
|
||||
/// Null matters: a trip with no times at all cannot be placed by day part,
|
||||
/// and guessing one from the wall clock would move it between tabs as the
|
||||
/// afternoon wore on. Those keep their position instead — see
|
||||
/// [TripSlots.slots].
|
||||
DayPart? get dayPart {
|
||||
final at = slotStart ?? _earliestDue;
|
||||
return at == null ? null : DayPart.of(at);
|
||||
}
|
||||
|
||||
/// `1`, `2` or `3` — the number the rider and the hub both use. Zero when
|
||||
/// this trip has no time to place it by.
|
||||
int get tripNumber => (dayPart?.index ?? -1) + 1;
|
||||
|
||||
DateTime? get _earliestDue {
|
||||
DateTime? best;
|
||||
for (final s in stops) {
|
||||
final due = _parseTimestamp(
|
||||
s['expected_pickup_time'] ?? s['expectedpickuptime'] ?? s['eta'],
|
||||
);
|
||||
if (due == null) continue;
|
||||
if (best == null || due.isBefore(best)) best = due;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/// Address components every stop on this trip shares — city, state,
|
||||
/// pincode and so on. Cached per build by the caller.
|
||||
@@ -211,13 +394,27 @@ class Trip {
|
||||
|
||||
/// Estimated ride time from the previous point to stop [index]. For index 0
|
||||
/// that is the hub → stop 1 leg.
|
||||
Duration travelTimeToStop(int index, {double? hubLat, double? hubLng}) {
|
||||
Duration travelTimeToStop(
|
||||
int index, {
|
||||
double? hubLat,
|
||||
double? hubLng,
|
||||
// ── The rail's legs run door to door, not counter to counter ──
|
||||
//
|
||||
// On the Deliveries rail every stop is the delivery leg, and this walked
|
||||
// the route over the PICKUP pairs — so the remaining-time estimate was a
|
||||
// tour of the kitchens the rider had already left. The flag mirrors
|
||||
// `RouteMetricsHelper.metersToStop(toDrop:)`: the previous stop's exit
|
||||
// point and this stop's target are both the drop when the leg is a
|
||||
// delivery, with the pickup pair as the payload fallback. Default false —
|
||||
// Home's pickup-leg arithmetic is untouched.
|
||||
bool deliveryLeg = false,
|
||||
}) {
|
||||
if (index < 0 || index >= stops.length) return Duration.zero;
|
||||
|
||||
final ({double lat, double lng})? from = index == 0
|
||||
? _origin(hubLat, hubLng)
|
||||
: _coordsOf(stops[index - 1]);
|
||||
final to = _coordsOf(stops[index]);
|
||||
: _coordsOf(stops[index - 1], toDrop: deliveryLeg);
|
||||
final to = _coordsOf(stops[index], toDrop: deliveryLeg);
|
||||
if (from == null || to == null) return Duration.zero;
|
||||
|
||||
final meters = RouteMetricsHelper.distanceMeters(
|
||||
@@ -356,7 +553,10 @@ class Trip {
|
||||
|
||||
static List<Trip> groupIntoTrips(
|
||||
List<Map<String, dynamic>> stops, {
|
||||
int windowHours = 3,
|
||||
|
||||
/// No longer used for bucketing — stops are grouped by [DayPart]. Kept so
|
||||
/// existing call sites compile; passing it changes nothing.
|
||||
@Deprecated('Trips are bucketed by DayPart') int windowHours = 3,
|
||||
double? hubLat,
|
||||
double? hubLng,
|
||||
DateTime? now,
|
||||
@@ -403,10 +603,14 @@ class Trip {
|
||||
stop['eta'],
|
||||
);
|
||||
if (due != null) {
|
||||
final blockHour = (due.hour ~/ windowHours) * windowHours;
|
||||
from = DateTime(due.year, due.month, due.day, blockHour);
|
||||
to = from.add(Duration(hours: windowHours));
|
||||
key = 'win:${from.toIso8601String()}';
|
||||
// Morning, afternoon or evening — see [DayPart]. Not a rolling
|
||||
// window: two stops due an hour apart are on the same run, and the
|
||||
// trip a stop belongs to must not depend on what else is in the day.
|
||||
final part = DayPart.of(due);
|
||||
final (partFrom, partTo) = DayPart.windowOn(due, part);
|
||||
from = partFrom;
|
||||
to = partTo;
|
||||
key = 'part:${due.year}-${due.month}-${due.day}:${part.name}';
|
||||
} else {
|
||||
from = null;
|
||||
to = null;
|
||||
@@ -436,6 +640,11 @@ class Trip {
|
||||
if (as == null && bs == null) return 0;
|
||||
if (as == null) return 1; // undated trips last
|
||||
if (bs == null) return -1;
|
||||
// Day part first, so Trip 1 is always the morning even on a day that
|
||||
// starts at two in the afternoon. Within a part, earliest first.
|
||||
final pa = a.dayPart?.index ?? DayPart.values.length;
|
||||
final pb = b.dayPart?.index ?? DayPart.values.length;
|
||||
if (pa != pb) return pa.compareTo(pb);
|
||||
return as.compareTo(bs);
|
||||
});
|
||||
|
||||
@@ -489,37 +698,39 @@ class Trip {
|
||||
/// Puts stops in the admin's intended order.
|
||||
///
|
||||
/// `step` is authoritative — it IS the admin's solved sequence, and the app
|
||||
/// must never second-guess it. Only when `step` is absent do we fall back to
|
||||
/// the booked time, then to the order the backend sent. Sorting by distance
|
||||
/// would be re-optimising the route, which the rider is not allowed to do.
|
||||
/// must never second-guess it. Only when no stop carries one do we fall back
|
||||
/// to the booked time, then to the order the backend sent. **Distance is not
|
||||
/// one of the options here**: sorting by it would be re-optimising a route
|
||||
/// the hub has planned, which the rider is not allowed to do.
|
||||
///
|
||||
/// The rule itself lives in [RouteOrder] — one implementation, shared with
|
||||
/// the Deliveries tab, which used to keep its own and disagreed. This is the
|
||||
/// pickup leg's door onto it.
|
||||
static List<Map<String, dynamic>> sortStops(
|
||||
List<Map<String, dynamic>> stops,
|
||||
) {
|
||||
final indexed = <(int, Map<String, dynamic>)>[
|
||||
for (var i = 0; i < stops.length; i++) (i, stops[i]),
|
||||
];
|
||||
) => orderStops(stops).$1;
|
||||
|
||||
indexed.sort((a, b) {
|
||||
final stepA = _toInt(a.$2['step'] ?? a.$2['Step']);
|
||||
final stepB = _toInt(b.$2['step'] ?? b.$2['Step']);
|
||||
if (stepA > 0 && stepB > 0 && stepA != stepB) return stepA - stepB;
|
||||
if (stepA > 0 && stepB <= 0) return -1;
|
||||
if (stepB > 0 && stepA <= 0) return 1;
|
||||
|
||||
final dueA = _parseTimestamp(a.$2['expected_pickup_time']);
|
||||
final dueB = _parseTimestamp(b.$2['expected_pickup_time']);
|
||||
if (dueA != null && dueB != null && dueA != dueB) {
|
||||
return dueA.compareTo(dueB);
|
||||
}
|
||||
return a.$1 - b.$1; // stable: keep backend order
|
||||
});
|
||||
|
||||
return [for (final e in indexed) e.$2];
|
||||
}
|
||||
/// [sortStops], plus which rule produced the order.
|
||||
static (List<Map<String, dynamic>>, RouteOrderSource) orderStops(
|
||||
List<Map<String, dynamic>> stops,
|
||||
) => RouteOrder.sort(
|
||||
stops,
|
||||
bookedTimeOf: (s) => _parseTimestamp(
|
||||
s['expected_pickup_time'] ?? s['expectedpickuptime'] ?? s['eta'],
|
||||
),
|
||||
);
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
static ({double lat, double lng})? _coordsOf(Map<String, dynamic> stop) {
|
||||
static ({double lat, double lng})? _coordsOf(
|
||||
Map<String, dynamic> stop, {
|
||||
bool toDrop = false,
|
||||
}) {
|
||||
if (toDrop) {
|
||||
final dLat = _toDouble(stop['droplat'] ?? stop['DropLat']);
|
||||
final dLng = _toDouble(stop['droplon'] ?? stop['DropLon']);
|
||||
if (dLat != 0 && dLng != 0) return (lat: dLat, lng: dLng);
|
||||
}
|
||||
final lat = _toDouble(stop['pickuplat'] ?? stop['PickupLat']);
|
||||
final lng = _toDouble(stop['pickuplon'] ?? stop['PickupLon']);
|
||||
if (lat == 0 || lng == 0) return null;
|
||||
@@ -540,12 +751,6 @@ class Trip {
|
||||
return DateTime.tryParse(v.toString().trim());
|
||||
}
|
||||
|
||||
static int _toInt(dynamic v) {
|
||||
if (v == null) return 0;
|
||||
if (v is num) return v.toInt();
|
||||
return int.tryParse(v.toString().trim()) ?? 0;
|
||||
}
|
||||
|
||||
static double _toDouble(dynamic v) {
|
||||
if (v == null) return 0;
|
||||
if (v is num) return v.toDouble();
|
||||
@@ -634,6 +839,14 @@ enum StopState {
|
||||
/// Accepted, not yet started. Lives on the Bookings tab.
|
||||
accepted,
|
||||
|
||||
/// Loaded from its source and physically in the rider's hands.
|
||||
///
|
||||
/// Service routes only. The parcel ladder has no equivalent — for a parcel,
|
||||
/// collecting it from the customer *is* the job — so this state exists only
|
||||
/// where accepting and carrying are two different days' work apart. See
|
||||
/// [ServiceProfile.handoffAt] and the collected store.
|
||||
collected,
|
||||
|
||||
/// The rider is physically on this stop right now.
|
||||
active,
|
||||
|
||||
@@ -660,17 +873,46 @@ extension StopStateX on StopState {
|
||||
/// Committed to: he has taken it and owes the customer a visit.
|
||||
bool get isCommitted =>
|
||||
this == StopState.accepted ||
|
||||
this == StopState.collected ||
|
||||
this == StopState.active ||
|
||||
this == StopState.skipped;
|
||||
|
||||
String get label => switch (this) {
|
||||
StopState.pending => 'Awaiting your decision',
|
||||
StopState.accepted => 'Accepted',
|
||||
StopState.active => 'In progress',
|
||||
StopState.done => 'Completed',
|
||||
StopState.skipped => 'Skipped',
|
||||
StopState.rejected => 'Rejected',
|
||||
};
|
||||
/// In the rider's hands right now.
|
||||
bool get isCollected => this == StopState.collected;
|
||||
|
||||
/// What this state is called on a card.
|
||||
///
|
||||
/// ── Two vocabularies, one state machine ──
|
||||
///
|
||||
/// The Doormile wording is written for a rider who is being *asked* something:
|
||||
/// "Awaiting your decision" is true because the card under it carries Accept
|
||||
/// and Reject. A service rider is asked nothing — the whole assignment came as
|
||||
/// one commitment — so the same state means "the hub has given you this, it is
|
||||
/// waiting for you to go and collect it", and calling that a decision invites
|
||||
/// him to look for a button that is not there.
|
||||
///
|
||||
/// `Delivered` rather than `Completed` for the same reason: on a meal run the
|
||||
/// last thing that happens at a door is a hand-over, and naming it is worth
|
||||
/// more than a word that covers every stop type in the app.
|
||||
String get label => ServiceProfile.active.acceptsPerStop
|
||||
? switch (this) {
|
||||
StopState.pending => 'Awaiting your decision',
|
||||
StopState.accepted => 'Accepted',
|
||||
StopState.collected => 'Collected',
|
||||
StopState.active => 'In progress',
|
||||
StopState.done => 'Completed',
|
||||
StopState.skipped => 'Skipped',
|
||||
StopState.rejected => 'Rejected',
|
||||
}
|
||||
: switch (this) {
|
||||
StopState.pending => 'Pending',
|
||||
StopState.accepted => 'Accepted',
|
||||
StopState.collected => 'Collected',
|
||||
StopState.active => 'Out for delivery',
|
||||
StopState.done => 'Delivered',
|
||||
StopState.skipped => 'Skipped',
|
||||
StopState.rejected => 'Cancelled',
|
||||
};
|
||||
}
|
||||
|
||||
/// Derives a stop's state from its backend status plus the local
|
||||
@@ -684,6 +926,10 @@ StopState stopStateOf(
|
||||
Map<String, dynamic> stop, {
|
||||
required Set<String> acceptedIds,
|
||||
required Set<String> rejectedIds,
|
||||
|
||||
/// Orders the rider is carrying. Optional so every existing parcel call site
|
||||
/// is unchanged — a Doormile route never populates it.
|
||||
Set<String> collectedIds = const <String>{},
|
||||
}) {
|
||||
final id = (stop['orderid'] ?? '').toString();
|
||||
final raw = (stop['orderstatus'] ?? '').toString().trim().toLowerCase();
|
||||
@@ -704,14 +950,29 @@ StopState stopStateOf(
|
||||
if (raw == 'active' || raw == 'arrived') return StopState.active;
|
||||
if (raw == 'skipped') return StopState.skipped;
|
||||
|
||||
// 3. Local decisions next, and they beat the server's accept/reject.
|
||||
// 2b. Released for delivery — which `pickup-complete` does by itself on
|
||||
// hyperlocal work, in the same call that records the collection. It says
|
||||
// the consignment may be delivered, not that the rider has set off, so it
|
||||
// resolves to *carrying* and never to [StopState.active]. Asked ahead of
|
||||
// the local sets because it must also hold after a reinstall, when the
|
||||
// collected set is empty and this row would otherwise fall through to
|
||||
// `pending` — putting a bag already in his box back on Home as work to
|
||||
// accept. See `MilkRun.stageOf`.
|
||||
if (raw == 'outfordelivery') return StopState.collected;
|
||||
|
||||
// 3. Carrying it beats every remaining local view. He has the food; no
|
||||
// later tap on this screen can make that untrue, and a refetch that still
|
||||
// reports `accepted` must not put the card back on Home.
|
||||
if (collectedIds.contains(id)) return StopState.collected;
|
||||
|
||||
// 4. Local decisions next, and they beat the server's accept/reject.
|
||||
// They are strictly newer: the rider just tapped, and the server view is
|
||||
// at best one poll behind. Without this, un-rejecting a stop would be
|
||||
// undone by the next refetch still reporting `rejected`.
|
||||
if (rejectedIds.contains(id)) return StopState.rejected;
|
||||
if (acceptedIds.contains(id)) return StopState.accepted;
|
||||
|
||||
// 4. Finally the server's own view.
|
||||
// 5. Finally the server's own view.
|
||||
if (raw == 'rejected') return StopState.rejected;
|
||||
if (raw == 'accepted') return StopState.accepted;
|
||||
return StopState.pending;
|
||||
@@ -732,6 +993,41 @@ enum StopProgress {
|
||||
skipped,
|
||||
}
|
||||
|
||||
/// How one stop's lifecycle rung reads on the route rail.
|
||||
///
|
||||
/// ── "Physically on" is a question about BOTH legs ──
|
||||
///
|
||||
/// This lived inline in `_MyPickupsState._tripProgress` and tested
|
||||
/// `isActive || arrived` — and both of those are **pickup-leg** rungs. It is
|
||||
/// read on the Deliveries tab, where every stop is past `pickup-complete` by
|
||||
/// definition, so on a milk run the rung the rider is actually standing on is
|
||||
/// [StopStatus.deliveryArrived], which matched neither. The customer's door he
|
||||
/// was at *that moment* drew grey — indistinguishable from the eleven he had
|
||||
/// not ridden to yet — and the scooter fell through to the caller's "promote
|
||||
/// the first pending one" fallback, parking it somewhere up the route behind
|
||||
/// him.
|
||||
///
|
||||
/// The rail's whole job is *where am I*. Answering it from one leg's
|
||||
/// vocabulary, on the tab that only ever shows the other leg, is why the answer
|
||||
/// was wrong all round.
|
||||
///
|
||||
/// [StopStatusX.isWorkComplete] decides `done`, so the answer is line-aware:
|
||||
/// `picked` ends a logistics stop and is the middle of the morning on a round.
|
||||
///
|
||||
/// Pure, and out here rather than on the State, because `_MyPickupsState`
|
||||
/// cannot be pumped — Get, Geolocator and a 3s poll — so a mapping left inside
|
||||
/// it can only ever be verified by reading it. See `trip_progress_test.dart`.
|
||||
StopProgress stopProgressFor(StopStatus status) {
|
||||
if (status.isWorkComplete) return StopProgress.done;
|
||||
if (status.isSkipped) return StopProgress.skipped;
|
||||
if (status.isActive ||
|
||||
status == StopStatus.arrived ||
|
||||
status == StopStatus.deliveryArrived) {
|
||||
return StopProgress.current;
|
||||
}
|
||||
return StopProgress.pending;
|
||||
}
|
||||
|
||||
/// Formats a duration the way a rider plans: `2h 15m`, `45 min`.
|
||||
String formatTripDuration(Duration d) {
|
||||
if (d.inMinutes < 1) return '—';
|
||||
|
||||
Reference in New Issue
Block a user