import 'package:flutter/foundation.dart'; /// ───────────────────────────────────────────────────────────────────────── /// THE ADMIN'S ROUTE IS THE ROUTE /// /// One rule, in one place, for the only question that decides what order a /// rider works his stops in: **has the hub solved this route?** /// /// If yes — that order is used, exactly, on both legs. The app does not /// improve it, shorten it, or reorder it by where the rider /// happens to be standing. /// If no — the app falls back, and *says* it is falling back. /// /// ── Why this needed taking out of the screens ── /// /// It was answered in two places that could not see each other. /// `Trip.sortStops` — the pickup leg — reads `step` and documents it as /// authoritative. The Deliveries tab had its own sort: stops with a step /// first, then everything else **by straight-line distance from the rider's /// current GPS fix**. Since `step` reached the app only through /// `GET /miler/assignments`, and that endpoint is deliberately the *active* /// queue (`Assigned`/`Accepted` only), a booking lost its step at the moment /// it was collected — which is exactly when the delivery leg starts. /// /// So every delivery was ordered nearest-first, the hub believed its solved /// sequence was being followed, and nothing on either side said otherwise. /// A rider re-optimising a route the hub has planned is not a small /// difference: it changes promised arrival windows the customer was given. /// /// ── The fallback is a fallback ── /// /// Proximity ordering is not wrong in itself — with no assigned sequence the /// app has to pick something, and nearest-first is the best guess available. /// What is wrong is presenting a guess as the hub's plan. [RouteOrder.sort] /// therefore returns *which rule it applied*, so a screen can label the queue /// honestly and a test can assert the rule rather than the resulting order. /// /// ── One sequenced stop is enough ── /// /// If **any** stop in the set carries a sequence, the whole set is treated as /// admin-ordered: the sequenced stops lead in their solved order, and the rest /// follow. Mixing the two — sorting the sequenced ones by step and the others /// by distance — is what the Deliveries tab used to do, and it produces a list /// that is neither the hub's route nor a sane one. /// ───────────────────────────────────────────────────────────────────────── enum RouteOrderSource { /// The hub solved it. `step` (or an equivalent) came down populated. adminSequence, /// No sequence, but the stops carry booked times — work them in the order /// they were promised for. bookedTime, /// No sequence and no times. Whatever order the backend listed them in, /// preserved rather than replaced. backendOrder, /// No sequence, and the app chose nearest-first from the rider's position. /// **A guess.** Must be labelled as one wherever it reaches a screen. proximity, } extension RouteOrderSourceX on RouteOrderSource { /// True when the order came from the hub and must not be second-guessed. bool get isAdmin => this == RouteOrderSource.adminSequence; /// What the rider is told the list is ordered by. Short, because it sits /// under a heading and not in a paragraph. String get label => switch (this) { RouteOrderSource.adminSequence => 'Assigned route', RouteOrderSource.bookedTime => 'By booked time', RouteOrderSource.backendOrder => 'As assigned', RouteOrderSource.proximity => 'Nearest first', }; /// The longer form, for a place with room to explain — and specifically to /// keep the app from implying the hub planned an order it did not. String get explanation => switch (this) { RouteOrderSource.adminSequence => 'Ordered by the route your office assigned.', RouteOrderSource.bookedTime => 'No route assigned — ordered by booked time.', RouteOrderSource.backendOrder => 'No route assigned — shown in the order they came through.', RouteOrderSource.proximity => 'No route assigned — ordered by what is closest to you.', }; } /// A stop's place in the hub's solved route, and the sort that honours it. abstract final class RouteOrder { /// Field names that have carried the solved sequence. /// /// `step` is the one the backend writes. The rest are spellings seen in the /// wild or named in the contract for the delivery leg; reading all of them /// costs nothing and means a rename does not silently drop the whole rule /// back to nearest-first — the failure mode that started this. static const List sequenceKeys = [ 'step', 'Step', 'deliverystep', 'deliveryStep', 'routestep', 'routeStep', 'sequence', 'routesequence', 'routeSequence', 'stopsequence', ]; /// This stop's place in the route, or `0` for *not sequenced*. /// /// **Zero is not "first".** The hub numbers from 1, so a 0 means the /// optimizer has not run for this stop, and treating it as position zero /// would put unsequenced work at the head of a solved route. static int sequenceOf(Map stop) { for (final key in sequenceKeys) { final raw = stop[key]; if (raw == null) continue; final n = raw is num ? raw.toInt() : int.tryParse(raw.toString()) ?? 0; if (n > 0) return n; } return 0; } /// Field names that have carried the moment the route was solved. static const List sequencedAtKeys = [ 'sequencedat', 'sequencedAt', 'sequenced_at', 'routesequencedat', ]; /// True when this stop carries a solve timestamp. /// /// ── The authority signal, confirmed by the backend ── /// /// `sequencedat` is what says a route exists: **non-null → follow `step` /// exactly; null → no route was assigned, use a fallback.** Sequencing is /// automatic on every assignment — there is no operator action to wait for — /// so `step: 0` with a null stamp means one of exactly three things: the /// rider has fewer than two active stops, a stop is missing coordinates, or /// the row predates the fix. None of those is a route to follow. static bool isSequenced(Map stop) { for (final key in sequencedAtKeys) { final raw = stop[key]; if (raw == null) continue; if (raw.toString().trim().isEmpty) continue; return true; } return false; } /// True when the hub has solved an order for at least one of these stops. /// /// The stamp is the authority and a positive `step` is accepted alongside /// it: a deployment that populates one without the other is still telling /// the app it has a route, and refusing to follow a numbered sequence /// because its timestamp is missing would be reading the contract against /// the rider. Either is enough; neither means no route. static bool hasAdminSequence(Iterable> stops) => stops.any((s) => isSequenced(s) || sequenceOf(s) > 0); /// Puts [stops] in the order they are to be worked, and says which rule it /// used. /// /// [distanceTo] is the escape hatch for the proximity fallback: the caller /// supplies metres from the rider to a stop, because this file is pure and /// has no business knowing about GPS. Omit it and proximity is simply never /// used — which is the correct behaviour with no fix available, not a /// reason to leave the list unsorted. static (List>, RouteOrderSource) sort( List> stops, { double Function(Map stop)? distanceTo, DateTime? Function(Map stop)? bookedTimeOf, }) { if (stops.length <= 1) { return ( List>.from(stops), hasAdminSequence(stops) ? RouteOrderSource.adminSequence : RouteOrderSource.backendOrder, ); } // Indexed so every comparison can fall back to the order the backend sent, // which makes the sort stable and keeps "unordered" meaning *unchanged*. final indexed = <(int, Map)>[ for (var i = 0; i < stops.length; i++) (i, stops[i]), ]; if (hasAdminSequence(stops)) { indexed.sort((a, b) { final sa = sequenceOf(a.$2); final sb = sequenceOf(b.$2); if (sa > 0 && sb > 0 && sa != sb) return sa - sb; // Sequenced work leads. An unsequenced stop appended to a solved route // is a stop the hub did not plan for, and it goes at the end of it. if (sa > 0 && sb == 0) return -1; if (sb > 0 && sa == 0) return 1; return a.$1 - b.$1; }); return ([for (final e in indexed) e.$2], RouteOrderSource.adminSequence); } // ── No admin route. Only now may the app choose. ── final times = {}; if (bookedTimeOf != null) { for (final e in indexed) { final t = bookedTimeOf(e.$2); if (t != null) times[e.$1] = t; } } if (times.length > 1) { indexed.sort((a, b) { final ta = times[a.$1]; final tb = times[b.$1]; if (ta != null && tb != null && ta != tb) return ta.compareTo(tb); if (ta != null && tb == null) return -1; if (tb != null && ta == null) return 1; return a.$1 - b.$1; }); return ([for (final e in indexed) e.$2], RouteOrderSource.bookedTime); } if (distanceTo != null) { final metres = {for (final e in indexed) e.$1: distanceTo(e.$2)}; indexed.sort((a, b) { final da = metres[a.$1] ?? double.infinity; final db = metres[b.$1] ?? double.infinity; if (da != db) return da.compareTo(db); return a.$1 - b.$1; }); return ([for (final e in indexed) e.$2], RouteOrderSource.proximity); } return ([for (final e in indexed) e.$2], RouteOrderSource.backendOrder); } /// Records that a set of stops reached a screen with no assigned order. /// /// Not a warning about a bug in this app — it is the hub's optimizer not /// having run. Logged so the gap is visible in a rider's log rather than /// inferred later from a complaint about stop order. static void logUnsequenced(String where, int count) { if (count <= 0) return; debugPrint( '[ROUTE][$where] $count stop(s) with no assigned sequence — ' 'ordering is the app\'s own fallback, not the hub\'s route', ); } }