Files
doormile_milderapp/lib/views/Dashboard/home/trip.dart
2026-09-09 12:55:23 +05:30

1232 lines
49 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import 'package:miler/data/work_domain.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// A TRIP — one slot's worth of work, as a single unit.
///
/// The hub manager does not assign bookings one at a time. He takes every
/// customer booking that falls inside a slot (say 10:00–13:00), orders them
/// into a route, and hands the whole thing to one miler. So the rider's
/// decision is never "do I want this one parcel?" — it is "do I take this
/// trip?". Home used to show loose bookings, which framed the job as a
/// marketplace it isn't, and hid the two facts that actually matter: how long
/// the whole run takes, and where it starts and ends.
///
/// A trip is therefore always shaped:
///
/// HUB → Stop 1 → Stop 2 → … → Stop N → HUB
///
/// with the same hub at both ends. Every duration and distance below includes
/// the return leg, because the rider is not finished until he is back.
///
/// 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.
///
/// ── This split is the client's, and the backend has confirmed it ──
///
/// Asked directly (24 Aug 2026): *does the console own Trip 1/2/3?* Answer:
/// **no — there is no `tripid` or `slotid` backend-side.** The day-part
/// grouping is therefore documented client behaviour rather than a guess
/// standing in for a field that exists, and the app is not waiting on one.
///
/// If that ever changes, this is the class to delete: the ids would arrive on
/// the booking row and the split would stop being derived at all.
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;
/// The customer-facing slot window this trip serves. Null when the backend
/// sends no usable times — the trip is then simply "today's route".
final DateTime? slotStart;
final DateTime? slotEnd;
/// Stops in the admin's fixed order. Never re-sorted by the app.
final List<Map<String, dynamic>> stops;
/// Straight-line round-trip length in metres, hub → stops → hub.
final double routeMeters;
/// Sum of the per-stop service estimates (time off the bike).
final Duration serviceDuration;
/// Time on the bike, at [RouteMetricsHelper.kDefaultSpeedMps].
final Duration travelDuration;
final int totalParcels;
final int deliverParcels;
final int collectParcels;
final double totalWeightKg;
final double cashToCollect;
const Trip({
required this.id,
required this.slotStart,
required this.slotEnd,
required this.stops,
required this.routeMeters,
required this.serviceDuration,
required this.travelDuration,
required this.totalParcels,
required this.deliverParcels,
required this.collectParcels,
required this.totalWeightKg,
required this.cashToCollect,
});
int get stopCount => stops.length;
/// The same trip, recounted over **only the work still owed on Home**.
///
/// ── Why the brief was reading the wrong number ──
///
/// The four figures in [TripBriefStrip] — duration, distance, parcels,
/// payment — were computed once, over every stop the slot ever held, and
/// never moved again. The headline above them says `20 stops left` and
/// counts down as the rider works; the figures under it kept describing the
/// morning he started with.
///
/// So a rider with five collections to go read `≈4h 15m · 29.0 km · 25
/// parcels` — the whole day, including the twenty stops already in his box
/// and gone to Deliveries. The one question the card exists to answer, *what
/// is left in front of me*, was the one thing on it that was stale.
///
/// ── Where the line is ──
///
/// [WorkBoundary.pickupComplete] — the same boundary Home and Deliveries
/// already split on, so a stop cannot be counted here and owned there. Once
/// the collection is done the stop belongs to the delivery leg and is not
/// this card's business. Skipped and cancelled work is dropped for the same
/// reason: it is a record now, not a stop to plan around.
///
/// [origin] is the point the round trip is measured from — the rider's own
/// position, which is what the page passes to [Trip.fromStops] as the hub.
/// Passing it keeps the recount on the same footing as the original
/// measurement; omitting it would silently fall back to per-stop kilometres
/// and read as a distance that changed for no reason.
///
/// Returns `this` unchanged when nothing has been collected yet, so the
/// common case allocates nothing.
/// ── Declined work is not outstanding work ──
///
/// [rejectedIds] is the set the rider's own decision writes, and it has to be
/// passed in because a decline is **local first**: the stop stays in the
/// payload reading `assigned` until the backend catches up, so
/// `stopStatusOf` cannot see it and the figures counted a stop the rider had
/// refused. He declined four of eight and the card still measured the
/// kilometres to all eight.
Trip outstanding({
Set<String> collectedIds = const <String>{},
Set<String> rejectedIds = const <String>{},
double? originLat,
double? originLng,
}) {
final left = <Map<String, dynamic>>[
for (final s in stops)
if (!WorkBoundary.pickupComplete(s, collectedIds: collectedIds) &&
!stopStatusOf(s).isCancelled &&
!stopStatusOf(s).isSkipped &&
!stopStatusOf(s).isRejected &&
!rejectedIds.contains((s['orderid'] ?? '').toString()))
s,
];
if (left.length == stops.length) return this;
return Trip.fromStops(
id: id,
stops: left,
slotStart: slotStart,
slotEnd: slotEnd,
hubLat: originLat,
hubLng: originLng,
);
}
/// The number the rider is actually planning around: ride time plus every
/// minute spent standing at a door.
Duration get totalDuration => travelDuration + serviceDuration;
/// Average time per stop across the whole trip, service only.
Duration get averageStopDuration => stopCount == 0
? Duration.zero
: Duration(seconds: serviceDuration.inSeconds ~/ stopCount);
/// `10:00 AM – 1:00 PM`, or a plain label when the slot is unknown.
String get slotLabel {
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)}';
}
/// The slot as a **clock range**, or `''` when it has none.
///
/// ── Why a day part is not printed at clock position ──
///
/// [slotLabel] answers "what is this trip called" and names a whole day part
/// as *Morning* / *Afternoon* / *Evening*, which is right for a heading.
/// It is wrong wherever the app is printing a live figure.
///
/// Two things happen when it lands there. The word is a **label pretending to
/// be data**: it sits where "3h 10m left" sits, in the corner a rider checks
/// for how much time he has, and answers with a noun he cannot act on. And it
/// is redundant twice over — the trip is already numbered `Trip 1`, and the
/// number *is* the day part, so `Trip 1 · Morning` says one thing twice.
///
/// This became visible when trips started being placed by day part at all:
/// before that they carried no window, [slotLabel] fell through to "Today's
/// route", and the same call sites printed nothing. So the fix is not to undo
/// the placement — it is to stop reading a heading as a measurement.
///
/// Returns a range only when the hub sent a real one: `10:00 AM – 1:00 PM`.
String get slotClockLabel {
final s = slotStart;
final e = slotEnd;
if (s == null || e == null) return '';
if (DayPart.spans(s, e)) return '';
return '${RouteMetricsHelper.formatClock(s)} – '
'${RouteMetricsHelper.formatClock(e)}';
}
/// True when the slot window has already closed.
bool isExpired([DateTime? now]) {
final e = slotEnd;
if (e == null) return false;
return (now ?? DateTime.now()).isAfter(e);
}
/// Order ids in this trip.
List<String> get orderIds => [
for (final s in stops)
if ((s['orderid'] ?? '').toString().isNotEmpty) (s['orderid']).toString(),
];
// ── Per-stop state & completion ────────────────────────────────────────
/// State of every stop, in route order.
List<StopState> states({
required Set<String> acceptedIds,
required Set<String> rejectedIds,
}) => [
for (final s in stops)
stopStateOf(s, acceptedIds: acceptedIds, rejectedIds: rejectedIds),
];
/// Ids an "accept whole trip" action should operate on — only the stops
/// still awaiting a decision. Re-accepting one already accepted would be a
/// wasted call, and re-accepting a rejected one would undo the rider.
List<String> pendingOrderIds({
required Set<String> acceptedIds,
required Set<String> rejectedIds,
}) => [
for (final s in stops)
if (stopStateOf(s, acceptedIds: acceptedIds, rejectedIds: rejectedIds) ==
StopState.pending)
(s['orderid'] ?? '').toString(),
].where((id) => id.isNotEmpty).toList();
/// Fraction of the trip that is finished, 0.0 → 1.0.
///
/// Counts **resolved** stops — done or rejected — not just completed ones.
/// A rejected stop is off the rider's plate; leaving it out of the numerator
/// would strand the trip below 100% with nothing he could do about it.
double completion({
required Set<String> acceptedIds,
required Set<String> rejectedIds,
}) {
if (stops.isEmpty) return 0;
final resolved = states(
acceptedIds: acceptedIds,
rejectedIds: rejectedIds,
).where((s) => s.isResolved).length;
return resolved / stops.length;
}
/// Completion as a whole percent, for display.
int completionPercent({
required Set<String> acceptedIds,
required Set<String> rejectedIds,
}) => (completion(acceptedIds: acceptedIds, rejectedIds: rejectedIds) * 100)
.round();
/// True once every stop has been resolved — the rider can head back.
bool isFinished({
required Set<String> acceptedIds,
required Set<String> rejectedIds,
}) =>
stops.isNotEmpty &&
states(
acceptedIds: acceptedIds,
rejectedIds: rejectedIds,
).every((s) => s.isResolved);
/// True while any stop still needs an accept/reject decision.
bool hasPendingStops({
required Set<String> acceptedIds,
required Set<String> rejectedIds,
}) => states(
acceptedIds: acceptedIds,
rejectedIds: rejectedIds,
).any((s) => s.needsDecision);
/// 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 = dayPartTimeOf(s);
if (due == null) continue;
if (best == null || due.isBefore(best)) best = due;
}
return best;
}
/// The clock that decides which part of the day a stop belongs to.
///
/// ── Due time first, assignment time second ──
///
/// The right answer is when the work is *due*: an order to be collected at
/// 10am is morning work whenever the hub happened to raise it. That is
/// `expected_pickup_time`, and where the backend sends it nothing here
/// changes.
///
/// Where it does not — which is the live Doormile contract today — the day
/// part had nothing to read at all, and every stop fell into the undated
/// `day:` bucket. One trip, no day part, and [TripSlots.slots] put it in the
/// first free slot: **everything was Trip 1, all day**. A rider given an
/// evening run opened Home to find it filed under the morning.
///
/// So the fallback is when the work reached him — `assignedat`, merged onto
/// the row by [WorkRepository] from the assignment ledger. It is the hub's
/// own act as the phone observed it, and it is what dispatch means on the
/// telephone: work handed over in the morning is the morning run. Approximate
/// where a due time would be exact, and exactly right for the common case,
/// which is a slot assigned at the top of the slot it is for.
///
/// `eta` is read last and usually answers nothing — on the v1 adapter it is
/// a duration, not a clock, and [_parseTimestamp] rejects it rather than
/// inventing a 15th of the month out of "15 min".
static DateTime? dayPartTimeOf(Map<String, dynamic> stop) => _parseTimestamp(
stop['expected_pickup_time'] ??
stop['expectedpickuptime'] ??
stop['assignedat'] ??
stop['assignedon'] ??
stop['eta'],
);
/// Address components every stop on this trip shares — city, state,
/// pincode and so on. Cached per build by the caller.
List<String> get sharedAddressTail => commonAddressTail([
for (final s in stops) (s['pickupaddress'] ?? '').toString(),
]);
/// The distinguishing part of a stop's address, with the shared tail removed.
String shortAddress(Map<String, dynamic> stop, List<String> tail) =>
stripAddressTail((stop['pickupaddress'] ?? '').toString(), tail);
// ── Per-stop estimates ─────────────────────────────────────────────────
/// Estimated time OFF the bike at [index].
///
/// Service time is not a constant. Handing a parcel over and reading back an
/// OTP is quick; weighing and photographing a collection is slower; doing
/// both at one door is slower still; and counting cash adds real minutes.
/// These are the numbers the pace warning on Home is measured against, so
/// they are deliberately not optimistic.
static Duration serviceTimeFor(Map<String, dynamic> stop) {
final kind = stopKindOf(stop);
var seconds = switch (kind) {
StopKind.delivery => 150, // 2m30 — hand over, OTP, photo
StopKind.pickup => 240, // 4m00 — collect, weigh, photo
StopKind.combined => 330, // 5m30 — both, but one walk-up
};
// Cash handling is the single biggest variable at a door.
if (stopCollectionAmount(stop) > 0) seconds += 90;
// Multi-parcel stops take longer to count and load. First parcel is in
// the base estimate; each extra adds handling time.
final parcels = deliveryParcelCount(stop) + pickupParcelCount(stop);
if (parcels > 1) seconds += (parcels - 1) * 25;
return Duration(seconds: seconds);
}
/// 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,
// ── 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], toDrop: deliveryLeg);
final to = _coordsOf(stops[index], toDrop: deliveryLeg);
if (from == null || to == null) return Duration.zero;
final meters = RouteMetricsHelper.distanceMeters(
from.lat,
from.lng,
to.lat,
to.lng,
);
return RouteMetricsHelper.travelTime(meters);
}
/// Running clock estimate of when the rider reaches stop [index], assuming
/// he leaves the hub at [departure].
DateTime etaForStop(
int index, {
required DateTime departure,
double? hubLat,
double? hubLng,
}) {
var clock = departure;
for (var i = 0; i <= index && i < stops.length; i++) {
clock = clock.add(travelTimeToStop(i, hubLat: hubLat, hubLng: hubLng));
if (i < index) clock = clock.add(serviceTimeFor(stops[i]));
}
return clock;
}
({double lat, double lng})? _origin(double? lat, double? lng) =>
(lat != null && lng != null && lat != 0 && lng != 0)
? (lat: lat, lng: lng)
: null;
// ── Construction ───────────────────────────────────────────────────────
/// Builds one trip from an ordered list of stops.
///
/// [hubLat]/[hubLng] anchor both ends of the round trip. The backend does
/// not yet send hub coordinates, so callers pass the rider's current
/// position — he is at (or near) the hub when he picks up a trip, which
/// makes it a workable stand-in. When it is missing, the route length is the
/// stop-to-stop chain only and the return leg is simply absent rather than
/// guessed.
factory Trip.fromStops({
required String id,
required List<Map<String, dynamic>> stops,
DateTime? slotStart,
DateTime? slotEnd,
double? hubLat,
double? hubLng,
}) {
final ordered = sortStops(stops);
int parcels = 0;
int deliver = 0;
int collect = 0;
double weight = 0;
double cash = 0;
var service = Duration.zero;
for (final s in ordered) {
final d = deliveryParcelCount(s);
final c = pickupParcelCount(s);
deliver += d;
collect += c;
parcels += (d + c) > 0 ? (d + c) : 1;
weight += _toDouble(s['weight'] ?? s['Weight']);
cash += stopCollectionAmount(s);
service += serviceTimeFor(s);
}
// Round trip: hub → every stop in order → back to the hub.
final chain = <({double lat, double lng})>[];
final origin =
(hubLat != null && hubLng != null && hubLat != 0 && hubLng != 0)
? (lat: hubLat, lng: hubLng)
: null;
if (origin != null) chain.add(origin);
for (final s in ordered) {
final c = _coordsOf(s);
if (c != null) chain.add(c);
}
if (origin != null && chain.length > 1) chain.add(origin);
double meters = 0;
for (var i = 0; i < chain.length - 1; i++) {
meters += RouteMetricsHelper.distanceMeters(
chain[i].lat,
chain[i].lng,
chain[i + 1].lat,
chain[i + 1].lng,
);
}
if (meters == 0) {
// Fall back to whatever per-stop kilometres the backend sent.
for (final s in ordered) {
meters += _toDouble(s['kms'] ?? s['km']) * 1000;
}
}
return Trip(
id: id,
slotStart: slotStart,
slotEnd: slotEnd,
stops: ordered,
routeMeters: meters,
serviceDuration: service,
travelDuration: RouteMetricsHelper.travelTime(meters),
totalParcels: parcels,
deliverParcels: deliver,
collectParcels: collect,
totalWeightKg: weight,
cashToCollect: cash,
);
}
/// Groups loose stops into trips.
///
/// Grouping key, in order of trust:
/// 1. An explicit backend `tripid` / `slotid` / `routeid`.
/// 2. An explicit slot window (`slotstarttime` + `slotendtime`).
/// 3. The `expected_pickup_time` bucketed into a [windowHours] block
/// aligned to the clock — 10:00–13:00, 13:00–16:00, and so on. This is
/// how the hub builds slots, so bucketing reproduces them closely enough
/// to be useful while the backend has no trip field.
/// 4. Everything else lands in one "today's route" trip rather than being
/// dropped.
///
/// Returned trips are ordered by slot start, earliest first.
/// The most trips a rider's day can contain.
///
/// Three is the operational fact, not a display limit: the hub assigns morning,
/// afternoon and evening slots. [TripTabs] renders three slots for the same
/// reason, and [_capToMaxTrips] folds any extra buckets into the last so the
/// two never disagree.
static const int maxTripsPerDay = 3;
static List<Trip> groupIntoTrips(
List<Map<String, dynamic>> stops, {
/// 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,
}) {
if (stops.isEmpty) return const [];
final clock = now ?? DateTime.now();
final buckets = <String, List<Map<String, dynamic>>>{};
final windows = <String, (DateTime?, DateTime?)>{};
for (final stop in stops) {
final explicitId = _firstNonEmpty(stop, [
'tripid',
'tripId',
'slotid',
'slotId',
'routeid',
'routeId',
]);
final slotFrom = _parseTimestamp(
stop['slotstarttime'] ?? stop['slotStartTime'] ?? stop['slotfrom'],
);
final slotTo = _parseTimestamp(
stop['slotendtime'] ?? stop['slotEndTime'] ?? stop['slotto'],
);
String key;
DateTime? from;
DateTime? to;
if (explicitId != null) {
key = 'id:$explicitId';
from = slotFrom;
to = slotTo;
} else if (slotFrom != null && slotTo != null) {
key = 'slot:${slotFrom.toIso8601String()}';
from = slotFrom;
to = slotTo;
} else {
// Due time where the hub sends one, and the moment the work reached
// the rider where it does not — see [Trip.dayPartTimeOf]. Without the
// second half every stop landed in the undated bucket below and the
// whole day rendered as Trip 1.
final due = dayPartTimeOf(stop);
if (due != null) {
// 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;
key = 'day:${clock.year}-${clock.month}-${clock.day}';
}
}
buckets.putIfAbsent(key, () => []).add(stop);
windows.putIfAbsent(key, () => (from, to));
}
final trips = <Trip>[
for (final entry in buckets.entries)
Trip.fromStops(
id: entry.key,
stops: entry.value,
slotStart: windows[entry.key]?.$1,
slotEnd: windows[entry.key]?.$2,
hubLat: hubLat,
hubLng: hubLng,
),
];
trips.sort((a, b) {
final as = a.slotStart;
final bs = b.slotStart;
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);
});
return _capToMaxTrips(trips, hubLat: hubLat, hubLng: hubLng);
}
/// A rider's day is three trips. Anything past that is folded into the last.
///
/// The bucketing above is time-based — a stop with no trip id lands in a
/// three-hour window — so a long day, or one stop booked well outside the
/// others, can produce a fourth or fifth bucket. That surfaced as a "Trip 4"
/// tab, which is not a thing the hub assigns.
///
/// **Merged, never dropped.** Truncating the list would be the one-line fix
/// and it would silently hide real work: those stops are accepted bookings the
/// rider is expected to complete, and a stop he cannot see is a stop he cannot
/// deliver. The tail is therefore folded into the third trip, whose slot then
/// spans from its own start to the last stop's end, so the label stays honest
/// about what it now contains.
static List<Trip> _capToMaxTrips(
List<Trip> trips, {
double? hubLat,
double? hubLng,
}) {
if (trips.length <= maxTripsPerDay) return trips;
final kept = trips.take(maxTripsPerDay - 1).toList();
final tail = trips.skip(maxTripsPerDay - 1).toList();
final mergedStops = [for (final t in tail) ...t.stops];
final starts = tail.map((t) => t.slotStart).whereType<DateTime>();
final ends = tail.map((t) => t.slotEnd).whereType<DateTime>();
kept.add(
Trip.fromStops(
id: tail.first.id,
stops: mergedStops,
slotStart: starts.isEmpty
? null
: starts.reduce((a, b) => a.isBefore(b) ? a : b),
slotEnd: ends.isEmpty
? null
: ends.reduce((a, b) => a.isAfter(b) ? a : b),
hubLat: hubLat,
hubLng: hubLng,
),
);
return kept;
}
/// 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 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,
) => orderStops(stops).$1;
/// [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, {
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;
return (lat: lat, lng: lng);
}
static String? _firstNonEmpty(Map<String, dynamic> m, List<String> keys) {
for (final k in keys) {
final v = m[k];
final s = v?.toString().trim() ?? '';
if (s.isNotEmpty && s != '0' && s != 'null') return s;
}
return null;
}
static DateTime? _parseTimestamp(dynamic v) {
if (v == null) return null;
return DateTime.tryParse(v.toString().trim());
}
static double _toDouble(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();
final cleaned = v.toString().replaceAll(RegExp(r'[^0-9.\-]'), '');
return double.tryParse(cleaned) ?? 0;
}
}
/// ─────────────────────────────────────────────────────────────────────────
/// ADDRESS COMPRESSION
///
/// A raw stop address is written for a postal system, not a rider:
///
/// "5/2, North Avenue, Vadavalli, Coimbatore, Tamil Nadu, 641041"
///
/// On a six-stop trip, "Coimbatore, Tamil Nadu, 641041" is repeated six times
/// and distinguishes nothing — every stop is in the same city, or the hub
/// would not have put them on one route. It is pure noise, and it is the noise
/// that pushes the useful part ("5/2, North Avenue") onto a second line and
/// then off the end of an ellipsis.
///
/// So: find the trailing components EVERY stop on the trip shares, and drop
/// them. What survives is exactly the part that tells one stop from another.
/// The full address is never lost — it goes to the detail sheet and to the
/// navigation hand-off untouched.
///
/// This is data-driven rather than a hardcoded list of cities and states: it
/// works for any city, any country, and degrades to a no-op on a one-stop trip
/// (where nothing is shared, so nothing is dropped).
/// ─────────────────────────────────────────────────────────────────────────
/// Splits an address into trimmed, non-empty components.
List<String> addressParts(String address) =>
address.split(',').map((p) => p.trim()).where((p) => p.isNotEmpty).toList();
/// The trailing components shared by every address in [addresses].
///
/// Returns an empty list when fewer than two addresses are given, or when the
/// tails differ — there is then nothing redundant to remove.
List<String> commonAddressTail(List<String> addresses) {
final parts = [
for (final a in addresses)
if (addressParts(a).isNotEmpty) addressParts(a),
];
if (parts.length < 2) return const [];
final shortest = parts.map((p) => p.length).reduce((a, b) => a < b ? a : b);
final tail = <String>[];
for (var back = 1; back < shortest; back++) {
final candidate = parts.first[parts.first.length - back].toLowerCase();
final shared = parts.every(
(p) => p[p.length - back].toLowerCase() == candidate,
);
if (!shared) break;
tail.insert(0, parts.first[parts.first.length - back]);
}
return tail;
}
/// [address] with [tail] removed. Always leaves at least one component, so a
/// stop can never render a blank address.
String stripAddressTail(String address, List<String> tail) {
if (tail.isEmpty) return address.trim();
final parts = addressParts(address);
var end = parts.length;
for (var i = tail.length - 1; i >= 0; i--) {
if (end <= 1) break;
if (parts[end - 1].toLowerCase() != tail[i].toLowerCase()) break;
end--;
}
return parts.sublist(0, end).join(', ');
}
/// Where a single stop sits in the accept → work → done lifecycle.
///
/// This is what lets one trip card serve both Home and Bookings. A trip does
/// not leave Home the moment it is accepted — the rider still wants to watch
/// it fill up — so every stop carries its own state and the card renders the
/// right control for it: Accept/Reject while pending, a progress tick once
/// it's moving, nothing at all once it's done.
enum StopState {
/// Awaiting the rider's decision. Shows Accept + Reject.
pending,
/// 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,
/// He is **at** the stop and has said so — the arrival is recorded, the
/// pickup is not yet made.
///
/// ── Why this is not [active] ──
///
/// It was. `stopStateOf` folded `arrived` and `active` into one value, so the
/// Home row had no arrived to render and the live chip printed the literal
/// word **Active** the moment a rider slid *confirm arrival*. He was told his
/// arrival had not registered, when in fact it had: the rung moved, the API
/// fired, the store was written, and the one word on screen said something
/// else.
///
/// They are different facts. Arrived is a rung on the pickup ladder that the
/// rider puts the stop on. Active is an operational statement about which
/// stop is being worked — see `_activePickupOrderId` — and it is the word the
/// backend reserves for a delivery under way.
arrived,
/// The rider is physically on this stop right now.
active,
/// Picked up / delivered.
done,
/// Skipped, awaiting a return visit. Not a failure.
skipped,
/// The rider declined this stop. It stays visible, struck through, so he
/// can see he handled it rather than wondering where it went.
rejected,
}
extension StopStateX on StopState {
/// Counts toward "trip complete". A rejected stop is resolved — the rider
/// has nothing left to do with it — so it must not hold the trip at 90%
/// forever.
bool get isResolved => this == StopState.done || this == StopState.rejected;
/// Still needs a decision from the rider.
bool get needsDecision => this == StopState.pending;
/// Committed to: he has taken it and owes the customer a visit.
bool get isCommitted =>
this == StopState.accepted ||
this == StopState.collected ||
this.isAtSource ||
this == StopState.skipped;
/// In the rider's hands right now.
bool get isCollected => this == StopState.collected;
/// He is **at** the source and the pickup is not yet made.
///
/// ── Why this is a predicate and not a comparison ──
///
/// [StopState.arrived] and [StopState.active] were one value. Splitting them
/// apart is what finally let the row say *Arrived* instead of *Active* — and
/// it silently changed the answer at every site that had written
/// `== StopState.active` to mean **is he standing at the counter**. There
/// were a dozen, and each of them began answering `false` for the one rung
/// whose entire point is that he is standing there:
///
/// • Home's visibility filter — so a stop *left the screen* the moment he
/// marked it arrived, and Deliveries would not take it either because
/// arrival is pre-pickup. The stop was in neither place.
/// • the selection bar's action, which offered *Accept* for a stop he was
/// already at
/// • the card's own **Mark as picked** button, which stopped being built
/// • which group opens by default, and which one counts as live
///
/// One question asked in one place, so the next rung added to the ladder
/// cannot quietly repeat it.
bool get isAtSource => this == StopState.arrived || this == StopState.active;
/// 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.arrived => 'Arrived',
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.arrived => 'Arrived',
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
/// accepted/rejected stores.
///
/// Local stores win for accept/reject because those writes are optimistic —
/// the rider's tap is recorded instantly and the network catches up. If the
/// server view won, a slow response would bounce a stop back to "pending"
/// under his thumb.
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();
// 1. Irreversible server states win over everything. A parcel that has been
// collected or handed over cannot be un-decided by a local tap.
if (raw == 'picked' ||
raw == 'picked up' ||
raw == 'pickedup' ||
raw == 'pickuped' ||
raw == 'delivered' ||
raw == 'cancelled' ||
raw == 'canceled') {
return StopState.done;
}
// 2. He is at the stop — also not a local decision.
//
// Two values, not one. Folding them lost the arrival: see [StopState.arrived].
if (raw == 'arrived') return StopState.arrived;
if (raw == 'active') return StopState.active;
if (raw == 'skipped') return StopState.skipped;
// 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;
// 5. Finally the server's own view.
if (raw == 'rejected') return StopState.rejected;
if (raw == 'accepted') return StopState.accepted;
return StopState.pending;
}
/// How a stop is progressing through the trip, for the progress rail.
enum StopProgress {
/// Finished — picked up / delivered. Rail turns green here.
done,
/// The stop the rider is on right now.
current,
/// Not started.
pending,
/// Skipped and awaiting a return visit.
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 '—';
if (d.inMinutes < 60) return '${d.inMinutes} min';
final minutes = d.inMinutes.remainder(60);
return minutes == 0 ? '${d.inHours}h' : '${d.inHours}h ${minutes}m';
}
/// Rounds a duration up to the nearest 5 minutes.
///
/// An estimate printed as "37 min" claims a precision the arithmetic does not
/// have and invites the rider to treat it as a promise. "40 min" reads as
/// what it is.
Duration roundTripDuration(Duration d) {
if (d.inSeconds <= 0) return Duration.zero;
final minutes = (d.inMinutes / 5).ceil() * 5;
return Duration(minutes: math.max(5, minutes));
}