Files
doormile_milderapp/lib/data/api_config.dart
Thiru-tenext d612916fe4 Session expiry, arrival geofence guard, multi-destination stops
Three fixes found by running the app on a real handset against production.

1. An expired token left the app looking signed in and unable to work.
   MilerApi.onUnauthorized was declared and called on every 401 but never
   assigned, so the token was dropped and nothing else happened: the profile
   stayed on disk, logged_out stayed false, and the rider saw his own name over
   a dashboard whose every call returned 401. He reads that as "no work today".
   The teardown now lives in endSession() and both ways out of a session — the
   Log out button and the 401 path — use it.

2. Arrived was written locally even when the rider was not there.
   updateArrivedStatus answers false for three different things and the caller
   treated all of them as "the write did not land", which is only true of one.
   A geofence refusal and a server refusal now stop the rung and hand back the
   reason; a dead network still advances, as it should.

3. A multi-destination customer pickup collapsed onto one stop.
   GET /miler/bookings returns a row per destination once collected, all with
   the same bookingid and reference. Every local store keys on that id, so the
   accepted store deduped two of three drops away and their consignment ids
   were unrecoverable. orderid is now the stop key; bookingreference stays the
   booking's name. Cards show "Stop 2 of 3" and the receiver's own name and
   number rather than the sender's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
2026-09-18 11:05:40 +05:30

746 lines
32 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, on two now-retired hosts.
/// 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',
]);
// ── One customer pickup can be several drops ──
//
// A customer-app booking carries N destinations and `GET /miler/bookings`
// returns ONE ROW PER DESTINATION once the pickup has been collected —
// same `bookingid`, same `bookingreference`, different door. The backend
// labels them for us: `destinationseq` is which one this is and
// `destinationcount` how many there are (both absent, or 1, on every
// console and milk-run booking, which is the whole existing world).
//
// Everything the rider's device remembers about a stop is keyed on
// `orderid` — the accepted store dedupes on it, the consignment-id map
// files under it, the collected and out-for-delivery sets hold it, the ETA
// and km keys are built from it. So three drops sharing one `orderid` is
// not a display bug: `addAcceptedBookings` deduped two of the three away
// before any screen saw them, and the two consignment ids that lost the
// race were unrecoverable, which is a bag the rider is holding with no
// door to take it to.
//
// So `orderid` becomes the **stop** key and carries the destination on it.
// The booking's own reference is untouched below in `bookingreference`,
// which is what every screen shows the rider and what he reads out on the
// phone. Single-destination bookings are byte-identical to before — the
// suffix only exists where there is something to tell apart.
final destinationCount =
int.tryParse(
(pick(['destinationcount', 'destinationCount']) ?? '').toString(),
) ??
1;
final destinationSeq =
int.tryParse(
(pick(['destinationseq', 'destinationSeq']) ?? '').toString(),
) ??
0;
final bookingKey = ref ?? id;
final stopKey = destinationCount > 1
? '$bookingKey#$destinationSeq'
: bookingKey;
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,
// The STOP key — see the note above. `bookingreference` is the booking's
// own name and is what gets shown; this is what gets remembered.
'orderid': stopKey,
'orderheaderid': id,
'bookingid': id, // keep original too
'bookingreference': s(ref),
// ── Which drop of the visit this is ──
//
// Carried so a card can say "Stop 2 of 3" rather than showing three rows
// that are identical down to the reference number. `destinationcount` is
// 1 for every single-destination and console booking, and the UI treats
// 1 as "say nothing".
'destinationseq': destinationSeq,
'destinationcount': destinationCount,
// ── The parcel's own number, and who is waiting for it ──
//
// All three exist per DESTINATION, not per booking: on a three-drop
// pickup each door has its own tracking number and its own receiver, and
// the booking-level customer is the SENDER, who is not at any of them.
// Dropped by this adapter until now, so the rider arrived at a stranger's
// door with the sender's name on his screen and no number to read out.
'trackingno': s(pick(['trackingno', 'trackingNo', 'tracking_no'])),
'recipientname': s(
pick(['recipientname', 'recipientName', 'recipient_name']),
),
'recipientphone': s(
pick(['recipientphone', 'recipientPhone', 'recipient_phone']),
),
// 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']),
),
// ── Where this parcel goes next, in the server's own word ──
//
// Shipped with request 27. Until it existed, `next_action` was returned
// **once** — by `pickup-complete` — and the app had to keep the pivot's
// answer on the handset to survive a poll, because `Created` is both the
// state a hub-routed parcel settles on *and* the state one holds while
// the pivot is still routing it. That cache is now the fallback rather
// than the source: [NextLegResolver] prefers this field over it, so a
// reinstall, a second device or a routing the office changed mid-day all
// read the live answer.
//
// Values: `pickup`, `start_delivery`, `inward_at_hub`, `deliver`,
// `handed_to_hub`, `none`. Carried verbatim — the resolver owns the
// reading of them, and an unrecognised word must reach it intact so it
// can fall through rather than being flattened here.
'next_action': s(
pick(['next_action', 'nextaction', 'nextAction']),
),
// ── The base this parcel is to be handed in at ──
//
// A nested object, carried whole: id, name, address, pincode, latitude,
// longitude. Null on a hyperlocal parcel, which has no base leg. See
// [HandoverHub], which is the only thing that reads it.
//
// This is the field that makes a handover navigable. Before it the app
// knew a parcel was hub-routed and had no idea which building — every
// `hubLat`/`hubLng` in the codebase was the rider's own position standing
// in for one.
'next_hub': booking['next_hub'] ?? booking['nexthub'],
// ── What kind of place this is collected FROM ──
//
// Shipped with request 28. `hub` / `customer` / `merchant` / `store`, on
// the row rather than against a location master — a customer-door pickup
// has no location id at all, so a type held against locations could never
// classify one.
//
// Home titled every logistics pickup group with the rider's own base name
// before this, because the per-booking source was unreliable and a
// constant was the safer wrong answer. This is what retires that.
'pickup_source_type': s(
pick(['pickup_source_type', 'pickupsourcetype', 'pickupSourceType']),
),
// The counter's own name, as the hub holds it. Distinct from
// `sourcename`, which the backend fills from `providercompany` /
// `providerlocation` and which is a contact person on some rows — that is
// how the biggest type on Home once read `Sudharsan`.
'pickup_source_name': s(
pick(['pickup_source_name', 'pickupsourcename', 'pickupSourceName']),
),
// ── 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,
// ── The stamp that says whether `step` means anything ──
//
// Dropped here until now, which made the whole sequencing contract
// unreadable: [RouteOrder.isSequenced] looks for this key and the adapter
// never wrote it, so every adapted row looked unsequenced and the app
// fell back to nearest-first on routes the hub had actually solved.
//
// The backend's rule, confirmed 25 Aug: **`sequencedat` is the
// authority, not `step`.** Non-null → a route was assigned, follow `step`
// exactly. Null → no route, and the fallback is correct. `step: 0` with a
// null stamp is not a bug: it is a rider holding fewer than two active
// stops, or a stop without coordinates — neither of which is a route.
'sequencedat': pick(RouteOrder.sequencedAtKeys),
// ── The arrival, as the backend records it ──
//
// Confirmed by the backend team and shipped with their redeploy:
// `/reached` writes an arrival **event**, and `GET /miler/bookings`
// returns it on the row. There is no `Arrived_At_Pickup` booking status
// and there never was — the status stays `Pickup_Scheduled` and the
// stamp beside it is what says he is there.
//
// Carried through here so the rung can be rebuilt from server data alone
// after a refresh or a restart, which is the thing the local arrival
// record exists to stand in for. See [riderStageOf].
'reachedat': pick(const [
'reachedat',
'reachedAt',
'reached_at',
'arrivedat',
]),
'arrivallatitude': pick(const [
'arrivallatitude',
'arrivalLatitude',
'arrival_latitude',
]),
'arrivallongitude': pick(const [
'arrivallongitude',
'arrivalLongitude',
'arrival_longitude',
]),
// ── 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']),
// ── The row's own clocks, carried verbatim ──
//
// This adapter builds a **fixed map**, so a field it does not name is a
// field the app can never see — the same trap `step` and `stoptype` were
// in. Every timestamp on the booking was in that trap, and the cost was
// not cosmetic: [ServiceDay] dates a row by exactly these key names, so
// an adapted row carried nothing to date it by and *every* screen that
// asks "does this belong to today" had to answer "cannot tell".
//
// That is how yesterday's assignments stayed on Home. The filter was
// never missing so much as starved: it was asking a question of fields
// this method had already thrown away.
//
// Copied under the names the wire uses, with no reshaping and no
// parsing. [ServiceDay] and [parseStamp] own the reading of them —
// Doormile sends IST wall-clock in naive columns, sometimes with a
// trailing `Z` it never had, and the one place that knows that should
// stay the one place that knows it.
for (final k in timestampFieldNames)
if (booking[k] != null && booking[k].toString().trim().isNotEmpty)
k: booking[k],
// ── And the row's distances, for the same reason ──
//
// `cumulativekms` was named above and the rest were not, so the whole
// distance vocabulary was dropped here: `kms` (what the hub planned),
// `riderkms` (what the rider actually covered) and the `compliance` block
// the contract carries them in.
//
// [StopCompliance] reads exactly those names, so it answered `null` for
// every adapted row — and Activity's `km ridden` totalled zero all day
// and printed an em dash. Same failure as the timestamps, on a different
// set of fields: a fixed map cannot pass on what it does not name.
for (final k in distanceFieldNames)
if (booking[k] != null && booking[k].toString().trim().isNotEmpty)
k: booking[k],
};
}
/// Every distance key a booking row is known to carry, copied through
/// [pickupFromBooking] untouched.
///
/// Kept in step with [StopCompliance], which is what reads them. `compliance`
/// is a nested object rather than a number and is passed on whole — this
/// adapter's job is to stop losing fields, not to reshape them.
static const List<String> distanceFieldNames = [
// What the hub planned for this stop.
'kms',
'km',
// What the rider actually covered. `riderkms` is the backend's name;
// `actualkms` is the name this app posts on its own pickup write.
'riderkms',
'actualkms',
// The contract's block, carrying both plus the on-time verdict.
'compliance',
];
/// Every timestamp key a booking row is known to carry, copied through
/// [pickupFromBooking] untouched.
///
/// Deliberately a superset of what any one endpoint sends: a name that is
/// absent costs one map lookup, and a name that is missing costs a screen
/// its ability to tell today from yesterday. Kept in step with
/// `ServiceDay.timeKeys`, which is what reads them.
static const List<String> timestampFieldNames = [
'createdat',
'createdon',
'updatedat',
'updatedon',
'modifiedon',
'pickedtime',
'picked_time',
'deliverytime',
'deliveredat',
'completedat',
'expected_pickup_time',
'expectedpickuptime',
'slotstarttime',
'slotendtime',
'slotfrom',
'slotto',
'assignedat',
'assignedon',
];
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');
}
}