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>
317 lines
13 KiB
Dart
317 lines
13 KiB
Dart
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));
|
|
}
|