Miler rider app: surface system, visible design language, backend lifecycle
Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
276
lib/data/lifecycle.dart
Normal file
276
lib/data/lifecycle.dart
Normal file
@@ -0,0 +1,276 @@
|
||||
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);
|
||||
final confirmed = parsed == BookingStatus.arrivedAtPickup;
|
||||
|
||||
return StateTransition(
|
||||
outcome: confirmed
|
||||
? TransitionOutcome.confirmed
|
||||
: TransitionOutcome.unconfirmed,
|
||||
bookingStatus: parsed,
|
||||
consignmentState: ConsignmentState.unknown,
|
||||
evidence: raw.isEmpty
|
||||
? 'the response named no status at all'
|
||||
: 'the response reported 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"',
|
||||
);
|
||||
}
|
||||
|
||||
/// 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}');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user