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. /// /// 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. extension TripSlots on List { /// 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 get slots { final out = List.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 = {}; 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> 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 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)}'; } /// 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 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 states({ required Set acceptedIds, required Set 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 pendingOrderIds({ required Set acceptedIds, required Set 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 acceptedIds, required Set 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 acceptedIds, required Set rejectedIds, }) => (completion(acceptedIds: acceptedIds, rejectedIds: rejectedIds) * 100) .round(); /// True once every stop has been resolved — the rider can head back. bool isFinished({ required Set acceptedIds, required Set 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 acceptedIds, required Set 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 = _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. List 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 stop, List 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 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> 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 groupIntoTrips( List> 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 = >>{}; final windows = {}; 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 { final due = _parseTimestamp( stop['expected_pickup_time'] ?? stop['expectedpickuptime'] ?? stop['eta'], ); 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 = [ 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 _capToMaxTrips( List 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(); final ends = tail.map((t) => t.slotEnd).whereType(); 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> sortStops( List> stops, ) => orderStops(stops).$1; /// [sortStops], plus which rule produced the order. static (List>, RouteOrderSource) orderStops( List> stops, ) => RouteOrder.sort( stops, bookedTimeOf: (s) => _parseTimestamp( s['expected_pickup_time'] ?? s['expectedpickuptime'] ?? s['eta'], ), ); // ── helpers ──────────────────────────────────────────────────────────── static ({double lat, double lng})? _coordsOf( Map 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 m, List 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 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 commonAddressTail(List 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 = []; 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 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, /// 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 == StopState.active || this == StopState.skipped; /// 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 /// 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 stop, { required Set acceptedIds, required Set rejectedIds, /// Orders the rider is carrying. Optional so every existing parcel call site /// is unchanged — a Doormile route never populates it. Set collectedIds = const {}, }) { 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. The rider is physically on the stop — also not a local decision. if (raw == 'active' || raw == 'arrived') 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)); }