328 lines
14 KiB
Dart
328 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|
|
|
import 'package:miler/data/service_profile.dart';
|
|
import 'package:miler/views/helpers/constants/Colorconstants.dart';
|
|
|
|
/// Canonical lifecycle status for a booking/stop, parsed from the backend's
|
|
/// free-form `orderstatus` string.
|
|
///
|
|
/// Status used to be compared as raw string literals in ~90 places, with
|
|
/// case-sensitivity landmines (`'active'` vs `'ACTIVE'`) and variant spellings
|
|
/// (`'picked'` / `'picked up'` / `'pickuped'`). This normalizes all of that in
|
|
/// one place so filters agree everywhere.
|
|
///
|
|
/// NOTE: this is the *read* side. The status-change API still sends its own
|
|
/// protocol verbs (`ACCEPTED`/`ARRIVED`/`PICKED`/`REJECTED`/`CANCELLED`) — see
|
|
/// [OrderAction] for those canonical write values.
|
|
enum StopStatus {
|
|
newStop,
|
|
assigned,
|
|
accepted,
|
|
active,
|
|
arrived,
|
|
picked,
|
|
|
|
/// ── The milk run's second half ──
|
|
///
|
|
/// [picked] is where a logistics stop ends: the parcel is collected and it
|
|
/// becomes the hub's problem. A milk run does not end there — collection is
|
|
/// the middle of the day, and the rider still has to carry the load to the
|
|
/// customers.
|
|
///
|
|
/// So these three rungs exist for the line that has them, and only that line
|
|
/// puts a stop into them. Keeping them in the same enum is what lets one
|
|
/// `stopStatusOf` test serve both halves of the day; keeping them *distinct
|
|
/// from* [picked] and [arrived] is what stops a collected crate being
|
|
/// reported as fifteen delivered lunches ninety minutes early.
|
|
///
|
|
/// The load is released and the rider is driving his round.
|
|
outForDelivery,
|
|
|
|
/// At a customer's door — not at a source. The distinction matters: the
|
|
/// pickup [arrived] and this one are different places, different actions and
|
|
/// different next steps.
|
|
deliveryArrived,
|
|
|
|
/// Handed over. Terminal for a milk-run stop, the way [picked] is terminal
|
|
/// for a logistics one.
|
|
delivered,
|
|
|
|
skipped,
|
|
cancelled,
|
|
rejected,
|
|
unknown,
|
|
}
|
|
|
|
/// Normalizes any raw `orderstatus` value (case-insensitive, trimmed, variant
|
|
/// spellings folded) into a [StopStatus].
|
|
StopStatus stopStatusFromRaw(dynamic raw) {
|
|
final s = (raw?.toString() ?? '').trim().toLowerCase();
|
|
switch (s) {
|
|
case 'new':
|
|
return StopStatus.newStop;
|
|
case 'assigned':
|
|
case 'miler_assigned':
|
|
case 'pending':
|
|
return StopStatus.assigned;
|
|
// ── The backend's own word for accepted ──
|
|
//
|
|
// Confirmed by the backend team: `pickup_scheduled` is what a booking sits
|
|
// on once the rider has accepted it. It parsed as [StopStatus.unknown] —
|
|
// so the rung the pickup UI reads had nothing in it, and any *other* field
|
|
// that did parse won by default.
|
|
case 'pickup_scheduled':
|
|
case 'pickupscheduled':
|
|
case 'accepted':
|
|
return StopStatus.accepted;
|
|
case 'active':
|
|
return StopStatus.active;
|
|
case 'arrived':
|
|
// ── The server's own spellings for the same rung ──
|
|
//
|
|
// `Arrived_At_Pickup` is what `POST /miler/bookings/:id/reached` now
|
|
// persists, and `At_Customer` is the undocumented variant the backend was
|
|
// observed sending before it. Neither parsed: both fell to
|
|
// [StopStatus.unknown], so the rung the rider had just reported would have
|
|
// been dropped the moment the backend started returning it — the same
|
|
// symptom the local compatibility store exists to paper over, arriving by
|
|
// a different route.
|
|
//
|
|
// `ApiConfig.legacyStatusFromNew` already folds this into `arrived` for
|
|
// rows that come through the booking adapter. This is the other door: a raw
|
|
// status read straight off a row, which is what `_fetchQueues` does.
|
|
//
|
|
// ── `At_Customer` — settled, and it means arrived at the PICKUP ──
|
|
//
|
|
// This word was read two ways: `ApiConfig` folded it to *arrived at
|
|
// pickup*, and the delivery case further down this switch folded it to
|
|
// [StopStatus.deliveryArrived]. Both could not be right, and a wrong guess
|
|
// moves a stop between the two halves of the rider's day — so it was left
|
|
// unparsed rather than settled on a hunch.
|
|
//
|
|
// The backend team answered it: `At_Customer` is a
|
|
// `milerprofiles.availabilitystatus` value, not a booking or consignment
|
|
// state, so it says where the **rider** is and not where the parcel is. The
|
|
// only thing that writes it is `POST /miler/bookings/:id/reached` — the
|
|
// pickup-arrival action. Nothing sets it on a delivery leg; a rider heading
|
|
// to a receiver goes `On_Delivery`. The name is misleading and predates the
|
|
// current lifecycle.
|
|
//
|
|
// So it belongs here, with the other spellings of the pickup arrival.
|
|
// `ApiConfig` was right and this file was wrong.
|
|
case 'arrived_at_pickup':
|
|
case 'arrivedatpickup':
|
|
case 'at customer':
|
|
case 'at_customer':
|
|
return StopStatus.arrived;
|
|
// ── The two words that mean the pickup is done ──
|
|
//
|
|
// Also confirmed by the backend team, and also unparsed until now:
|
|
// `converted_to_consignment` is what the booking becomes when
|
|
// `pickup-complete` converts it, and `picked_up` is the same fact said
|
|
// plainly. Both are the **pickup milestone**, and both must outrank
|
|
// whatever the *delivery* lifecycle has moved on to — see
|
|
// [MilkRun.stageOf], which is where that precedence lives.
|
|
//
|
|
// This is the other half of "Picked showed as Active": with these
|
|
// unparsed, `active` was the only word on the row the app could read.
|
|
case 'converted_to_consignment':
|
|
case 'convertedtoconsignment':
|
|
case 'picked_up':
|
|
case 'picked':
|
|
case 'picked up':
|
|
case 'pickuped':
|
|
case 'pickedup':
|
|
return StopStatus.picked;
|
|
// The milk run's delivery rungs. `out_for_delivery` is also the backend's
|
|
// own consignment status, spelled its way, so a stop stamped from either
|
|
// side folds to the same state.
|
|
case 'outfordelivery':
|
|
case 'out_for_delivery':
|
|
case 'out for delivery':
|
|
case 'delivering':
|
|
return StopStatus.outForDelivery;
|
|
case 'deliveryarrived':
|
|
case 'delivery_arrived':
|
|
return StopStatus.deliveryArrived;
|
|
case 'delivered':
|
|
return StopStatus.delivered;
|
|
case 'skipped':
|
|
return StopStatus.skipped;
|
|
case 'cancelled':
|
|
case 'canceled':
|
|
return StopStatus.cancelled;
|
|
case 'rejected':
|
|
return StopStatus.rejected;
|
|
default:
|
|
return StopStatus.unknown;
|
|
}
|
|
}
|
|
|
|
/// Parse straight from a booking map's `orderstatus`.
|
|
StopStatus stopStatusOf(Map<String, dynamic> booking) =>
|
|
stopStatusFromRaw(booking['orderstatus']);
|
|
|
|
extension StopStatusX on StopStatus {
|
|
bool get isPicked => this == StopStatus.picked;
|
|
bool get isCancelled => this == StopStatus.cancelled;
|
|
bool get isSkipped => this == StopStatus.skipped;
|
|
bool get isActive => this == StopStatus.active;
|
|
bool get isRejected => this == StopStatus.rejected;
|
|
|
|
/// On the customer round: released, at a door, or handed over.
|
|
bool get isDeliveryLeg =>
|
|
this == StopStatus.outForDelivery ||
|
|
this == StopStatus.deliveryArrived ||
|
|
this == StopStatus.delivered;
|
|
|
|
/// Handed to the customer. Terminal on a milk run.
|
|
bool get isDelivered => this == StopStatus.delivered;
|
|
|
|
/// ── "Is this stop finished?" is a question about the LINE ──
|
|
///
|
|
/// This is the distinction that broke the milk run, so it is worth being
|
|
/// exact about.
|
|
///
|
|
/// On **logistics**, collecting the parcel *is* the job: [picked] is the end,
|
|
/// the booking becomes a consignment, and the hub takes it from there.
|
|
///
|
|
/// On a **milk run**, [picked] is the *middle of the morning*. The rider is
|
|
/// holding fifteen lunches and has not delivered one of them. His day ends at
|
|
/// [delivered], one customer at a time.
|
|
///
|
|
/// One boolean answered both, and it answered "picked = finished". So a
|
|
/// milk-run order collected at a kitchen was dropped from the deliveries list
|
|
/// as completed work and filed on Activity as history — before the rider had
|
|
/// left the counter. He collected five lunches and watched them disappear
|
|
/// into his own history.
|
|
///
|
|
/// Read this, not [isTerminal], anywhere the question is "should this stop
|
|
/// still be worked today?".
|
|
bool get isWorkComplete => ServiceProfile.active.deliversToCustomer
|
|
? (this == StopStatus.delivered || this == StopStatus.cancelled)
|
|
: (this == StopStatus.picked ||
|
|
this == StopStatus.delivered ||
|
|
this == StopStatus.cancelled);
|
|
|
|
/// Every state that ends a stop on *some* line, without asking which.
|
|
///
|
|
/// Only for code that must not depend on the active profile — a pure store
|
|
/// helper, say. Screens want [isWorkComplete].
|
|
bool get isTerminal =>
|
|
this == StopStatus.picked ||
|
|
this == StopStatus.delivered ||
|
|
this == StopStatus.cancelled;
|
|
|
|
/// The old name for [isWorkComplete], kept because ~8 call sites read it and
|
|
/// they all want the line-aware answer.
|
|
bool get isFinishedPickup => isWorkComplete;
|
|
|
|
/// Still awaiting the rider's acceptance — belongs on Home, not Bookings.
|
|
bool get isPending =>
|
|
this == StopStatus.newStop || this == StopStatus.assigned;
|
|
|
|
/// What this state is *called*, in the rider's words.
|
|
///
|
|
/// The status vocabulary had been re-invented per widget: the live banner had
|
|
/// its own switch over raw `orderstatus` strings returning "At pickup" and
|
|
/// "In progress", the trip card wrote 'Accepted' as a literal, and the two
|
|
/// disagreed about the same backend state. A rider moving between Home, the
|
|
/// banner and Bookings was reading three vocabularies for one lifecycle and
|
|
/// re-learning the app at each stop.
|
|
///
|
|
/// One list, here, next to the parser that produces the states — so a new
|
|
/// status cannot be added without someone deciding what to call it.
|
|
String get label => switch (this) {
|
|
StopStatus.newStop => 'New',
|
|
StopStatus.assigned => 'Assigned',
|
|
StopStatus.accepted => 'Accepted',
|
|
StopStatus.active => 'In progress',
|
|
StopStatus.arrived => 'At the stop',
|
|
StopStatus.picked => 'Picked up',
|
|
StopStatus.outForDelivery => 'Out for delivery',
|
|
StopStatus.deliveryArrived => 'At the customer',
|
|
StopStatus.delivered => 'Delivered',
|
|
StopStatus.skipped => 'Skipped',
|
|
StopStatus.cancelled => 'Cancelled',
|
|
StopStatus.rejected => 'Rejected',
|
|
StopStatus.unknown => 'Active',
|
|
};
|
|
|
|
/// The colour that name is drawn in.
|
|
///
|
|
/// Next to [label] for the same reason the labels are here: a status cannot
|
|
/// be added without somebody deciding both what it is called and how loud it
|
|
/// is. Semantic, not decorative — green means the rider is done with it, red
|
|
/// means it needs him now, grey means it is waiting on somebody else.
|
|
Color get color => switch (this) {
|
|
StopStatus.newStop || StopStatus.assigned => ColorConstants.secondaryText,
|
|
StopStatus.accepted => ColorConstants.acceptGreen,
|
|
StopStatus.active || StopStatus.arrived => ColorConstants.pickupAccent,
|
|
StopStatus.picked => ColorConstants.acceptGreen,
|
|
// ── Loud, but in the leg's own colour ──
|
|
//
|
|
// These wore the brand red, and on the stop sheet that word sat between a
|
|
// blue leg disc and a blue "what to do here" chip, one line above a red
|
|
// Navigate button — a status dressed as an action, on a screen that codes
|
|
// its delivery leg blue everywhere else. The round is still drawn loudly;
|
|
// it is drawn in the ink that already means *delivery leg*, which is the
|
|
// same rule that keeps the pickup-leg states on the pickup accent above.
|
|
StopStatus.outForDelivery ||
|
|
StopStatus.deliveryArrived => ColorConstants.deliveryAccent,
|
|
StopStatus.delivered => ColorConstants.acceptGreen,
|
|
StopStatus.skipped => ColorConstants.warning,
|
|
StopStatus.cancelled || StopStatus.rejected => ColorConstants.errorRed,
|
|
StopStatus.unknown => ColorConstants.secondaryText,
|
|
};
|
|
|
|
/// A glyph for the same state, so the tag never depends on colour alone —
|
|
/// these are read in sunlight, through a scratched screen, at a gate.
|
|
IconData get icon => switch (this) {
|
|
StopStatus.newStop || StopStatus.assigned => LucideIcons.clock,
|
|
StopStatus.accepted => LucideIcons.circleCheck,
|
|
StopStatus.active => LucideIcons.bike,
|
|
StopStatus.arrived => LucideIcons.mapPin,
|
|
StopStatus.picked => LucideIcons.package,
|
|
StopStatus.outForDelivery => LucideIcons.truck,
|
|
StopStatus.deliveryArrived => LucideIcons.mapPin,
|
|
// ── Delivered is settled, not decorated ──
|
|
//
|
|
// `verified_rounded` is a starburst badge — the shape Material reserves
|
|
// for *verified account*, and the loudest glyph in the set. Down a column
|
|
// of finished stops it made every completed delivery look like an award.
|
|
// A package with a tick on it says the same thing about the same object,
|
|
// quietly, and it is the mark every delivery app in the world uses.
|
|
StopStatus.delivered => LucideIcons.packageCheck,
|
|
// Skipped must not read as a variant of delivered. A circle with a stroke
|
|
// through it is the "attempted, did not happen" mark; `replay` promised a
|
|
// retry the rider may not actually be able to make.
|
|
StopStatus.skipped => LucideIcons.circleSlash,
|
|
StopStatus.cancelled || StopStatus.rejected => LucideIcons.ban,
|
|
StopStatus.unknown => LucideIcons.circle,
|
|
};
|
|
}
|
|
|
|
/// Canonical protocol verbs sent to the status-change API (the *write* side).
|
|
class OrderAction {
|
|
static const String accept = 'ACCEPT';
|
|
static const String accepted = 'ACCEPTED';
|
|
static const String arrived = 'ARRIVED';
|
|
static const String picked = 'PICKED';
|
|
static const String rejected = 'REJECTED';
|
|
static const String cancelled = 'CANCELLED';
|
|
|
|
/// The milk run's second half. These are *client* verbs — they name what the
|
|
/// rider did, and each one maps to a real backend call rather than to a
|
|
/// status string the API would not recognise:
|
|
///
|
|
/// startDelivery → POST /miler/deliveries/start
|
|
/// deliveryArrived → (local; the round has no per-stop arrival route)
|
|
/// delivered → POST /miler/consignments/:id/deliver
|
|
///
|
|
/// See the mapping table in `MilkRun`.
|
|
static const String startDelivery = 'START_DELIVERY';
|
|
static const String deliveryArrived = 'DELIVERY_ARRIVED';
|
|
static const String delivered = 'DELIVERED';
|
|
}
|