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. bool get awaitsHub => this == ConsignmentState.created || this == ConsignmentState.inwardedAtHub || this == ConsignmentState.tripsheetLoaded || this == ConsignmentState.inTransit; /// 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; } /// 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, /// 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.isClosed) return DeliverGate.closed; return DeliverGate.unknown; } /// Convenience: read and classify in one call. static Future gateOf(Object consignmentId) async => gateFor(await stateOf(consignmentId)); }