Files
doormile_milderapp/lib/data/api_config.dart
Thiru-tenext d7348e253f 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>
2026-08-22 05:40:35 +05:30

504 lines
21 KiB
Dart

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
/// legacy stop shape the UI reads.
///
/// ── The flag is gone ──
///
/// This class used to carry `useNewApi`, a `--dart-define` switch between the
/// v1 backend and a legacy one (`jupiter.doormile.app` / `queue.workolik.com`).
/// Every provider branched on it, so the app shipped two implementations of
/// every call and only one of them was ever exercised.
///
/// It was also the switch that turned the mock layer on: `USE_NEW_API=false`
/// gave a fake "Demo Rider" login that accepted any four digits, seeded demo
/// routes, and invented earnings figures. A build flag that silently swaps
/// authentication for a bypass is not a development convenience.
///
/// One backend, one path, no flag. `MilerApi` is where the endpoints live; what
/// remains here is the base URL, the token, and the booking→stop mapping.
class ApiConfig {
ApiConfig._();
/// API base URL (no trailing slash).
static const String newBase = 'https://api.doormile.com/api/v1';
static String url(String path) =>
'$newBase${path.startsWith('/') ? path : '/$path'}';
// ---------------------------------------------------------------------------
// Bearer token
// ---------------------------------------------------------------------------
static const String _kTokenKey = 'authtoken';
static Future<void> setToken(String token) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kTokenKey, token);
}
static Future<String?> getToken() async {
final prefs = await SharedPreferences.getInstance();
final t = prefs.getString(_kTokenKey);
return (t != null && t.isNotEmpty) ? t : null;
}
static Future<void> clearToken() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_kTokenKey);
}
/// Headers for every authenticated request (Content-Type + Bearer token).
static Future<Map<String, String>> authHeaders() async {
final headers = <String, String>{
'Content-Type': 'application/json',
'Accept': 'application/json',
};
final token = await getToken();
if (token != null) headers['Authorization'] = 'Bearer $token';
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}
// ---------------------------------------------------------------------------
/// Wrap a decoded new-API response into the legacy envelope the controllers
/// expect. `_isSuccess()` reads `status` (bool); readers read `details`.
static Map<String, dynamic> toLegacyEnvelope(dynamic decoded) {
if (decoded is Map<String, dynamic>) {
final success = decoded['success'];
final bool ok = success is bool ? success : success == true;
return <String, dynamic>{
'status': ok,
'code': ok ? 200 : 400,
'message': decoded['message']?.toString() ?? '',
'details': decoded['data'] ?? decoded['details'] ?? decoded,
};
}
// Bare list/value response.
return <String, dynamic>{'status': true, 'code': 200, 'details': decoded};
}
/// A generic "it worked" legacy envelope (for no-op / not-yet-supported flows).
static Map<String, dynamic> okEnvelope([String message = '']) =>
<String, dynamic>{'status': true, 'code': 200, 'message': message};
// ---------------------------------------------------------------------------
// STATUS mapping (new booking status <-> legacy orderstatus)
// ---------------------------------------------------------------------------
//
// New (in order): Miler_Assigned -> Pickup_Scheduled -> At_Customer
// -> Picked_Up -> Converted_To_Consignment -> Cancelled
// Legacy the UI branches on: accepted / active / arrived / "Picked up"
// / picked / skipped / cancelled / rejected
static String legacyStatusFromNew(String? 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',
_ => '',
};
}
// ---------------------------------------------------------------------------
// BOOKING -> legacy pickup object mapping
// ---------------------------------------------------------------------------
//
// The UI reads legacy lowercase keys (pickupid, orderid, orderstatus,
// 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
// tolerant of field-name variants (camelCase / snake_case / short forms)
// so a naming mismatch can't map bookings to empty ids and drop them.
dynamic pick(List<String> keys) {
for (final k in keys) {
final v = booking[k];
if (v != null && v.toString().trim().isNotEmpty) return v;
}
return null;
}
// Some endpoints already return the legacy STOP shape the UI reads
// (orderid/pickupid/orderstatus/pickupcustomer). Translating that as if it
// were a new-booking object would map everything to empty ids (→ dropped)
// and lose fields the translation doesn't cover (step, tenant, amounts).
// Detect it and pass it through untouched.
final looksNew =
booking['bookingid'] != null || booking['bookingreference'] != null;
final looksLegacy =
booking['orderid'] != null ||
booking['pickupid'] != null ||
booking['orderstatus'] != null ||
booking['pickupcustomer'] != null;
if (!looksNew && looksLegacy) {
return Map<String, dynamic>.from(booking);
}
final id = pick(['bookingid', 'bookingId', 'id', 'booking_id', 'pickupid']);
final ref = pick([
'bookingreference',
'bookingRef',
'reference',
'referenceno',
'orderid',
]);
final status = pick([
'status',
'bookingstatus',
'booking_status',
'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,
'orderid': ref ?? id,
'orderheaderid': id,
'bookingid': id, // keep original too
'bookingreference': s(ref),
// 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(
pick([
'customername',
'customerName',
'customer_name',
'name',
'pickupcustomer',
]),
),
'pickupcontactno': s(
pick([
'customerphone',
'customerPhone',
'customer_phone',
'phone',
'pickupcontactno',
]),
),
'pickupaddress': s(
pick(['pickupaddress', 'pickupAddress', 'pickup_address']),
),
'pickuppincode': s(
pick(['pickuppincode', 'pickupPincode', 'pickup_pincode']),
),
'pickuplat': s(
pick([
'pickuplatitude',
'pickupLatitude',
'pickup_latitude',
'pickuplat',
]),
),
'pickuplong': s(
pick([
'pickuplongitude',
'pickupLongitude',
'pickup_longitude',
'pickuplong',
'pickuplon',
]),
),
'pickuplon': s(
pick([
'pickuplongitude',
'pickupLongitude',
'pickup_longitude',
'pickuplong',
'pickuplon',
]),
),
// delivery / drop side
'dropaddress': s(
pick(['deliveryaddress', 'deliveryAddress', 'delivery_address']),
),
'droppincode': s(
pick(['deliverypincode', 'deliveryPincode', 'delivery_pincode']),
),
'droplat': s(
pick(['deliverylatitude', 'deliveryLatitude', 'delivery_latitude']),
),
'droplon': s(
pick(['deliverylongitude', 'deliveryLongitude', 'delivery_longitude']),
),
// ── 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,
'pickupamt': booking['pickupamt'] ?? 0,
// misc passthrough
'parcels': booking['parcels'] ?? const [],
'starttime': s(booking['createdat']),
'eta': s(booking['eta']),
};
}
static List<Map<String, dynamic>> pickupsFromBookings(dynamic data) {
if (data is List) {
return data.whereType<Map>().map((b) => pickupFromBooking(b)).toList();
}
// A single booking object (some endpoints return one, not a list).
if (data is Map &&
(data['bookingid'] != null || data['bookingreference'] != null)) {
return <Map<String, dynamic>>[pickupFromBooking(data)];
}
return <Map<String, dynamic>>[];
}
static void logGap(String where, String detail) {
debugPrint('[API_GAP][$where] $detail');
}
}