445 lines
20 KiB
Dart
445 lines
20 KiB
Dart
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<ConsignmentState> 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<String, dynamic> 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<ConsignmentState> _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 = <Map<String, dynamic>>[
|
|
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<String, dynamic> 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<DeliverGate> gateOf(Object consignmentId) async =>
|
|
gateFor(await stateOf(consignmentId));
|
|
}
|