import 'dart:math' as math; import 'package:miler/views/Dashboard/pickups/stop_type.dart'; import 'package:miler/views/Dashboard/pickups/route_metrics.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`. /// ───────────────────────────────────────────────────────────────────────── 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"; 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 — `Trip 1`. static String tabLabel(int index) => 'Trip ${index + 1}'; /// 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}) { 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]); 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, { 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) { 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()}'; } 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; 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 `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. static List> sortStops( List> stops, ) { final indexed = <(int, Map)>[ for (var i = 0; i < stops.length; i++) (i, stops[i]), ]; 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]; } // ── helpers ──────────────────────────────────────────────────────────── static ({double lat, double lng})? _coordsOf(Map stop) { 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 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(); 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, /// 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.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', }; } /// 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, }) { 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; // 3. 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. 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, } /// 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)); }