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 setToken(String token) async { final prefs = await SharedPreferences.getInstance(); await prefs.setString(_kTokenKey, token); } static Future getToken() async { final prefs = await SharedPreferences.getInstance(); final t = prefs.getString(_kTokenKey); return (t != null && t.isNotEmpty) ? t : null; } static Future clearToken() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_kTokenKey); } /// Headers for every authenticated request (Content-Type + Bearer token). static Future> authHeaders() async { final headers = { '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 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 toLegacyEnvelope(dynamic decoded) { if (decoded is Map) { final success = decoded['success']; final bool ok = success is bool ? success : success == true; return { 'status': ok, 'code': ok ? 200 : 400, 'message': decoded['message']?.toString() ?? '', 'details': decoded['data'] ?? decoded['details'] ?? decoded, }; } // Bare list/value response. return {'status': true, 'code': 200, 'details': decoded}; } /// A generic "it worked" legacy envelope (for no-op / not-yet-supported flows). static Map okEnvelope([String message = '']) => {'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 sequenceFieldNames = RouteOrder.sequenceKeys; static Map 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 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.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 { // 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 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 timestampFieldNames = [ 'createdat', 'createdon', 'updatedat', 'updatedon', 'modifiedon', 'pickedtime', 'picked_time', 'deliverytime', 'deliveredat', 'completedat', 'expected_pickup_time', 'expectedpickuptime', 'slotstarttime', 'slotendtime', 'slotfrom', 'slotto', 'assignedat', 'assignedon', ]; static List> pickupsFromBookings(dynamic data) { if (data is List) { return data.whereType().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 >[pickupFromBooking(data)]; } return >[]; } static void logGap(String where, String detail) { debugPrint('[API_GAP][$where] $detail'); } }