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