import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import 'package:miler/data/api_config.dart'; import 'package:miler/data/mock_backend.dart'; import 'package:miler/data/mutation_guard.dart'; /// ───────────────────────────────────────────────────────────────────────── /// THE MILER API — every `/miler/*` route, in one place. /// /// Before this existed, endpoints were spelled out inline across five provider /// files, a controller and a widget, each with its own idea of how to decode an /// envelope and what counts as success. Half the contract was implemented and /// the other half was a `logGap` call — and there was nowhere to look to find /// out which half a given route was in. /// /// This is the whole surface: 3 auth routes and 35 authenticated ones. If a /// route is not here, the backend does not have it (see [missingFromBackend]). /// /// ── Things the contract gets wrong if you guess ── /// /// Each of these cost a real debugging session, so they are encoded here rather /// than left to the call site: /// /// • **`configid` is 1001.** It defaults server-side, but a miler row created /// without it can never log in. Always sent. /// • **`verify-pin` has no `data` key.** The login lands under `user` / /// `user.profile`. An earlier contract doc said otherwise and was wrong. /// • **Telemetry numbers are strings.** `latitude`, `longitude`, `speed`, /// `heading`, `battery` on `/logs` and `/consignments/logs` fail to parse if /// sent as JSON numbers. Everywhere else they are numbers. See [_str]. /// • **Never send `userid` in a telemetry body.** Identity comes from the /// token; a body `userid` used to let one rider write another's GPS into the /// dispatch index. It is ignored now, but sending it is still wrong. /// • **The break status is `Break`,** not `On_Break`. /// • **`vehicle-required` reads query strings,** not a body. /// • **`reject` reads its reason from the query string** in the deployed /// handler, while the contract doc says body. Both are sent — see /// [rejectAssignment]. /// • **`/miler/reset-pin` is deliberately absent from this class.** It needs an /// admin token, and it was once open: reset-pin followed by verify-pin took /// over any rider account given only a phone number. Rider PIN resets go /// through ops. Do not add it here. /// ───────────────────────────────────────────────────────────────────────── class MilerApi { MilerApi._(); /// The partition riders live in. static const int configId = 1001; /// ── The operating company this build signs riders in for ── /// /// Sent as `tenantid` on both auth calls, so the backend can scope the login /// to one company. Set at build time: /// /// flutter build apk --dart-define=TENANT_ID=7 /// /// Zero means "not specified" and the field is **omitted** rather than sent /// as `0` — a zero looks like a real tenant to a server doing a lookup, and /// an omitted field is the only way to say "you decide". /// /// ── What this value is and is not ── /// /// It is a *hint from the build*, not an authorisation claim. The rider's /// real tenant is a column on his account row and the server is the only /// thing that knows it; a client that asserts its own scope is a client that /// can be modified to assert somebody else's. So the response wins wherever /// it answers — see the tenant resolution in `auth_provider.dart`, which /// falls back to this only while `verify-pin` returns no tenant at all. /// /// The day the backend returns `tenantid` on the profile, this keeps being /// sent and stops being read, and nothing else has to change. static const int tenantId = int.fromEnvironment('TENANT_ID'); /// True when this build was given a tenant to declare. static bool get hasTenantId => tenantId > 0; static const Duration _timeout = Duration(seconds: 20); /// How long to wait before asking again for the result of a write the server /// says it is already running, and how many times. Kept small: the rider is /// standing at a door with his thumb on the screen, and beyond a second or /// so a spinner stops reading as *working* and starts reading as *stuck*. static const Duration _inFlightBackoff = Duration(milliseconds: 600); static const int _inFlightRetries = 2; /// ── The one seam in the transport ── /// /// Every `/miler/*` route goes through [_send], and [_send] went straight to /// the `http` package's top-level functions — which are not injectable, so the /// only way to assert what this client actually *puts on the wire* was to let /// it reach the network. /// /// That matters more here than it usually would, because several fields in /// this contract are quietly load-bearing: `device_token` is snake_case where /// everything around it is not, telemetry numbers are **strings**, telemetry /// bodies must carry no `userid`, availability sends `Break` and not /// `On_Break`, and `vehicle-required` takes query parameters rather than a /// body. Every one of those is a silent failure if it drifts, and none of them /// was covered. /// /// A swappable client makes the request itself the thing under test. Nothing /// in the app assigns this; only tests do. @visibleForTesting static http.Client client = http.Client(); /// Wraps a non-idempotent mutation so a double tap cannot send it twice. /// /// A dropped duplicate comes back as a **failed result carrying the reason** /// rather than as null, so a caller that does not know about the guard still /// takes its error path instead of reading a null as success. See /// [MutationGuard] for why a second press is dropped rather than queued. static Future _guarded( String key, Future Function() send, ) async => await MutationGuard.run(key, send) ?? const ApiResult( ok: false, status: 0, message: 'That is already going through — give it a moment.', ); /// Called once when an authenticated call is refused by the server. /// /// The transport does the irreversible half — dropping the dead token — and /// this hands the rest to whoever owns navigation, so this file never has to /// know what a screen is. Set by the app shell; null in tests unless one is /// asserting on it. static void Function()? onUnauthorized; /// Telemetry wants strings for values that are numbers everywhere else. static String _str(dynamic v) => v == null ? '' : v.toString(); /// `YYYY-MM-DD HH:MM:SS`, the phone's **local wall clock**, for `logdate`. /// /// ── This was briefly changed to UTC, and that was wrong ── /// /// The reasoning looked sound: the field carries no zone suffix, so one side /// has to name the convention, and UTC is the usual answer. It is not the /// answer here. The Doormile backend's read range — `DBNow` / `DBToday` — and /// the database itself run on **IST wall-clock**, so a UTC stamp from an /// Indian handset lands 5h30m *behind* the window the console asks for, and /// after 18:30 local it files the evening's work under the previous day. /// /// Confirmed against the live backend on 28 Aug 2026, after the UTC change /// had already shipped to a test build. It went unnoticed there because that /// handset's own clock was running on UTC, so local and UTC agreed and the /// rows landed in the window anyway. /// /// So: local, and the phone's timezone is assumed to be the fleet's. That is /// true for every Doormile Miler today. The durable fix is an offset on the /// wire — an ISO-8601 stamp the backend parses with its zone — and until the /// contract carries one, this is the convention both sides have agreed. static String logStamp([DateTime? at]) { // `.toLocal()` because a caller can hand this a UTC `DateTime` — the // consignment breadcrumb does — and reading `.hour` off one of those // renders the UTC wall clock, which is the bug this method just came back // from. A local `DateTime` passes through unchanged. final t = (at ?? DateTime.now()).toLocal(); String p(int n) => n.toString().padLeft(2, '0'); return '${t.year}-${p(t.month)}-${p(t.day)} ' '${p(t.hour)}:${p(t.minute)}:${p(t.second)}'; } /// `YYYY-MM-DD`, for date filters. static String dateStamp([DateTime? at]) { final t = at ?? DateTime.now(); String p(int n) => n.toString().padLeft(2, '0'); return '${t.year}-${p(t.month)}-${p(t.day)}'; } // ── Transport ────────────────────────────────────────────────────────── static Future _send( String method, String path, { Object? body, Map? query, bool auth = true, /// Sent as `Idempotency-Key`. A retry with the same key replays the first /// response instead of writing twice — see [_idempotencyKey]. String? idempotencyKey, /// How many times an `IDEMPOTENCY_IN_PROGRESS` answer has already been /// waited out on this call. Internal — see [_inFlightRetries]. int inFlightAttempt = 0, }) async { // ── Nothing leaves the device while the mock is on ── // // Intercepted here rather than per call site, because this is the one door // every `/miler/*` route goes through — and a mock that covers 38 routes by // covering one function cannot drift out of step with the ones it missed. // The canned body is handed to exactly the same envelope handling as a real // 200, so the app cannot tell the difference and neither can this file. if (MockBackend.enabled) { final canned = MockBackend.respond( method, path, body: body, query: query, ); if (canned != null) { return ApiResult( ok: true, status: 200, data: canned['data'] ?? canned, raw: canned, message: _str(canned['message']), ); } } final uri = Uri.parse( ApiConfig.url(path), ).replace(queryParameters: (query == null || query.isEmpty) ? null : query); final headers = { ...(auth ? await ApiConfig.authHeaders() : const { 'Content-Type': 'application/json', 'Accept': 'application/json', }), if (idempotencyKey != null && idempotencyKey.isNotEmpty) 'Idempotency-Key': idempotencyKey, }; final encoded = body == null ? null : json.encode(body); try { late http.Response res; switch (method) { case 'GET': res = await client.get(uri, headers: headers).timeout(_timeout); case 'POST': res = await client .post(uri, headers: headers, body: encoded) .timeout(_timeout); case 'PUT': res = await client .put(uri, headers: headers, body: encoded) .timeout(_timeout); case 'PATCH': res = await client .patch(uri, headers: headers, body: encoded) .timeout(_timeout); default: throw ArgumentError('Unsupported method $method'); } dynamic decoded; if (res.body.isNotEmpty) { try { decoded = json.decode(res.body); } catch (_) { decoded = res.body; } } final ok = res.statusCode >= 200 && res.statusCode < 300; // The envelope is `{success, data}` / `{success, message}`. A 2xx with // `success: false` is still a failure — the status line alone is not the // answer. final envelopeOk = decoded is Map ? decoded['success'] != false : true; final result = ApiResult( ok: ok && envelopeOk, status: res.statusCode, data: decoded is Map ? (decoded['data'] ?? decoded) : decoded, raw: decoded, message: decoded is Map ? _str(decoded['message']) : '', code: decoded is Map ? _str(decoded['code']) : '', ); // ── The duplicate that is not a failure ── // // `IDEMPOTENCY_IN_PROGRESS` means the server is *at this moment* running // the very request being retried — the first attempt's response was // lost, not its effect. Surfacing it would tell a rider his delivery // failed while it is in the act of succeeding, and a fresh press would // meet the same answer. // // It is also not a reason to declare success: the write in flight can // still fail. So the call waits and asks again, and the idempotent // replay hands back the first attempt's real outcome. Two short waits, // then whatever the server says is passed through as-is. if (!result.ok && result.isInFlight && inFlightAttempt < _inFlightRetries) { debugPrint('[API] $method $path -> in flight, waiting for the answer'); await Future.delayed(_inFlightBackoff * (inFlightAttempt + 1)); return _send( method, path, body: body, query: query, auth: auth, idempotencyKey: idempotencyKey, inFlightAttempt: inFlightAttempt + 1, ); } if (!result.ok) { debugPrint( '[API] $method $path -> ${res.statusCode} ' '${result.code.isEmpty ? '' : '[${result.code}] '}${result.message} ' '${res.body.length > 300 ? '${res.body.substring(0, 300)}…' : res.body}', ); // ── One place decides that a session is over ── // // A 401 used to surface as an ordinary failed call, so every screen // handled it — or, mostly, did not: the rider stayed visually signed in // with a dead token, every list refreshed to empty, and nothing said // why. Handled here because this is the one door all 38 routes pass // through, and because the decision is the same wherever it happens. // // The token is dropped so nothing retries with it, and [onUnauthorized] // lets the shell take the rider back to sign-in. Auth's own two routes // are exempt: a wrong PIN is a 401 about a credential, not an expired // session, and clearing state there would be a logout in response to a // typo. if (res.statusCode == 401 && auth) { await ApiConfig.clearToken(); onUnauthorized?.call(); } } return result; } catch (e) { debugPrint('[API] $method $path -> transport error: $e'); return ApiResult(ok: false, status: 0, message: '$e'); } } // ═══════════════════════════════════════════════════════════════════════ // AUTH (4 routes — reset-pin is intentionally not one of them) // ═══════════════════════════════════════════════════════════════════════ /// Step 1. 404 when there is no account, 403 when the row is not role 5 or /// not Active — both surface as `ok: false` with the server's message. /// /// Answers `{success, message, phone, pin_set}` — **top level, not under /// `data`**. Read the branch with [pinSetOf]; never off the message string. static Future login(String phone) => _send( 'POST', '/miler/login', auth: false, body: { 'phone': phone, 'configid': configId, if (hasTenantId) 'tenantid': tenantId, }, ); /// Does this account already have a PIN on file? /// /// ── The one thing the login response is asked for ── /// /// Riders set their own PIN on first sign-in now; the console no longer /// issues one. `pin_set` is how the server says which of the two screens the /// rider is owed, and it is a **boolean** — the message beside it is prose /// and prose gets reworded. Branching on "PIN verification required" would /// break the day somebody improves that sentence. /// /// Null when the response did not carry the field at all: an older server, or /// a failure. A caller that cannot tell should send the rider to Enter-PIN, /// which is the safe direction — a rider who does have a PIN can use it, and /// one who does not gets a refusal he can report, rather than being walked /// into a Set-PIN screen that will 409. static bool? pinSetOf(ApiResult res) { final raw = res.raw; final v = raw is Map ? (raw['pin_set'] ?? raw['pinSet']) : null; if (v is bool) return v; if (v is String) { final t = v.toLowerCase(); if (t == 'true') return true; if (t == 'false') return false; } return null; } /// First-time PIN creation, self-service. `POST /miler/set-pin`. /// /// ── This route did not exist, and its absence shaped the whole screen ── /// /// The only PIN-write the backend had was `POST /miler/reset-pin`, which /// needs an ADMIN token — it was once open, and reset-pin followed by /// verify-pin took over any rider account given nothing but a phone number. /// So the app could not let a rider set a PIN at all, and `AuthProvider` /// answered its own Create-MPIN screen with a manufactured 403 telling him /// his office issues it. /// /// That is no longer true. This route is rider-authenticated by phone, /// **cannot overwrite an existing PIN** (409 if one is set), and returns a /// full session — the same `{token, user}` shape as [verifyPin] — so the /// rider lands signed in without a second round trip. /// /// Refusals: `404` no such phone, `403` inactive or not a miler, `409` a PIN /// already exists — send that rider to Enter-PIN instead. static Future setPin({ required String phone, required String pin, String? deviceToken, }) async { final res = await _send( 'POST', '/miler/set-pin', auth: false, body: { 'phone': phone, 'new_pin': pin, 'configid': configId, if (hasTenantId) 'tenantid': tenantId, if (deviceToken != null && deviceToken.isNotEmpty) 'device_token': deviceToken, }, ); // Same token handling as verify-pin, deliberately: this IS a sign-in, and // a second implementation of "where does the session come from" is how the // two paths drift. if (res.ok && res.raw is Map) { final token = _str((res.raw as Map)['token']); if (token.isNotEmpty) await ApiConfig.setToken(token); } return res; } /// Step 2. On success the token is stored and the caller gets `user`. /// /// The login is under `user` / `user.profile` — there is no `data` key on /// this response, whatever the older contract doc claimed. static Future verifyPin({ required String phone, required String pin, String? deviceToken, }) async { final res = await _send( 'POST', '/miler/verify-pin', auth: false, body: { 'phone': phone, 'pin': pin, 'configid': configId, if (hasTenantId) 'tenantid': tenantId, if (deviceToken != null && deviceToken.isNotEmpty) 'device_token': deviceToken, }, ); if (res.ok && res.raw is Map) { final token = _str((res.raw as Map)['token']); if (token.isNotEmpty) await ApiConfig.setToken(token); } return res; } // ═══════════════════════════════════════════════════════════════════════ // PROFILE & DEVICE // ═══════════════════════════════════════════════════════════════════════ static Future getProfile() => _send('GET', '/miler/profile'); /// ── Email and address are saveable now ── /// /// The edit screen has always collected both and only `displayname` could be /// sent, so the other two lived on the device and were lost on a reinstall. /// The handler takes them as of this contract. /// /// **Email is unique across users.** A collision comes back `409` with code /// `EMAIL_IN_USE`; sending the address the rider already has is a no-op /// rather than a conflict. Branch on [ApiResult.code], never the message. static Future updateProfile({ String? displayName, String? profilePhotoUrl, String? defaultVehicleType, String? phone, String? email, String? address, }) => _send( 'PUT', '/miler/profile', body: { if (displayName != null) 'displayname': displayName, if (profilePhotoUrl != null) 'profilephotourl': profilePhotoUrl, if (defaultVehicleType != null) 'defaultvehicletype': defaultVehicleType, if (phone != null) 'phone': phone, if (email != null) 'email': email, if (address != null) 'address': address, }, ); /// snake_case, unlike the rest of the contract. static Future setDeviceToken(String token) => _send('PUT', '/miler/device-token', body: {'device_token': token}); // ═══════════════════════════════════════════════════════════════════════ // LOCATION & AVAILABILITY // ═══════════════════════════════════════════════════════════════════════ /// Redis only — a SET plus a GEOADD into `milers:locations`, which is the /// index dispatch searches. Numbers here, not strings; this is not telemetry. static Future pushLocation({ required double latitude, required double longitude, String? pincode, double? speed, double? heading, }) => _send( 'PUT', '/miler/location', body: { 'latitude': latitude, 'longitude': longitude, if (pincode != null && pincode.isNotEmpty) 'pincode': pincode, if (speed != null) 'speed': speed, if (heading != null) 'heading': heading, }, ); /// One of [availabilityStatuses]. Sent under both keys the backend has /// accepted at different times, so neither a doc nor a handler change can /// silently drop it. static Future setAvailability(String status) => _guarded( 'availability', () => _send( 'PUT', '/miler/availability', body: {'status': status, 'availabilitystatus': status}, ), ); /// Note `Break`, not `On_Break` — the obvious guess is the wrong one. static const List availabilityStatuses = [ 'Offline', 'Available', 'Assigned', 'On_Pickup', 'At_Customer', 'Picked_Up', 'On_Delivery', 'Break', 'Blocked', ]; // ═══════════════════════════════════════════════════════════════════════ // DUTY & BREAKS // // Ordering is enforced server-side: starting duty twice is an error, and a // break without duty is an error. Callers should treat those as state to // reconcile (re-read `dutyCurrent`), not as failures to retry. // ═══════════════════════════════════════════════════════════════════════ // Guarded on one key for both directions: starting duty twice is a // server-side error, and so is ending it twice — and a rider who taps the // switch again because the first tap "did nothing" is the commonest way to // produce either. static Future startDuty({double? lat, double? lon}) => _guarded( 'duty', () => _send( 'POST', '/miler/duty/start', body: {'lat': lat ?? 0, 'lon': lon ?? 0}, ), ); static Future endDuty() => _guarded('duty', () => _send('PUT', '/miler/duty/end')); static Future dutyCurrent() => _send('GET', '/miler/duty/current'); static Future startBreak(String breakType) => _guarded( 'break', () => _send( 'POST', '/miler/breaks/start', body: {'breaktype': breakType.isEmpty ? 'Personal' : breakType}, ), ); static Future endBreak() => _guarded('break', () => _send('PUT', '/miler/breaks/end')); // ═══════════════════════════════════════════════════════════════════════ // ASSIGNMENTS // ═══════════════════════════════════════════════════════════════════════ static Future assignments() => _send('GET', '/miler/assignments'); static Future assignment(Object id) => _send('GET', '/miler/assignments/$id'); /// Keyed on `bookingassignmentid`, **not** `bookingid` — they come from /// different sequences. Resolve through `AssignmentLookup` first or the /// backend answers 404 while the rider is shown success. static Future acceptAssignment(Object assignmentId) => _guarded( 'accept:$assignmentId', () => _send('POST', '/miler/assignments/$assignmentId/accept', body: {}), ); /// The deployed handler reads `reason` from the query string; the contract /// doc says the body. Sent both ways — this route has never had a real /// request against it, so neither source is confirmed. static Future rejectAssignment( Object assignmentId, { required String reason, }) => _guarded( 'reject:$assignmentId', () => _send( 'POST', '/miler/assignments/$assignmentId/reject', query: {'reason': reason}, body: {'reason': reason}, ), ); // ═══════════════════════════════════════════════════════════════════════ // THE PICKUP FLOW — in order, all keyed on bookingid // ═══════════════════════════════════════════════════════════════════════ /// Step 1. static Future reached( Object bookingId, { double? lat, double? lon, }) => _guarded( 'reached:$bookingId', () => _send( 'POST', '/miler/bookings/$bookingId/reached', body: { if (lat != null) 'latitude': lat, if (lon != null) 'longitude': lon, }, ), ); /// Step 1b — **the FROM and TO the rider confirms at the door.** /// /// A logistics booking often reaches the rider half-addressed: raised from a /// map pin, destination described as a phone number and a landmark. He is the /// first person who can stand in front of the sender and ask, and the answers /// decide the consignment's routing and its pricing zone. /// /// Every field is optional and only non-empty ones are applied server-side, /// so this is a correction and never a wipe. Must land **before** /// [pickupComplete], which builds the consignment from these values; the /// handler refuses it once the booking has been converted. static Future updateBookingAddresses( Object bookingId, { String? pickupAddress, String? pickupPincode, double? pickupLat, double? pickupLon, String? deliveryAddress, String? deliveryPincode, double? deliveryLat, double? deliveryLon, String? deliveryCity, }) => _send( 'PATCH', '/miler/bookings/$bookingId/addresses', body: { if (pickupAddress != null && pickupAddress.isNotEmpty) 'pickupaddress': pickupAddress, if (pickupPincode != null && pickupPincode.isNotEmpty) 'pickuppincode': pickupPincode, if (pickupLat != null && pickupLat != 0) 'pickuplatitude': pickupLat, if (pickupLon != null && pickupLon != 0) 'pickuplongitude': pickupLon, if (deliveryAddress != null && deliveryAddress.isNotEmpty) 'deliveryaddress': deliveryAddress, if (deliveryPincode != null && deliveryPincode.isNotEmpty) 'deliverypincode': deliveryPincode, if (deliveryLat != null && deliveryLat != 0) 'deliverylatitude': deliveryLat, if (deliveryLon != null && deliveryLon != 0) 'deliverylongitude': deliveryLon, if (deliveryCity != null && deliveryCity.isNotEmpty) 'deliverycity': deliveryCity, }, ); /// Step 2. The dimensions here are what `pickup-complete` recomputes /// chargeable weight from, so this is the one moment the parcel's measured /// size can be recorded at all. static Future submitParcels( Object bookingId, List parcels, ) => _guarded( 'parcel:$bookingId', () => _send( 'POST', '/miler/bookings/$bookingId/parcel', body: { 'parcels': [for (final p in parcels) p.toJson()], }, ), ); /// Step 3. `amount` must be > 0; `paymentmode` is one of [paymentModes]. static Future submitPayment( Object bookingId, { required double amount, required String paymentMode, String? transactionRef, }) => _guarded( 'payment:$bookingId', () => _send( 'POST', '/miler/bookings/$bookingId/payment', idempotencyKey: _idempotencyKey('payment', bookingId), body: { 'amount': amount, 'paymentmode': paymentMode, 'transactionref': transactionRef ?? '', }, ), ); static const List paymentModes = ['Cash', 'UPI', 'Card', 'Wallet']; /// Step 4, and the pivot of the whole flow: it converts the booking into a /// consignment, recomputes chargeable weight from the dimensions submitted in /// step 2, and decides routing — a shared 3-digit pincode prefix means /// hyperlocal and the consignment goes straight to `Out_for_Delivery` in this /// rider's hands, otherwise it routes via the hub. /// A key that identifies **one rider action**, so a retry after a dropped /// acknowledgement replays the first result instead of writing twice. /// /// Derived, not random: the same action retried must produce the *same* key /// or the replay never matches. `verb:resource:day` is stable across a /// retry, an app restart and a lost response, while still letting a genuine /// second attempt tomorrow through. /// /// This complements [MutationGuard] rather than replacing it — the guard /// stops a double *press* on this device, the key stops a double *write* on /// the server after the network lost the first answer. static String _idempotencyKey(String verb, Object resource) { final now = DateTime.now(); final day = '${now.year}${now.month.toString().padLeft(2, '0')}' '${now.day.toString().padLeft(2, '0')}'; return '$verb:$resource:$day'; } static Future pickupComplete( Object bookingId, { double? lat, double? lon, }) => _guarded( 'pickup-complete:$bookingId', () => _send( 'POST', '/miler/bookings/$bookingId/pickup-complete', idempotencyKey: _idempotencyKey('pickup-complete', bookingId), body: { if (lat != null) 'latitude': lat, if (lon != null) 'longitude': lon, }, ), ); // ═══════════════════════════════════════════════════════════════════════ // THE RELEASE — collected → out for delivery // ═══════════════════════════════════════════════════════════════════════ /// Moves a `Collected_By_Miler` consignment to `Out_for_Delivery`. /// /// ── This is the step the app spent months without ── /// /// `pickup-complete` used to push hyperlocal work straight to /// `Out_for_Delivery`, so the hub saw a rider "actively delivering" food /// still on the kitchen counter, and there was no server state for /// *collected, holding*. The app modelled the difference locally, which the /// hub could not see and a reinstall erased. /// /// Now `pickup-complete` lands on `Collected_By_Miler` and this call — the /// rider pressing **Start round** — makes the release. Two consequences /// worth knowing: /// /// • **The receiver OTP is issued here**, not at pickup, so a code is not /// in the customer's hands during the holding period. /// • **The rider's availability flips** `Picked_Up` → `On_Delivery`. /// /// Replaces the old `POST /miler/deliveries/start`, which never existed. static Future startDelivery(Object consignmentId) => _guarded( 'start-delivery:$consignmentId', () => _send( 'POST', '/miler/consignments/$consignmentId/start-delivery', idempotencyKey: _idempotencyKey('start-delivery', consignmentId), body: const {}, ), ); // ═══════════════════════════════════════════════════════════════════════ // THE HANDOVER — the rider gives a hub-routed parcel to a base // ═══════════════════════════════════════════════════════════════════════ /// Hands a `Created` consignment in at a base, ending this rider's part. /// /// ── The leg that had no route ── /// /// A parcel bound for another district is collected at a customer's door and /// carried to a base; the network takes it from there. The rider could do the /// first half and had no way to record the second, so an intercity parcel sat /// in his queue until somebody inwarded it in the console — and every one of /// those jobs reported zero distance and zero value on `/miler/earnings`, /// because the assignment was never closed against him. /// /// ── The body is optional, all of it ── /// /// Sent with nothing, the parcel is handed into the base it was already /// routed to. [hubId] is worth sending when the app has one — it is what the /// server reconciles against — and the coordinates are stamped onto the /// history row as evidence of where the hand-over happened. /// /// ── Idempotent twice over ── /// /// The shared `Idempotency-Key` middleware covers a retry after a dropped /// response, and a parcel already inwarded answers **200 with /// `already_inwarded: true`** rather than a 4xx — so a rider on bad signal at /// a loading bay who presses again is confirmed, not refused. Read the result /// through [MilerLifecycle.inwardAtHub], which treats that reply as the /// success it is. /// /// Refusals carry their own reason: `CONSIGNMENT_NOT_FOUND`, /// `CONSIGNMENT_NOT_ASSIGNED`, `HUB_REQUIRED`, `HUB_NOT_FOUND`, /// `INVALID_STATE` for a parcel already past this leg. static Future inwardAtHub( Object consignmentId, { Object? hubId, double? lat, double? lon, }) => _guarded( 'inward-at-hub:$consignmentId', () => _send( 'POST', '/miler/consignments/$consignmentId/inward-at-hub', idempotencyKey: _idempotencyKey('inward-at-hub', consignmentId), body: { if (hubId != null) 'hub_id': hubId, if (lat != null) 'latitude': lat, if (lon != null) 'longitude': lon, }, ), ); /// The bases this rider may hand a parcel in at. /// /// ── Why this is not the tenant locations route ── /// /// `GET /admin/tenants/:id/locations` is a **different dataset** — a client's /// own sites, not bases — and `/admin/*` requires roles 1/3/4 while a rider is /// role 5. The 401 this app has been logging as a gap on that route was by /// design, not an oversight. This is the rider-readable one. /// /// Returns `{id, name, address, pincode, latitude, longitude}` per base, plus /// `distance_km` and nearest-first ordering once the rider has reported a /// position. static Future bases({String status = 'Active'}) => _send( 'GET', '/miler/bases', query: {if (status.isNotEmpty) 'status': status}, ); /// The consignment's current state and what may be done to it. /// /// Returns `status` plus the backend's own derived flags — `collected`, /// `out_for_delivery`, `delivered`, `can_start_delivery`, `can_deliver`, /// `can_skip` — and the COD figures. Replaces walking /// `GET /miler/consignments/logs/:id` and reading the last event, which was /// a history replay standing in for a state read. static Future consignment(Object consignmentId) => _send('GET', '/miler/consignments/$consignmentId'); /// Query strings, not a body — the handler reads `c.Query`. static Future vehicleRequired( Object bookingId, { required String type, required String reason, }) => _send( 'POST', '/miler/bookings/$bookingId/vehicle-required', query: {'type': type, 'reason': reason}, ); /// Refused once the booking is picked up. static Future cancelBooking( Object bookingId, { required String reason, }) => _guarded( 'cancel:$bookingId', () => _send( 'POST', '/miler/bookings/$bookingId/cancel', body: {'reason': reason}, ), ); // ═══════════════════════════════════════════════════════════════════════ // PRICING — what this shipment costs, from the real numbers // ═══════════════════════════════════════════════════════════════════════ /// The fare band for a shipment, quoted at the door. /// /// `POST /api/v1/pricing/check` — note it is **not** under `/miler`, and it /// takes no auth. The zone is derived server-side from the two pincodes when /// [zone] is omitted, which is the path the rider app uses: he has just /// captured both addresses and has no business deciding what a zone is. /// /// Returns `{found, zone, service_type, weight, currency, results: [{category, /// category_label, min_price, max_price}]}` — a *band* per category, not a /// single number, because that is what the pricing table holds. When `found` /// is false the combination has no rule configured and the rider must be told /// that rather than shown a zero. See [ShipmentQuote]. static Future checkPrice({ required double weight, required String serviceType, String? zone, String? pickupPincode, String? deliveryPincode, String? category, }) => _send( 'POST', '/pricing/check', body: { 'weight': weight, 'service_type': serviceType, if (zone != null && zone.isNotEmpty) 'zone': zone, if (pickupPincode != null && pickupPincode.isNotEmpty) 'pickup_pincode': pickupPincode, if (deliveryPincode != null && deliveryPincode.isNotEmpty) 'delivery_pincode': deliveryPincode, if (category != null && category.isNotEmpty) 'category': category, }, ); /// The service tiers the pricing table is keyed on. `Express` — not /// `Fast`/`Superfast`, which is what the *booking* service options use. static const List serviceTypes = ['Normal', 'Express']; /// Parcel categories the pricing table recognises. static const List parcelCategories = [ 'General', 'Documents', 'Electronics', 'Clothing', 'Fragile', 'Medical', 'Automotive', 'Food', ]; // ═══════════════════════════════════════════════════════════════════════ // DELIVERY // ═══════════════════════════════════════════════════════════════════════ // ── `POST /miler/deliveries/start` is not on this API ── // // It was declared here and called after every pickup. It is absent from the // contract's 38 routes and from the backend's route table, so it 404'd every // time — and the app was built to believe a consignment only became // deliverable once it had "worked". // // It never needed to. `pickup-complete` decides routing itself: matching // 3-digit pickup and delivery pincode prefixes are hyperlocal and the // consignment goes straight to `Out_for_Delivery` in the rider's hands. There // is no rider-facing release step to make, so there is no endpoint to call. // // Do not add it back without a contract change. What the rider presses is a // *local* record of having set off — see `OrderEvent.outForDelivery`. /// The consignment must be `Out_for_Delivery` or this is a 400. /// /// `otp` is **optional**. There is no delivery-OTP column on consignments and /// nothing generates one, so the handler cannot check a code against /// anything; it records whether one was presented. Requiring it used to force /// any caller without an OTP — a milk-run drop, where a subscriber's lunch is /// handed over with no code — to invent a value, which is worse than /// recording that there was none. Do not start sending a placeholder. /// /// `lat`/`lon` must be the actual delivery point: the server computes /// `riderkms` from the pickup coords by haversine and writes it onto the /// earnings record with `ridercharges`. Passing the pickup coords here would /// silently zero the rider's distance for that leg. static Future deliver( Object consignmentId, { required String deliveredToName, String? otp, String? photoUrl, String? receiverSignatureUrl, double? lat, double? lon, }) => _guarded( 'deliver:$consignmentId', () => _send( 'POST', '/miler/consignments/$consignmentId/deliver', idempotencyKey: _idempotencyKey('deliver', consignmentId), body: { 'deliveredtoname': deliveredToName, if (otp != null && otp.isNotEmpty) 'otp': otp, 'photourl': photoUrl ?? '', 'receiversignatureurl': receiverSignatureUrl ?? '', if (lat != null) 'lat': lat, if (lon != null) 'lon': lon, }, ), ); /// ── Skipping a stop the rider has **not** collected yet ── /// /// `POST /miler/consignments/:id/skip` keys on a consignment, and a booking /// he has not picked up does not have one — so the pre-pickup case had no /// route at all and the app recorded it locally, where the hub could not see /// it. This is that route. /// /// It keeps the booking **assigned and resumable** (`resumable: true` comes /// back on success), which is the whole difference from /// [cancelBooking]: cancel gives the booking up and releases it for /// reassignment; this says *not now*. /// /// Which of the three to call, in one line each: /// /// ``` /// not collected yet → skipBooking (this) resumable /// already collected → skipConsignment attemptcount++ /// giving it up → cancelBooking released /// ``` static Future skipBooking( Object bookingId, { required String reason, double? lat, double? lon, }) => _guarded( 'skip-booking:$bookingId', () => _send( 'POST', '/miler/bookings/$bookingId/skip', idempotencyKey: _idempotencyKey('skip-booking', bookingId), body: { 'reason': reason, if (lat != null) 'lat': lat, if (lon != null) 'lon': lon, }, ), ); // ═══════════════════════════════════════════════════════════════════════ // PROOF UPLOAD — a signed URL, then a direct PUT // // The photograph a rider takes at a door had nowhere to go: `deliver` takes // `photourl`, which wants a URL, and nothing on the contract accepted an // upload — so proof lived on the phone and died with the next reinstall. // // Two steps, and the second does not touch this API at all: // // 1. POST /miler/uploads/sign → { uploadurl, url, method, headers } // 2. PUT the bytes to `uploadurl` with **exactly** those headers // 3. send `url` as `photourl` on deliver // // The object-store credentials stay server-side, which is the point of the // signed-URL shape: the app never holds a key. // ═══════════════════════════════════════════════════════════════════════ /// What a proof upload is for. The server picks the bucket path from this, /// so it is not free text. static const String proofDelivery = 'delivery_proof'; static const String proofPickup = 'pickup_proof'; static const String proofSignature = 'receiver_signature'; static const String proofSupport = 'support'; /// Step 1 — asks for somewhere to put an image. /// /// The signature expires in ten minutes, so a failed upload is re-*signed* /// rather than retried against the old URL. /// ── A pickup proof has no consignment, and must not pretend to ── /// /// The server builds the storage key as `{folder}/{tag}-{id}-…`, choosing the /// id from `consignmentid`, then `bookingid`, then the rider. A pickup photo /// is taken *before* `pickup-complete` mints anything, so there is no /// consignment to key it on — and this method only offered `consignmentid`. /// The one caller that needed it passed a **booking** id in that slot, so /// every pickup proof this app has ever uploaded was filed under a /// consignment number that does not exist, colliding with whatever real /// consignment later took it. /// /// [bookingId] is the field the server already has for this case. Send /// whichever one the stop actually has. static Future signUpload({ required String purpose, String contentType = 'image/jpeg', Object? consignmentId, Object? bookingId, }) => _send( 'POST', '/miler/uploads/sign', body: { 'purpose': purpose, 'contentType': contentType, if (consignmentId != null) 'consignmentid': consignmentId, if (bookingId != null) 'bookingid': bookingId, }, ); /// Steps 1–3 in one call: sign, PUT the bytes, hand back the public URL. /// /// Returns `null` when the photograph could not be uploaded, which is a /// **survivable** answer and never a reason to block a hand-over — the /// delivery is the thing that matters and `deliver` accepts an empty /// `photourl`. The caller records the delivery either way and keeps the /// local copy, so nothing is lost that was not already only local. /// /// ── Why the headers are sent back verbatim ── /// /// `x-amz-acl` is part of what was signed. Dropping it, or adding a header /// of our own, invalidates the signature and the store answers 403 — so the /// map returned by the sign call is used as-is rather than merged with /// anything this app thinks a request should carry. In particular the /// bearer token must **not** go to the object store. static Future uploadProof( File file, { required String purpose, Object? consignmentId, Object? bookingId, }) async => (await uploadProofRef( file, purpose: purpose, consignmentId: consignmentId, bookingId: bookingId, ))?.url; /// The same upload, returning **both** references the platform uses. /// /// ── Why the key matters, and why it was being thrown away ── /// /// `POST /miler/uploads/sign` answers with `uploadurl`, `url` **and `key`**. /// This method used to keep only `url` and discard the key, which was fine /// while the only consumer was `deliver` — that route takes a `photourl`. /// /// It is not fine for `POST /miler/bookings/:id/parcel`, whose `photos` field /// wants the **storage key**, not a URL. The server's own note says why: the /// customer is served a short-lived signed link *derived from* the key, never /// a permanent one. Sending a URL there would store a link that either /// expires or, worse, never does. /// /// So both come back and each caller takes the one its route is specified in. static Future uploadProofRef( File file, { required String purpose, Object? consignmentId, Object? bookingId, }) async { if (!file.existsSync()) return null; final contentType = file.path.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg'; final signed = await signUpload( purpose: purpose, contentType: contentType, consignmentId: consignmentId, bookingId: bookingId, ); if (!signed.ok) { debugPrint('[UPLOAD] sign failed: ${signed.status} ${signed.message}'); return null; } final data = signed.data; final map = data is Map ? Map.from(data) : null; final uploadUrl = (map?['uploadurl'] ?? map?['uploadUrl'] ?? '') .toString() .trim(); final publicUrl = (map?['url'] ?? '').toString().trim(); final objectKey = (map?['key'] ?? '').toString().trim(); if (uploadUrl.isEmpty || publicUrl.isEmpty) { debugPrint('[UPLOAD] sign returned no url pair'); return null; } final headers = {}; final raw = map?['headers']; if (raw is Map) { raw.forEach((k, v) => headers['$k'] = '$v'); } // The store needs a content type even if the signer did not name one. headers.putIfAbsent('Content-Type', () => contentType); try { final res = await http .put( Uri.parse(uploadUrl), headers: headers, body: await file.readAsBytes(), ) .timeout(const Duration(seconds: 30)); if (res.statusCode >= 200 && res.statusCode < 300) { return UploadRef(url: publicUrl, key: objectKey); } debugPrint('[UPLOAD] PUT ${res.statusCode} — ${res.body}'); } catch (e) { debugPrint('[UPLOAD] PUT failed: $e'); } return null; } /// Bumps `attemptcount` rather than failing the consignment — a skip is a /// return visit, not an outcome. static Future skipConsignment( Object consignmentId, { required String reason, double? lat, double? lon, }) => _guarded( 'skip:$consignmentId', () => _send( 'POST', '/miler/consignments/$consignmentId/skip', idempotencyKey: _idempotencyKey('skip', consignmentId), body: { 'reason': reason, if (lat != null) 'lat': lat, if (lon != null) 'lon': lon, }, ), ); // ═══════════════════════════════════════════════════════════════════════ // BOOKINGS & EARNINGS // ═══════════════════════════════════════════════════════════════════════ static Future bookings({String? status, String? date}) => _send( 'GET', '/miler/bookings', query: { if (status != null && status.isNotEmpty) 'status': status, if (date != null && date.isNotEmpty) 'date': date, }, ); /// `bonuspoints` on this response is always zero — nothing writes it yet. static Future earnings({String period = 'daily', String? date}) => _send( 'GET', '/miler/earnings', query: { 'period': period, if (date != null && date.isNotEmpty) 'date': date, }, ); // ═══════════════════════════════════════════════════════════════════════ // TELEMETRY — Redis-backed, high frequency // // Redis is never the system of record here: a flush loses telemetry, not // business state. So every call in this section is best-effort and must // never block the rider. // ═══════════════════════════════════════════════════════════════════════ /// One `MilerLog`. Every numeric field goes as a **string** — sending real /// numbers fails to parse server-side. /// /// Deliberately takes no `userid`: identity comes from the token, and a body /// `userid` is how one rider's GPS could once be written under another's. static Future postLog({ required double latitude, required double longitude, String? status, Object? orderId, double? speed, double? heading, double? accuracy, int? battery, bool isCharging = false, String? connection, String? locationService, bool isBackground = false, DateTime? at, }) => _send( 'POST', '/miler/logs', body: { 'logdate': logStamp(at), 'latitude': _str(latitude), 'longitude': _str(longitude), if (speed != null) 'speed': _str(speed), if (heading != null) 'heading': _str(heading), if (accuracy != null) 'accuracy': _str(accuracy), if (status != null && status.isNotEmpty) 'status': status, // The device's own stop key can carry a `#seq` destination suffix (see // ApiConfig's adapter); the server has never seen one and this is a // breadcrumb tag, not a join key. Send the booking's half. if (orderId != null) 'orderid': _str(orderId).split('#').first, if (battery != null) 'battery': _str(battery), 'is_charging': isCharging, if (connection != null) 'connection': connection, if (locationService != null) 'location_service': locationService, 'is_background': isBackground, }, ); static Future getLogs() => _send('GET', '/miler/logs'); static Future postStatus(String status) => _send('POST', '/miler/status', body: {'status': status}); static Future getStatus() => _send('GET', '/miler/status'); /// The pickup locations a tenant runs — the counters, branches or kitchens /// the rider collects from. /// /// `GET /admin/tenants/:tenantid/locations`. **Note the path is under /// `/admin`, not `/miler`**, which is the one thing worth knowing about it: /// every other route this class calls is on the rider surface, and whether a /// rider's bearer token is accepted here is the backend's decision, not /// ours. A 401 or 403 is therefore an ordinary outcome and not a bug — /// [PickupLocations] treats it as "no names available" and the app carries /// on with whatever the booking row said. static Future tenantLocations(Object tenantId) => _send('GET', '/admin/tenants/$tenantId/locations'); /// A JSON **array**, even for one entry — the handler decodes a list. static Future postConsignmentLogs( List entries, ) => _send( 'POST', '/miler/consignments/logs', body: [for (final e in entries) e.toJson()], ); static Future consignmentLogs(Object consignmentId) => _send('GET', '/miler/consignments/logs/$consignmentId'); /// Must be your own userid; anyone else's is rejected. static Future userLogs(Object userId) => _send('GET', '/miler/consignments/userlogs/$userId'); // ═══════════════════════════════════════════════════════════════════════ // NOTIFICATIONS & SUPPORT // ═══════════════════════════════════════════════════════════════════════ /// Synthesized fresh from `BookingAssignment` rows on every call, and `id` is /// just the array index — so an id is only valid until the next GET. static Future notifications() => _send('GET', '/miler/notifications'); /// **A stub.** Returns success without persisting anything, because there is /// no notifications table with read state. Read state cannot survive a /// refresh until that table exists, so nothing may depend on it. static Future markNotificationRead(Object id) => _send('PATCH', '/miler/notifications/$id/read'); static Future createSupportTicket({ required String subject, required String description, }) => _send( 'POST', '/miler/support', body: {'subject': subject, 'description': description}, ); static Future supportTickets() => _send('GET', '/miler/support'); // ═══════════════════════════════════════════════════════════════════════ /// What the rider app needs and the backend does not yet expose. Kept here /// rather than scattered as `logGap` calls so there is one list to hand the /// backend team. See the write-up in ABOUT_MILER.md §8. static const List missingFromBackend = [ // ── Delivered since this list was written, and removed from it ── // // Four of the seven entries here were shipped by the backend and the app // now reads them, so they are gone rather than kept as history: // // per-stop `stoptype` + `step` on GET /miler/bookings → read by // [ApiConfig.adaptBooking]; the mixed route is reachable // `codamount` / `paymentmode` on the booking → read // a booking-level skip → [skipBooking] // `cancelled_stops` / `total_stops` on earnings → read // // What is left is what is still genuinely absent. 'a real notifications table with read state', 'anything that writes bonuspoints', // The quoted fare has nowhere to be recorded: `POST /bookings/:id/payment` // stores what was COLLECTED, and `bookingserviceoptions.estimatedprice` is // written when the customer books and is not rider-writable. So a rider who // re-prices a shipment at the door leaves the original estimate on the // booking and only the collected amount reflects the new figure. 'a rider-writable quoted price on the booking (only the collected amount lands)', // A delivery-OTP column on consignments. Until one exists, `deliver` // records whether a code was presented, not whether it was correct. 'a real delivery OTP on consignments (deliver cannot verify a code today)', // Proof of delivery is a local file path today: `photourl` wants a URL and // nothing in the contract accepts an upload, so the photograph the rider // takes never leaves his phone. See [ProofStore]. 'an upload route for the proof-of-delivery photograph', // ── Answered 24 Aug 2026, and off this list ── // // the route sequence → automatic on every assignment; `sequencedat` // is the authority signal and `step` stays on the row through the // pickup leg. `step: 0` with a null stamp now means one of three // stated things, none of which is a route. See [RouteOrder]. // a trip / slot id → there is none, and none is planned. The // day-part split is client behaviour by agreement. See [TripSlots]. // an upload route → shipped: `POST /miler/uploads/sign`, and the // proof photo reaches the hub. See [uploadProof]. // // What is left is what is still genuinely absent. // A rider payout rate. `ridercharges` holds the *client's* order price, // not the rider's pay, and `bonuspoints` is unused — confirmed by the // backend, with a real rate-card named as a separate build. Nothing in // this app may present either figure as what a rider earned. 'a rider payout rate — ridercharges is the client price, not rider pay', ]; } /// One parcel measured at the door, for `POST /miler/bookings/:id/parcel`. /// /// Dimensions are centimetres and weight is kilograms. They are what /// `pickup-complete` recomputes chargeable weight from, so a parcel submitted /// without them bills on the booked figure rather than the real one. /// Where an uploaded image lives, in the two forms the platform uses. /// /// `deliver` takes [url] as `photourl`; `parcel` takes [key] as one entry of /// `photos`. Neither is a substitute for the other — see [MilerApi.uploadProofRef]. @immutable class UploadRef { const UploadRef({required this.url, required this.key}); /// The object's public URL. final String url; /// The object's storage key — `{folder}/{tag}-{id}-{date}-{time}-{rand}.{ext}`. /// /// May be empty if an older server build does not return one; a caller that /// needs it must treat empty as "no photo" rather than sending a blank entry. final String key; bool get hasKey => key.isNotEmpty; } class ParcelEntry { final Object? parcelId; final double weight; final double length; final double width; final double height; /// Storage keys of the photographs taken of this parcel at the door. /// /// ── The field the app never sent ── /// /// `POST /miler/bookings/:id/parcel` has accepted `photos` for as long as the /// route has existed, and the server's own note says why it matters: *"Weight /// without a photograph is a number the customer has no way to check, and /// this is the only point in the flow where anyone is standing next to the /// parcel."* /// /// Meanwhile the rider app **compels** the photograph — `StopVerificationPage` /// will not let him confirm without one — uploaded it to object storage, and /// then dropped the reference on the floor. The evidence existed in a bucket /// nobody could search, for every CX pickup this app has ever done. /// /// **Keys, not URLs.** The customer is served a short-lived signed link /// derived from the key; a URL stored here either expires or never does. /// See [MilerApi.uploadProofRef]. final List photos; const ParcelEntry({ this.parcelId, required this.weight, this.length = 0, this.width = 0, this.height = 0, this.photos = const [], }); Map toJson() => { if (parcelId != null) 'parcel_id': parcelId, 'weight': weight, 'length': length, 'width': width, 'height': height, // Omitted entirely when there is none, rather than sent as `[]`: an empty // array is a claim that no photograph was taken, and a failed upload is // not that claim. if (photos.isNotEmpty) 'photos': photos, }; } /// What the pricing table says this shipment costs. /// /// ── Why a band and not a number ── /// /// `POST /pricing/check` answers with a min/max per category, because that is /// what the table holds — a rule is a weight slab in a zone, and it prices a /// range. The rider has to quote one figure to a customer, so [payable] takes /// the **minimum**: it is the number the customer was shown when the booking /// was raised, and quoting the top of a band at the door is how a rider ends up /// arguing about money on a doorstep. /// /// [found] false means no rule covers this weight/zone/category combination. /// That is not a zero — it is "this app cannot price this", and the rider must /// be told so rather than shown a free shipment. Nothing here invents a fare. @immutable class ShipmentQuote { /// True when the table had a rule for this combination. final bool found; /// `Local` / `Regional` / `National`, as resolved from the two pincodes. final String zone; final String serviceType; final double weight; final String currency; final String category; final double minPrice; final double maxPrice; /// The server's own words when it could not price this. final String message; const ShipmentQuote({ required this.found, this.zone = '', this.serviceType = '', this.weight = 0, this.currency = 'INR', this.category = '', this.minPrice = 0, this.maxPrice = 0, this.message = '', }); /// The figure quoted to the customer and taken at the door. double get payable => minPrice; /// True when the band is wide enough that the two ends are different money. bool get isRange => maxPrice > minPrice; static double _num(dynamic v) { if (v is num) return v.toDouble(); return double.tryParse(v?.toString() ?? '') ?? 0; } /// Reads the `data` block of a `/pricing/check` response. /// /// [preferredCategory] picks a row out of `results` when the rider named a /// category; without one the cheapest row wins, for the same reason [payable] /// takes the minimum. factory ShipmentQuote.fromData( Map data, { String preferredCategory = '', }) { final found = data['found'] == true; final results = (data['results'] is List) ? (data['results'] as List).whereType().toList() : const []; if (!found || results.isEmpty) { return ShipmentQuote( found: false, zone: (data['zone'] ?? '').toString(), serviceType: (data['service_type'] ?? '').toString(), weight: _num(data['weight']), message: (data['message'] ?? 'no price configured for this shipment') .toString(), ); } Map? chosen; if (preferredCategory.isNotEmpty) { for (final r in results) { if ((r['category'] ?? '').toString().toLowerCase() == preferredCategory.toLowerCase()) { chosen = r; break; } } } chosen ??= results.reduce( (a, b) => _num(a['min_price']) <= _num(b['min_price']) ? a : b, ); return ShipmentQuote( found: true, zone: (data['zone'] ?? '').toString(), serviceType: (data['service_type'] ?? '').toString(), weight: _num(data['weight']), currency: (data['currency'] ?? 'INR').toString(), category: (chosen['category_label'] ?? chosen['category'] ?? '') .toString(), minPrice: _num(chosen['min_price']), maxPrice: _num(chosen['max_price']), ); } } /// One `ConsignmentLog`. Numeric fields are strings, as with [MilerApi.postLog]. class ConsignmentLogEntry { final Object consignmentId; final double latitude; final double longitude; final String? status; final double? speed; final double? heading; final int? battery; final String remarks; final bool isBackground; final DateTime? at; const ConsignmentLogEntry({ required this.consignmentId, required this.latitude, required this.longitude, this.status, this.speed, this.heading, this.battery, this.remarks = '', this.isBackground = false, this.at, }); Map toJson() => { 'consignmentid': consignmentId, 'logdate': MilerApi.logStamp(at), 'latitude': latitude.toString(), 'longitude': longitude.toString(), if (speed != null) 'speed': speed.toString(), if (heading != null) 'heading': heading.toString(), if (status != null) 'status': status, 'remarks': remarks, if (battery != null) 'battery': battery.toString(), 'is_background': isBackground, }; } /// The result of one call. /// /// [ok] folds two things the transport keeps apart: a 2xx status *and* an /// envelope that did not say `success: false`. A 200 carrying `success: false` /// is a failure, and treating it as one at the boundary is what stops it being /// re-checked, inconsistently, at every call site. class ApiResult { final bool ok; final int status; /// The backend's stable machine-readable failure code — `INVALID_STATE`, /// `OTP_REQUIRED`, `CONSIGNMENT_NOT_ASSIGNED`, `IDEMPOTENCY_IN_PROGRESS`… /// /// Added to the `/miler/*` 4xx envelope on 21 Aug 2026. **Branch on this, /// never on [message]** — string-matching a human sentence is how the app /// came to tell riders "the hub hasn't released this" for a consignment that /// had already been delivered. Empty on success and on any route that does /// not carry one yet. final String code; /// The envelope's `data`, or the whole body when there is no `data` key. final dynamic data; /// The undecorated decoded body — needed for `verify-pin`, whose payload sits /// under `user` rather than `data`. final dynamic raw; final String message; const ApiResult({ required this.ok, required this.status, this.data, this.raw, this.message = '', this.code = '', }); /// True when the backend refused because the entity is in the wrong state. bool get isInvalidState => code == 'INVALID_STATE'; /// True when an identical request is still running server-side — retry /// shortly rather than treating it as a failure. bool get isInFlight => code == 'IDEMPOTENCY_IN_PROGRESS'; /// [data] as a map, or an empty one. Map get map => data is Map ? Map.from(data as Map) : {}; /// [data] as a list, tolerating the envelope shapes seen in the wild: /// `[…]`, `{data: […]}`, `{data: {items: […]}}`, `{bookings: […]}`. List get list { dynamic d = data; if (d is List) return d; if (d is Map) { for (final k in const ['items', 'bookings', 'data', 'details', 'logs']) { if (d[k] is List) return d[k] as List; } } return const []; } /// True when the failure is an auth failure — the session is gone and the /// rider has to sign in again, which is a different recovery from a retry. bool get isUnauthorized => status == 401 || status == 403; }