Files
doormile_milderapp/lib/data/lifecycle.dart
2026-09-09 12:55:23 +05:30

357 lines
14 KiB
Dart

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<String, dynamic> data, List<String> 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}');
}
}
}