import 'package:flutter/foundation.dart'; import 'package:miler/data/api_status.dart'; import 'package:miler/data/consignment_state.dart'; import 'package:miler/data/miler_api.dart'; /// ───────────────────────────────────────────────────────────────────────── /// WHAT THE SERVER ACTUALLY CONFIRMED /// /// Every rung this app draws — Accepted, Arrived, Picked, Active, Delivered — /// is a claim about what the **hub** believes. The app had no way to check /// that claim: a mutation returned `ok`, the screen advanced, and whether the /// office could see the same thing was never asked. /// /// ── The bug that made this a file ── /// /// Verified against production on 21 Aug 2026, booking 78: /// /// ``` /// BEFORE GET /miler/bookings status = Miler_Assigned /// CALL POST /miler/bookings/78/reached /// → 200 {"success":true,"data":{"bookingid":78, /// "status":"Miler_Assigned"}} /// AFTER GET /miler/bookings status = Miler_Assigned /// ``` /// /// `reached` **returns success and writes nothing.** It echoes the booking's /// current status. So the rider pressed *I've arrived*, the app got `ok: true`, /// drew ARRIVED, and the hub never heard about it — not because the app failed /// to call, but because a 200 was taken as proof of a transition that never /// happened. /// /// ── The rule ── /// /// A 200 is proof the call was accepted. It is **not** proof of a state /// change. The only proof of a state change is the state coming back in the /// response. So every lifecycle mutation is read through this file, which /// answers three separate questions the caller used to conflate: /// /// • did the call succeed? → [ApiResult.ok] /// • did the state actually move? → [TransitionOutcome.confirmed] /// • what is the state now? → [bookingStatus] / [consignmentState] /// /// Nothing here blocks a rider. A backend that does not record his arrival is /// the backend's fault, and stranding him at a kitchen over it would turn one /// broken endpoint into a stopped operation. What it does is stop the app /// *claiming* the hub agrees when it demonstrably does not — the claim is /// downgraded, logged with the evidence, and surfaced. /// ───────────────────────────────────────────────────────────────────────── enum TransitionOutcome { /// The response carries the state the call was supposed to produce. The rung /// the rider sees is the rung the office sees. confirmed, /// The call succeeded and the state did **not** move — or moved somewhere /// the response does not name. The rider may carry on; the app must not /// pretend the hub knows. See [MilerLifecycle.reached]. unconfirmed, /// The server refused. A real failure with a reason. refused, } /// One lifecycle mutation, read for what it proves. @immutable class StateTransition { const StateTransition({ required this.outcome, required this.bookingStatus, required this.consignmentState, this.consignmentId = '', this.nextAction = '', this.code = '', this.message = '', this.evidence = '', }); final TransitionOutcome outcome; /// The booking status the response reported, parsed. [BookingStatus.unknown] /// when the response named none. final BookingStatus bookingStatus; /// The consignment state the response reported, parsed. final ConsignmentState consignmentState; /// The id minted at the pivot, when this transition was one. final String consignmentId; /// The server's own instruction for what happens next — `start_delivery`, /// `inward_at_hub`. Read, never assumed. final String nextAction; /// The stable failure code, for callers that must branch on *why*. final String code; final String message; /// What the response actually said, for a log line that can be pasted into a /// backend ticket without re-running anything. final String evidence; bool get isConfirmed => outcome == TransitionOutcome.confirmed; bool get isUnconfirmed => outcome == TransitionOutcome.unconfirmed; bool get isRefused => outcome == TransitionOutcome.refused; /// True when the pivot left the consignment in the rider's hands awaiting a /// **Start delivery** press — the post-flag lifecycle. bool get awaitsStartDelivery => consignmentState.needsRelease || nextAction == 'start_delivery'; /// True when the pivot released the consignment itself, which is the /// pre-flag lifecycle the backend calls compatibility mode. /// /// **This is the reason Admin shows Active for a stop the rider just /// picked.** Not a client bug and not something the app may paper over: the /// consignment really is out for delivery, and a rider told otherwise would /// be looking at a different truth from his office. bool get isCompatibilityMode => consignmentState.isDeliverable && nextAction != 'start_delivery'; } /// Reads lifecycle mutations. Pure — no I/O of its own. abstract final class MilerLifecycle { /// First non-empty value among [keys], searched shallow then one level in. static String _str(Map data, List keys) { for (final k in keys) { final v = data[k]; if (v == null || v is Map || v is List) continue; final s = v.toString().trim(); if (s.isNotEmpty && s != 'null' && s != '0') return s; } for (final v in data.values) { if (v is Map) { final nested = v.map((k, x) => MapEntry(k.toString(), x)); final found = _str(nested, keys); if (found.isNotEmpty) return found; } } return ''; } /// `POST /miler/bookings/:id/reached`. /// /// Confirmed **only** when the response reports /// [BookingStatus.arrivedAtPickup]. Anything else — including the 200 that /// echoes the unchanged status, which is what production returns today — is /// [TransitionOutcome.unconfirmed], and the evidence says exactly what came /// back so the report writes itself. static StateTransition reached(ApiResult res) { if (!res.ok) { return StateTransition( outcome: TransitionOutcome.refused, bookingStatus: BookingStatus.unknown, consignmentState: ConsignmentState.unknown, code: res.code, message: res.message, evidence: 'HTTP ${res.status} ${res.code} ${res.message}', ); } final raw = _str(res.data, const [ 'status', 'bookingstatus', 'booking_status', ]); final parsed = BookingStatus.parse(raw); // ── Arrival is a timestamp, not a status ── // // This asked whether the booking's `status` had become // `Arrived_At_Pickup`, and answered *unconfirmed* forever — because the // backend team has since confirmed there is **no Arrived rung in the // booking-status lifecycle at all**. It goes // `pickup_scheduled → converted_to_consignment → …`, and arrival is // recorded beside it as `reachedat`. // // So the app was demanding evidence of a transition the backend never // claimed to make, and logging a gap every time it did not get it. The // proof of arrival is the arrival stamp coming back; the status echoing // `Miler_Assigned` or `pickup_scheduled` is correct and expected. final stamp = _str(res.data, const [ 'reachedat', 'reached_at', 'arrivedat', ]); final confirmed = stamp.isNotEmpty; return StateTransition( outcome: confirmed ? TransitionOutcome.confirmed : TransitionOutcome.unconfirmed, bookingStatus: parsed, consignmentState: ConsignmentState.unknown, evidence: confirmed ? 'the response stamped the arrival at "$stamp"' : raw.isEmpty ? 'the response carried no arrival stamp and named no status' : 'the response carried no arrival stamp; status="$raw"', ); } /// `POST /miler/bookings/:id/pickup-complete`. /// /// The pivot has two legitimate outcomes and the app must not choose between /// them from a build-time assumption: /// /// `Collected_By_Miler` + `next_action: start_delivery` /// the rider holds it; **Start delivery** releases it. /// `Out_for_Delivery` /// compatibility mode — the pivot released it in the same call. /// /// Both are confirmed transitions. Which one happened is [isCompatibilityMode], /// read from the response and never from a flag mirrored into this app. static StateTransition pickupComplete(ApiResult res) { if (!res.ok) { return StateTransition( outcome: TransitionOutcome.refused, bookingStatus: BookingStatus.unknown, consignmentState: ConsignmentState.unknown, code: res.code, message: res.message, evidence: 'HTTP ${res.status} ${res.code} ${res.message}', ); } final bookingRaw = _str(res.data, const [ 'booking_status', 'bookingstatus', 'status', ]); final consignmentRaw = _str(res.data, const [ 'consignmentstatus', 'consignment_status', 'consignmentstate', ]); final id = _str(res.data, const [ 'consignment_id', 'consignmentid', 'consignmentId', 'consignmentno', ]); final next = _str(res.data, const [ 'next_action', 'nextaction', ]).toLowerCase(); final booking = BookingStatus.parse(bookingRaw); var consignment = consignmentStateFromRaw(consignmentRaw); // A response that names only the booking still answers the question when // the booking word is one of the two that carries the delivery half. if (consignment == ConsignmentState.unknown) { if (booking == BookingStatus.outForDelivery) { consignment = ConsignmentState.outForDelivery; } else if (next == 'start_delivery') { consignment = ConsignmentState.collectedByMiler; } } // The pivot is confirmed by the id it minted. Without one there is nothing // to deliver against, whatever the words say. final confirmed = id.isNotEmpty || booking == BookingStatus.convertedToConsignment || consignment != ConsignmentState.unknown; return StateTransition( outcome: confirmed ? TransitionOutcome.confirmed : TransitionOutcome.unconfirmed, bookingStatus: booking, consignmentState: consignment, consignmentId: id, nextAction: next, evidence: 'booking="$bookingRaw" consignment="$consignmentRaw" ' 'id="$id" next="$next"', ); } /// `POST /miler/consignments/:id/inward-at-hub`. /// /// Confirmed when the response says the parcel is now the base's — /// `Inwarded_at_Hub`, or `already_inwarded: true` for a retry whose first /// attempt landed and whose answer was lost. /// /// ── Why `already_inwarded` is a success and not an error ── /// /// A rider hands a parcel over at a loading bay, the reply is dropped, and he /// presses again. The server has already done the work; refusing the second /// press would tell him the hand-over failed for a parcel now sitting on the /// base's counter, and he has no way to prove otherwise from where he is /// standing. So the second answer confirms the first — which is exactly what /// the backend's 200-with-a-flag shape is for, and the app must not turn it /// back into a failure. static StateTransition inwardAtHub(ApiResult res) { if (!res.ok) { return StateTransition( outcome: TransitionOutcome.refused, bookingStatus: BookingStatus.unknown, consignmentState: ConsignmentState.unknown, code: res.code, message: res.message, evidence: 'HTTP ${res.status} ${res.code} ${res.message}', ); } final raw = _str(res.data, const [ 'consignmentstatus', 'consignment_status', 'status', ]); final state = consignmentStateFromRaw(raw); final stamp = _str(res.data, const ['inwardedat', 'inwarded_at']); final next = _str(res.data, const ['next_action', 'nextaction']).toLowerCase(); // `_str` skips `false`-ish values by design, so the flag is read straight. final already = res.data['already_inwarded'] == true || res.data['alreadyInwarded'] == true; final confirmed = state == ConsignmentState.inwardedAtHub || already || next == 'handed_to_hub'; return StateTransition( outcome: confirmed ? TransitionOutcome.confirmed : TransitionOutcome.unconfirmed, bookingStatus: BookingStatus.unknown, consignmentState: state, nextAction: next, evidence: 'consignment="$raw" inwardedat="$stamp" next="$next" ' 'already=$already', ); } /// One line, in the shape a backend ticket wants. static void report(String verb, StateTransition t) { switch (t.outcome) { case TransitionOutcome.confirmed: debugPrint('[LIFECYCLE][$verb] confirmed — ${t.evidence}'); case TransitionOutcome.unconfirmed: debugPrint( '[LIFECYCLE][$verb] NOT CONFIRMED BY THE SERVER — ${t.evidence}. ' 'The call succeeded and the state did not move. This is a backend ' 'deployment mismatch, not a client failure.', ); case TransitionOutcome.refused: debugPrint('[LIFECYCLE][$verb] refused — ${t.evidence}'); } } }