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:
2026-08-22 05:40:35 +05:30
parent c8350563d9
commit d7348e253f
387 changed files with 80693 additions and 12272 deletions

View File

@@ -0,0 +1,316 @@
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<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.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));
}