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

@@ -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,