Files
doormile_milderapp/lib/Models/stop_status.dart
2026-09-09 12:55:23 +05:30

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';
}