753 lines
27 KiB
Dart
753 lines
27 KiB
Dart
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<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 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<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 — `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<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}) {
|
||
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<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, {
|
||
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 {
|
||
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 = <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;
|
||
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 `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<Map<String, dynamic>> sortStops(
|
||
List<Map<String, dynamic>> stops,
|
||
) {
|
||
final indexed = <(int, Map<String, dynamic>)>[
|
||
for (var i = 0; i < stops.length; i++) (i, stops[i]),
|
||
];
|
||
|
||
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<String, dynamic> 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<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 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<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,
|
||
|
||
/// 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<String, dynamic> stop, {
|
||
required Set<String> acceptedIds,
|
||
required Set<String> 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));
|
||
}
|