import 'package:flutter/foundation.dart'; import 'package:miler/data/miler_api.dart'; /// ───────────────────────────────────────────────────────────────────────── /// THE CONSIGNMENT'S OWN VOCABULARY /// /// A booking and a consignment are two different objects with two different /// state machines, and the app has been paying for treating them as one. /// /// booking Pending_Pickup → Miler_Assigned → Pickup_Scheduled /// → Picked_Up → **Converted_To_Consignment** | Cancelled /// /// consignment Created → Inwarded_at_Hub → Tripsheet_Loaded → In_Transit /// → **Out_for_Delivery** → **Delivered** | RTO | … /// /// `Converted_To_Consignment` is where the booking's story ENDS. There is no /// booking `Delivered`, and `GET /miler/bookings` therefore reports the same /// terminal word for a parcel sitting in a hub, a parcel on a rider's bike and /// a parcel handed over an hour ago. Any code that decides "is this stop still /// mine to deliver?" from a booking status is asking the wrong object — which /// is exactly the defect this file exists to close. /// /// ── What this is not ── /// /// Not a local mirror and not a cache to write into. The consignment's state /// belongs to the server; the only honest way to know it is to ask. Nothing /// here ever *sets* a state — see [ConsignmentGate]. /// ───────────────────────────────────────────────────────────────────────── enum ConsignmentState { created, inwardedAtHub, tripsheetLoaded, inTransit, /// **In the rider's hands, not yet on the road.** /// /// Added by the backend on 21 Aug 2026 and it closes the gap this app had /// been modelling locally: `pickup-complete` used to push hyperlocal work /// straight to [outForDelivery], so the hub saw "actively delivering" for /// food still on the kitchen counter and there was no server state meaning /// *collected, holding*. Now the pivot lands here and /// `POST /consignments/:id/start-delivery` makes the release. collectedByMiler, /// Released to a rider. **The only state `deliver` accepts.** outForDelivery, /// Handed over. Terminal. delivered, rtoInitiated, returnedToSender, missing, damaged, cancelled, /// The server said something this build does not know. Deliberately not /// deliverable and deliberately not "finished" — an unrecognised state is a /// reason to ask, never a reason to act. unknown, } /// Normalises the backend's `eventstatus` / `status` spelling. ConsignmentState consignmentStateFromRaw(dynamic raw) { final s = (raw?.toString() ?? '').trim().toLowerCase().replaceAll(' ', '_'); switch (s) { case 'created': return ConsignmentState.created; case 'inwarded_at_hub': return ConsignmentState.inwardedAtHub; case 'tripsheet_loaded': return ConsignmentState.tripsheetLoaded; case 'in_transit': return ConsignmentState.inTransit; case 'collected_by_miler': case 'collectedbymiler': return ConsignmentState.collectedByMiler; case 'out_for_delivery': case 'outfordelivery': return ConsignmentState.outForDelivery; case 'delivered': return ConsignmentState.delivered; case 'rto_initiated': return ConsignmentState.rtoInitiated; case 'returned_to_sender': return ConsignmentState.returnedToSender; case 'missing': return ConsignmentState.missing; case 'damaged': return ConsignmentState.damaged; case 'cancelled': case 'canceled': return ConsignmentState.cancelled; default: return ConsignmentState.unknown; } } extension ConsignmentStateX on ConsignmentState { /// `POST /miler/consignments/:id/deliver` is refused unless the consignment /// is `Out_for_Delivery`. One state, no inference, no other spelling. bool get isDeliverable => this == ConsignmentState.outForDelivery; /// Already handed over. The work is done server-side; the rider's device is /// the thing that is behind. bool get isDelivered => this == ConsignmentState.delivered; /// Closed for good, one way or another — nothing left for a rider to do. bool get isClosed => this == ConsignmentState.delivered || this == ConsignmentState.cancelled || this == ConsignmentState.returnedToSender; /// Collected and waiting on the rider's own **Start round**, not on anyone /// else. Deliberately *not* [awaitsHub]: telling a rider the hub has his /// parcel while it is in his own box is the error this state exists to /// prevent. bool get needsRelease => this == ConsignmentState.collectedByMiler; /// Still inside the hub's half of the network. **This is the case the /// "not released yet" guard is for** — a logistics consignment sitting at a /// hub genuinely cannot be delivered by this rider, and must stay blocked. /// /// ── `Created` is not one of these, and treating it as one stranded riders ── /// /// It was listed here, and it is the wrong half of the network. The backend's /// own contract for the pivot is: /// /// ``` /// hyperlocal pickup-complete → Out_for_Delivery (compatibility mode) /// → Collected_By_Miler (flag on) /// hub-routed pickup-complete → Created + next_action: inward_at_hub /// ``` /// /// So `Created` means *the consignment exists and nothing has happened to it /// yet* — the parcel is *in the rider's own hands*, waiting either on his /// release or on him carrying it to the hub. The hub has never seen it. The /// rider slid **Start ride**, and the app answered "This parcel is with the /// hub — it will be delivered from there, not by you" about a bag on his own /// back, with no way forward from that screen. /// /// Genuine hub custody starts at [ConsignmentState.inwardedAtHub] — the state /// whose name says the hub took it in. See [awaitsHubInward] for `Created`. bool get awaitsHub => this == ConsignmentState.inwardedAtHub || this == ConsignmentState.tripsheetLoaded || this == ConsignmentState.inTransit; /// Converted, and not yet moved anywhere by anyone. /// /// The parcel is with **this rider**. On a hyperlocal run this is a state the /// release moves out of; on a hub-routed one his next act is to inward it at /// the hub, which is not something this app does yet. Either way it is not a /// reason to tell him the parcel is somebody else's — see [awaitsHub]. bool get awaitsHubInward => this == ConsignmentState.created; /// After a successful `skip`, whether the stop is **still the rider's /// problem**. /// /// A skip is a failed attempt, not a closed consignment, and what the server /// does with one is the server's business: it may move the consignment to a /// failure state, or leave it `Out_for_Delivery` for a second attempt or an /// RTO decision taken elsewhere. The app cannot tell from the skip's own /// 200, so it reads the consignment afterwards and asks this. /// /// **Unknown counts as open.** A read that failed is not permission to /// declare a stop finished — writing a terminal local record over a /// consignment the hub still calls open leaves two systems disagreeing about /// whether a parcel is anyone's problem, with the rider's screen the only /// one saying it is not. bool get isOpenAfterSkip => isDeliverable || needsRelease || this == ConsignmentState.unknown; /// `skip` is accepted from both halves of the rider's custody — the backend /// widened it on 21 Aug 2026 so a failed attempt is reportable the moment /// the parcel is collected, not only once the round has started. bool get canSkip => needsRelease || isDeliverable; /// Why **Start ride** could not set off, in words a rider can act on — or /// null when this state is not a reason to refuse one. /// /// ── The screen was reading the state machine out loud ── /// /// When `start-delivery` refuses, the backend answers with an assertion /// about its own model: *"consignment is Inwarded_At_Hub, not /// Collected_By_Miler"*. That sentence was passed through to the rider /// verbatim, on the reasoning that the server's own words beat a guess — and /// that reasoning is right about *whose* answer it is and wrong about *what /// kind of sentence* it is. `Inwarded_At_Hub` is a database enum. It is not /// something a man holding a phone at a kitchen counter can do anything /// with, and he had no way to tell it from a bug. /// /// So the state is translated here, in the file that owns the vocabulary, /// exactly once. Every sentence answers the only two questions he has: is /// this mine, and is sliding again going to help. /// /// Null for [outForDelivery], [collectedByMiler] and [unknown] — the first /// two are not refusals at all and the third is not an answer, so the caller /// falls back rather than inventing a cause. String? get startRefusalSentence => switch (this) { // Converted and not yet routed. The one refusal here that a second slide // in a minute genuinely can clear. ConsignmentState.created => 'Your office is still setting this one up. Give it a moment and slide ' 'again.', // The logistics network has it. Said without naming a building the rider // has never been to — what matters is that it is not his round. ConsignmentState.inwardedAtHub || ConsignmentState.tripsheetLoaded || ConsignmentState.inTransit => "This one has already been handed on — it's not yours to deliver.", ConsignmentState.delivered => 'This one has already been delivered. Nothing left to start.', ConsignmentState.rtoInitiated || ConsignmentState.returnedToSender => 'This one is going back to the sender, so there is no delivery to ' 'start.', ConsignmentState.missing || ConsignmentState.damaged => 'Your office has flagged a problem with this parcel. They have to clear ' 'it before it can go out.', ConsignmentState.cancelled => 'This one has been cancelled. There is nothing to deliver.', ConsignmentState.outForDelivery || ConsignmentState.collectedByMiler || ConsignmentState.unknown => null, }; } /// The consignment state a **start-delivery** refusal names, or /// [ConsignmentState.unknown] when it names none this build knows. /// /// ── Reading a state out of a sentence, safely ── /// /// Parsing prose is normally a bad idea, and this is the case where it is not, /// because it does not depend on the prose. `POST /// /miler/consignments/:id/start-delivery` accepts **exactly one** state — /// `Collected_By_Miler`. So of the state names a refusal from that endpoint /// mentions, the only one that cannot be describing where the consignment /// actually *is* is the one that would have been accepted. Drop that, and /// whatever is left is the answer — whichever order the words come in, and /// however the sentence is later reworded. /// /// This exists because the state read can fail twice: the row need not carry /// `consignmentstatus`, and `GET /miler/consignments/:id` can 404 on an older /// deployment. In that case the refusal itself is the only thing that knows /// what happened, and throwing it away costs the rider a real answer. /// /// Matches only the wire's own shape — capitalised words joined by /// underscores — so an ordinary sentence yields nothing. ConsignmentState consignmentStateNamedInRefusal(String message) { if (message.isEmpty) return ConsignmentState.unknown; for (final m in RegExp( r'[A-Za-z]+(?:_[A-Za-z]+)+', ).allMatches(message)) { final state = consignmentStateFromRaw(m.group(0)); if (state == ConsignmentState.unknown) continue; // The state the endpoint requires, not the state it found. if (state == ConsignmentState.collectedByMiler) continue; return state; } return ConsignmentState.unknown; } /// True when [message] is the backend talking to itself. /// /// A refusal carrying a wire enum — `Inwarded_At_Hub`, `Out_for_Delivery` — is /// a state-machine assertion, not a sentence for a rider, and it must never /// reach a screen. Anything else the server says is prose written for a person /// and is worth more than a generic apology, so it is still passed through. bool namesWireState(String message) => RegExp(r'[A-Za-z]+(?:_[A-Za-z]+)+').hasMatch(message); /// What the app is allowed to do with a consignment, decided from the /// authoritative server state rather than from a booking row or a local flag. enum DeliverGate { /// `Out_for_Delivery` — post the delivery. deliverable, /// `Delivered` — the server already has it. Reconcile locally; do not post /// again and do not show the rider an error for work he completed. alreadyDelivered, /// Collected but the round has not been started. The rider unblocks this /// himself — **Start round** on the Deliveries tab. needsRelease, /// A real hub-side hold. Block, and say so. awaitingHub, /// `Created` — converted, never released, never inwarded. The parcel is in /// this rider's hands, so the hub-hold wording is a lie; but it is not /// `Out_for_Delivery` either, so `deliver` will refuse it. Blocked, with the /// one sentence that is actually true about it. See /// [ConsignmentStateX.awaitsHubInward]. awaitingInward, /// Closed some other way (cancelled, returned). Not deliverable, not an /// error the rider caused. closed, /// The state could not be read — no id, no network, an unparseable answer. /// **Not a block.** A read failure is not evidence of anything, so the /// delivery is attempted and the server remains the judge. Blocking here /// would strand a rider at a door because a GET timed out. unknown, } /// Reads a consignment's authoritative state and answers what may be done. /// /// ── Why this needs a network call at all ── /// /// Nothing the rider's device already holds can answer it. `GET /miler/bookings` /// carries the *booking* status (terminal at `Converted_To_Consignment`) and no /// consignment status at all — verified against the live API. The local /// collected/out-for-delivery sets record what the *rider* did on *this* /// handset, which is exactly what a reinstall, a second device or a /// hub-side change makes wrong. /// /// `GET /miler/consignments/:consignmentid` reports the current state directly /// — shipped 21 Aug 2026 at this app's request. Before it, the only route that /// carried consignment state was `…/logs/:id`, and reading a state machine /// meant pulling its entire history and sorting it. That still works and /// remains the fallback here, because a rider mid-round on a build that meets /// an older deployment must not be blocked by a 404. class ConsignmentGate { ConsignmentGate._(); /// Reads the current state of [consignmentId]. /// /// Returns [ConsignmentState.unknown] on any failure — see [DeliverGate]. static Future stateOf(Object consignmentId) async { final id = consignmentId.toString().trim(); if (id.isEmpty || id == '0') return ConsignmentState.unknown; try { final res = await MilerApi.consignment(id); if (res.ok) { final state = _stateFromDetail(res.data); if (state != ConsignmentState.unknown) { debugPrint('[CONSIGNMENT] $id is ${state.name}'); return state; } } else { debugPrint('[CONSIGNMENT] get $id -> ${res.status} ${res.message}'); } // Either the route is not deployed yet, or it answered something this // build cannot read. The history still holds the answer. return _stateFromLogs(id); } catch (e) { debugPrint('[CONSIGNMENT] could not read $id: $e'); return ConsignmentState.unknown; } } /// Reads `GET /miler/consignments/:id`. /// /// The response carries both a `status` string and the derived booleans /// (`collected`, `out_for_delivery`, `delivered`, `can_deliver`…). The /// string is preferred: it is the state itself, whereas the flags are the /// server's opinion *about* the state and can be extended independently. /// The flags are only consulted when the string is a word this build has /// never heard of — and there, `delivered` first, because mistaking a /// completed delivery for an open one is the failure that makes a rider /// re-post work he has already done. static ConsignmentState _stateFromDetail(Map data) { if (data.isEmpty) return ConsignmentState.unknown; final raw = data['consignmentstatus'] ?? data['status'] ?? data['state']; final named = consignmentStateFromRaw(raw); if (named != ConsignmentState.unknown) return named; bool flag(String key) => data[key] == true || '${data[key]}' == 'true'; // State flags first — they describe where the consignment *is*. if (flag('delivered')) return ConsignmentState.delivered; if (flag('out_for_delivery')) return ConsignmentState.outForDelivery; if (flag('collected')) return ConsignmentState.collectedByMiler; // Then the permission flags, which describe what may be *done*. A weaker // signal — `can_deliver` is the server having already decided the answer // this app derives from the state — but a far better one than giving up: // `unknown` blocks the Start delivery bar and makes the door guess. if (flag('can_deliver')) return ConsignmentState.outForDelivery; if (flag('can_start_delivery')) return ConsignmentState.collectedByMiler; return ConsignmentState.unknown; } /// The pre-21-Aug-2026 read: the whole history, newest row wins. static Future _stateFromLogs(String id) async { final res = await MilerApi.consignmentLogs(id); if (!res.ok) { debugPrint('[CONSIGNMENT] logs $id -> ${res.status} ${res.message}'); return ConsignmentState.unknown; } // Rows arrive oldest-first; the state is whatever happened last. Sorted // on `historyid` rather than trusting arrival order, because a state // machine read out of order is worse than not read. final rows = >[ for (final r in res.list) if (r is Map) r.map((k, v) => MapEntry(k.toString(), v)), ]; if (rows.isEmpty) return ConsignmentState.unknown; rows.sort((a, b) { final ai = int.tryParse('${a['historyid'] ?? 0}') ?? 0; final bi = int.tryParse('${b['historyid'] ?? 0}') ?? 0; if (ai != bi) return ai.compareTo(bi); return (a['createdat'] ?? '').toString().compareTo( (b['createdat'] ?? '').toString(), ); }); final state = consignmentStateFromRaw( rows.last['eventstatus'] ?? rows.last['status'], ); debugPrint('[CONSIGNMENT] $id is ${state.name} (from logs)'); return state; } /// [_stateFromDetail], reachable from a test. /// /// The flag-reading path is the one that runs when the backend adds a state /// this build has never heard of — the case that cannot be produced by /// naming a status, and is exactly the case worth pinning. @visibleForTesting static ConsignmentState stateFromDetailForTest(Map data) => _stateFromDetail(data); /// Maps a state to what the delivery flow may do about it. static DeliverGate gateFor(ConsignmentState state) { if (state.isDeliverable) return DeliverGate.deliverable; if (state.isDelivered) return DeliverGate.alreadyDelivered; if (state.needsRelease) return DeliverGate.needsRelease; if (state.awaitsHub) return DeliverGate.awaitingHub; if (state.awaitsHubInward) return DeliverGate.awaitingInward; if (state.isClosed) return DeliverGate.closed; return DeliverGate.unknown; } /// Convenience: read and classify in one call. static Future gateOf(Object consignmentId) async => gateFor(await stateOf(consignmentId)); }