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:
@@ -1,4 +1,10 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:miler/data/api_status.dart';
|
||||
import 'package:miler/data/consignment_state.dart';
|
||||
import 'package:miler/data/route_order.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Base URL, bearer token, and the adapter that turns a v1 booking into the
|
||||
@@ -59,6 +65,62 @@ class ApiConfig {
|
||||
return headers;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WHAT THE TOKEN ALREADY SAYS ABOUT THE RIDER
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The `tenantid` claim carried in the bearer token, or 0 when there is none.
|
||||
///
|
||||
/// ── Why the app reads its own token ──
|
||||
///
|
||||
/// The rider's tenant decides his entire operational mode, and the server has
|
||||
/// always known it — `GenerateToken` signs `tenantid` into every miler JWT.
|
||||
/// What it did *not* do was put it in the `verify-pin` response body, so the
|
||||
/// app was told the answer and could not hear it: the login carried tenant 13
|
||||
/// in its token and a body with no tenant at all, and every rider resolved to
|
||||
/// the fallback line.
|
||||
///
|
||||
/// The handler now returns it too, but a deployed backend is not the same
|
||||
/// thing as a merged one, and the app should not need a release to be
|
||||
/// redeployed alongside. The claim is the same fact from the same source —
|
||||
/// signed by the server, not asserted by the client — so reading it closes
|
||||
/// the gap without waiting on anything.
|
||||
///
|
||||
/// ── What this is not ──
|
||||
///
|
||||
/// It is **not** a security decision and must never become one. The signature
|
||||
/// is not verified here — the app has no key and does not need one, because
|
||||
/// every request is still authorised server-side by the same token. A rider
|
||||
/// who edited this claim would change which screens his own phone draws and
|
||||
/// nothing else; the API would keep answering for the tenant it verified.
|
||||
///
|
||||
/// Returns 0 for a missing, malformed or unparseable token rather than
|
||||
/// throwing: an unreadable token must fall through to the other signals, not
|
||||
/// take the app down at launch.
|
||||
static int tenantIdFromToken(String? token) {
|
||||
if (token == null || token.isEmpty) return 0;
|
||||
try {
|
||||
final parts = token.split('.');
|
||||
if (parts.length != 3) return 0;
|
||||
// JWT uses base64url without padding; `base64Url.decode` demands it.
|
||||
String payload = parts[1];
|
||||
payload += '=' * ((4 - payload.length % 4) % 4);
|
||||
final decoded = json.decode(utf8.decode(base64Url.decode(payload)));
|
||||
if (decoded is! Map) return 0;
|
||||
final raw = decoded['tenantid'];
|
||||
if (raw is int) return raw;
|
||||
if (raw is num) return raw.toInt();
|
||||
return int.tryParse(raw?.toString() ?? '') ?? 0;
|
||||
} catch (e) {
|
||||
debugPrint('[AUTH] could not read tenant from token: $e');
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored session's tenant claim. See [tenantIdFromToken].
|
||||
static Future<int> storedTenantId() async =>
|
||||
tenantIdFromToken(await getToken());
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Response envelope adapter: {success,data,message} -> {status,details}
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -94,26 +156,95 @@ class ApiConfig {
|
||||
// / picked / skipped / cancelled / rejected
|
||||
|
||||
static String legacyStatusFromNew(String? newStatus) {
|
||||
switch ((newStatus ?? '').trim()) {
|
||||
case 'Miler_Assigned':
|
||||
// Admin-assigned but NOT yet accepted by the rider. This must land on
|
||||
// the Home tab as a pending booking to accept/reject — it becomes
|
||||
// 'accepted' (a client-side marker in accepted_store) only once the
|
||||
// rider accepts it, which is what moves it to the Bookings tab.
|
||||
return 'assigned';
|
||||
case 'Pickup_Scheduled':
|
||||
return 'active';
|
||||
case 'At_Customer':
|
||||
return 'arrived';
|
||||
case 'Picked_Up':
|
||||
return 'Picked up';
|
||||
case 'Converted_To_Consignment':
|
||||
return 'picked';
|
||||
case 'Cancelled':
|
||||
return 'cancelled';
|
||||
default:
|
||||
return newStatus ?? '';
|
||||
}
|
||||
// ── Parsed, not string-matched ──
|
||||
//
|
||||
// This was a `switch` on raw strings whose `default` returned the value
|
||||
// unchanged, so two things went wrong quietly. `Pending_Pickup` and
|
||||
// `Created` were not listed at all and fell through as themselves, and a
|
||||
// status added by the backend tomorrow would do the same — arriving in the
|
||||
// UI as an unrecognised string that the row logic then had to guess at.
|
||||
//
|
||||
// [BookingStatus] covers the contract exhaustively and folds everything
|
||||
// else into `unknown`, which maps to the empty string here: a stop the app
|
||||
// cannot classify renders as undecided, never as picked, cancelled or
|
||||
// otherwise finished. Wrong-but-safe beats wrong-and-settled.
|
||||
return switch (BookingStatus.parse(newStatus)) {
|
||||
// Admin-assigned but NOT yet accepted by the rider. These land on Home as
|
||||
// pending bookings to accept or reject; the client-side accepted marker
|
||||
// is what moves one to the work tab.
|
||||
BookingStatus.pendingPickup ||
|
||||
BookingStatus.created ||
|
||||
BookingStatus.milerAssigned => 'assigned',
|
||||
// ── `Pickup_Scheduled` is the ACCEPTED rung, not the arrived one ──
|
||||
//
|
||||
// It is the status the backend writes when the rider accepts an
|
||||
// assignment — the flow doc calls it "the pickup is on their route", and
|
||||
// the console maps it to *accepted* for exactly that reason.
|
||||
//
|
||||
// This mapped it to `active`, which every row in this app reads as *the
|
||||
// rider is physically on the stop* (see `stopStateOf`, where a raw
|
||||
// `active` outranks the local accepted record). The effect was that
|
||||
// accepting skipped a whole rung: the moment the queue came back, the
|
||||
// stop reported as arrived, and selecting it offered **Mark as Picked**
|
||||
// for a kitchen the rider had not reached yet. Arrival — the one rung
|
||||
// that has a real endpoint behind it, `reached` — could not be recorded
|
||||
// at all, so the hub never saw it.
|
||||
//
|
||||
// The two applications were reading one status two different ways. This
|
||||
// is the app's half of that; the console's half already said accepted.
|
||||
BookingStatus.pickupScheduled => 'accepted',
|
||||
// The rung `reached` writes. It was only ever reachable through the
|
||||
// undocumented `At_Customer` spelling, handled here as a special case
|
||||
// ahead of the parse; `Arrived_At_Pickup` is the contract name and both
|
||||
// now come through [BookingStatus].
|
||||
BookingStatus.arrivedAtPickup => 'arrived',
|
||||
BookingStatus.pickedUp => 'Picked up',
|
||||
BookingStatus.convertedToConsignment => 'picked',
|
||||
// ── Past the boundary, and it has to say so ──
|
||||
//
|
||||
// A hyperlocal booking is released for delivery by `pickup-complete`
|
||||
// itself, so this is the status most collected DailyGrubs orders carry.
|
||||
// It fell through as `unknown` → '' → *undecided*, which put a bag
|
||||
// already in the rider's box back on Home as work to accept. See
|
||||
// [BookingStatus.outForDelivery].
|
||||
BookingStatus.outForDelivery => 'outfordelivery',
|
||||
BookingStatus.delivered => 'delivered',
|
||||
BookingStatus.cancelled => 'cancelled',
|
||||
BookingStatus.unknown => '',
|
||||
};
|
||||
}
|
||||
|
||||
/// The delivery half of the same translation.
|
||||
///
|
||||
/// ── Why a second mapper exists ──
|
||||
///
|
||||
/// A booking's story ends at `Converted_To_Consignment`. From there the work
|
||||
/// belongs to a different object with a different vocabulary, and the app
|
||||
/// had no way to see it on a list: every collected stop — in the box, on the
|
||||
/// road, handed over an hour ago — reported the same terminal booking word.
|
||||
/// That is why a delivered stop kept sitting on the Deliveries tab until
|
||||
/// something asked its consignment directly, one round trip per stop.
|
||||
///
|
||||
/// Since 21 Aug 2026 `GET /miler/bookings` carries `consignmentstatus` on
|
||||
/// every row, so the list itself answers it.
|
||||
///
|
||||
/// Returns `''` for the hub-side states (`Created`, `Inwarded_at_Hub`,
|
||||
/// `Tripsheet_Loaded`, `In_Transit`) and for anything unrecognised — the
|
||||
/// caller then keeps the booking's own word. A hub-side consignment is not
|
||||
/// this rider's to act on and has no rung on his card; inventing one would
|
||||
/// put a parcel in somebody else's warehouse on his screen.
|
||||
static String legacyStatusFromConsignment(Object? raw) {
|
||||
return switch (consignmentStateFromRaw(raw)) {
|
||||
// Collected and in the rider's hands. Same rung the booking's
|
||||
// `Converted_To_Consignment` produces — but now it is the consignment
|
||||
// itself saying so.
|
||||
ConsignmentState.collectedByMiler => 'picked',
|
||||
ConsignmentState.outForDelivery => 'outfordelivery',
|
||||
ConsignmentState.delivered => 'delivered',
|
||||
ConsignmentState.cancelled ||
|
||||
ConsignmentState.returnedToSender => 'cancelled',
|
||||
_ => '',
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -124,6 +255,9 @@ class ApiConfig {
|
||||
// pickuplat/pickuplong, dropaddress/droplat/droplon, pickupcustomer,
|
||||
// pickupcontactno, collectionamt, step, type ...). Translate a new `booking`
|
||||
// object into that shape so the existing cards/flows render unchanged.
|
||||
/// The sequence spellings this adapter looks for, in [RouteOrder]'s order.
|
||||
static const List<String> sequenceFieldNames = RouteOrder.sequenceKeys;
|
||||
|
||||
static Map<String, dynamic> pickupFromBooking(Map booking) {
|
||||
String s(dynamic v) => v == null ? '' : v.toString();
|
||||
// First non-null value among several candidate keys — makes the adapter
|
||||
@@ -168,6 +302,21 @@ class ApiConfig {
|
||||
'orderstatus',
|
||||
'state',
|
||||
]);
|
||||
|
||||
// ── The consignment outranks the booking, when there is one ──
|
||||
//
|
||||
// Not a preference — a correction. `Converted_To_Consignment` is where the
|
||||
// booking stops being informative, so if the row also reports where the
|
||||
// consignment has got to, that is the newer fact and the one the card must
|
||||
// draw. Falls back to the booking's word whenever the consignment says
|
||||
// nothing this rider can act on. See [legacyStatusFromConsignment].
|
||||
final consignmentStatus = pick([
|
||||
'consignmentstatus',
|
||||
'consignmentStatus',
|
||||
'consignment_status',
|
||||
]);
|
||||
final fromConsignment = legacyStatusFromConsignment(consignmentStatus);
|
||||
|
||||
return <String, dynamic>{
|
||||
// identity
|
||||
'pickupid': id,
|
||||
@@ -177,7 +326,12 @@ class ApiConfig {
|
||||
'bookingreference': s(ref),
|
||||
|
||||
// status
|
||||
'orderstatus': legacyStatusFromNew(s(status)),
|
||||
'orderstatus': fromConsignment.isNotEmpty
|
||||
? fromConsignment
|
||||
: legacyStatusFromNew(s(status)),
|
||||
// Kept raw alongside, so anything that needs the consignment's own word
|
||||
// reads it rather than inferring it back out of the legacy one.
|
||||
'consignmentstatus': s(consignmentStatus),
|
||||
|
||||
// pickup side
|
||||
'pickupcustomer': s(
|
||||
@@ -201,6 +355,9 @@ class ApiConfig {
|
||||
'pickupaddress': s(
|
||||
pick(['pickupaddress', 'pickupAddress', 'pickup_address']),
|
||||
),
|
||||
'pickuppincode': s(
|
||||
pick(['pickuppincode', 'pickupPincode', 'pickup_pincode']),
|
||||
),
|
||||
'pickuplat': s(
|
||||
pick([
|
||||
'pickuplatitude',
|
||||
@@ -232,6 +389,9 @@ class ApiConfig {
|
||||
'dropaddress': s(
|
||||
pick(['deliveryaddress', 'deliveryAddress', 'delivery_address']),
|
||||
),
|
||||
'droppincode': s(
|
||||
pick(['deliverypincode', 'deliveryPincode', 'delivery_pincode']),
|
||||
),
|
||||
'droplat': s(
|
||||
pick(['deliverylatitude', 'deliveryLatitude', 'delivery_latitude']),
|
||||
),
|
||||
@@ -239,9 +399,80 @@ class ApiConfig {
|
||||
pick(['deliverylongitude', 'deliveryLongitude', 'delivery_longitude']),
|
||||
),
|
||||
|
||||
// stop type — new bookings are first-mile PICKUPS; delivery legs come
|
||||
// through the consignment flow. Backend has no per-stop `type` yet.
|
||||
'type': 'pickup',
|
||||
// ── Where this stop is collected FROM ──
|
||||
//
|
||||
// On a milk run the rider works two or three sources in a morning and
|
||||
// Home groups his stops under one heading per source, with the bulk
|
||||
// collect belonging to that group. `stopSourceId` / `stopSourceName` read
|
||||
// exactly these keys — and this adapter builds a fixed map, so a field it
|
||||
// does not name is a field the UI can never see, however faithfully the
|
||||
// backend sends it. That was the bug: every milk-run stop grouped into
|
||||
// one nameless pile.
|
||||
//
|
||||
// Empty on a logistics booking, which is collected from a customer's door
|
||||
// rather than from a source. Nothing is invented when the keys are
|
||||
// absent: an empty string groups as "no source", which is the truth.
|
||||
'sourceid': s(
|
||||
pick([
|
||||
'sourceid',
|
||||
'sourceId',
|
||||
'source_id',
|
||||
'kitchenid',
|
||||
'kitchenId',
|
||||
'pickuplocationid',
|
||||
'pickupLocationId',
|
||||
]),
|
||||
),
|
||||
'sourcename': s(
|
||||
pick([
|
||||
'sourcename',
|
||||
'sourceName',
|
||||
'source_name',
|
||||
'kitchenname',
|
||||
'kitchenName',
|
||||
'providerlocation',
|
||||
'providercompany',
|
||||
]),
|
||||
),
|
||||
'pickuplocationid': s(
|
||||
pick(['pickuplocationid', 'pickupLocationId', 'pickup_location_id']),
|
||||
),
|
||||
|
||||
// Present once the booking has been converted. It is what the delivery
|
||||
// route keys on, so a milk-run drop cannot be closed without it.
|
||||
'consignmentid': s(
|
||||
pick(['consignmentid', 'consignmentId', 'consignment_id']),
|
||||
),
|
||||
|
||||
// ── The hub's solved position in the route ──
|
||||
//
|
||||
// Verified live 21 Aug 2026: `GET /miler/bookings` carries `step` on
|
||||
// every row. This adapter builds a **fixed map**, so a field it does not
|
||||
// name is a field the UI can never see however faithfully the backend
|
||||
// sends it — and `step` was not named. The delivery leg therefore had no
|
||||
// sequence at all and fell back to ordering by distance, which is the
|
||||
// app re-planning a route the hub had already solved.
|
||||
//
|
||||
// `0` means *not sequenced* and is passed through as such; see
|
||||
// [RouteOrder.sequenceOf], which treats it as "no answer", never as
|
||||
// position zero.
|
||||
'step': pick(sequenceFieldNames) ?? 0,
|
||||
|
||||
// ── Which leg this stop is ──
|
||||
//
|
||||
// Also live, also previously hardcoded: every row came through as
|
||||
// `'pickup'` because "backend has no per-stop type yet". It does now —
|
||||
// 23 of this rider's 29 rows say `delivery`.
|
||||
'type': s(pick(['stoptype', 'stopType', 'stop_type'])).isEmpty
|
||||
? 'pickup'
|
||||
: s(pick(['stoptype', 'stopType', 'stop_type'])).toLowerCase(),
|
||||
|
||||
// Route estimates, straight from the assignment. Zero until the hub's
|
||||
// optimizer has run — the app shows its own estimate in that case and
|
||||
// says so rather than drawing a confident 0.
|
||||
'etaminutes': pick(['etaminutes', 'etaMinutes']) ?? 0,
|
||||
'cumulativekms': pick(['cumulativekms', 'cumulativeKms']) ?? 0,
|
||||
'cumulativeeta': pick(['cumulativeeta', 'cumulativeEta']) ?? 0,
|
||||
|
||||
// money — NOT provided by the new booking object yet (see gaps doc)
|
||||
'collectionamt': booking['collectionamt'] ?? 0,
|
||||
|
||||
Reference in New Issue
Block a user