Files
doormile_milderapp/lib/data/miler_api.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

1711 lines
70 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<ApiResult> _guarded(
String key,
Future<ApiResult> 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<ApiResult> _send(
String method,
String path, {
Object? body,
Map<String, String>? 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 = <String, String>{
...(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<void>.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<ApiResult> 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<ApiResult> 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<ApiResult> 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<ApiResult> 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<ApiResult> 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<ApiResult> 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<ApiResult> 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<ApiResult> 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<String> 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<ApiResult> startDuty({double? lat, double? lon}) => _guarded(
'duty',
() => _send(
'POST',
'/miler/duty/start',
body: {'lat': lat ?? 0, 'lon': lon ?? 0},
),
);
static Future<ApiResult> endDuty() =>
_guarded('duty', () => _send('PUT', '/miler/duty/end'));
static Future<ApiResult> dutyCurrent() => _send('GET', '/miler/duty/current');
static Future<ApiResult> startBreak(String breakType) => _guarded(
'break',
() => _send(
'POST',
'/miler/breaks/start',
body: {'breaktype': breakType.isEmpty ? 'Personal' : breakType},
),
);
static Future<ApiResult> endBreak() =>
_guarded('break', () => _send('PUT', '/miler/breaks/end'));
// ═══════════════════════════════════════════════════════════════════════
// ASSIGNMENTS
// ═══════════════════════════════════════════════════════════════════════
static Future<ApiResult> assignments() => _send('GET', '/miler/assignments');
static Future<ApiResult> 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<ApiResult> 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<ApiResult> 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<ApiResult> 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<ApiResult> 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<ApiResult> submitParcels(
Object bookingId,
List<ParcelEntry> 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<ApiResult> 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<String> 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<ApiResult> 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<ApiResult> 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<ApiResult> 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<ApiResult> 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<ApiResult> consignment(Object consignmentId) =>
_send('GET', '/miler/consignments/$consignmentId');
/// Query strings, not a body — the handler reads `c.Query`.
static Future<ApiResult> 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<ApiResult> 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<ApiResult> 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<String> serviceTypes = ['Normal', 'Express'];
/// Parcel categories the pricing table recognises.
static const List<String> 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<ApiResult> 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<ApiResult> 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<ApiResult> 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<String?> 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<UploadRef?> 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<String, dynamic>.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 = <String, String>{};
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<ApiResult> 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<ApiResult> 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<ApiResult> 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<ApiResult> 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<ApiResult> getLogs() => _send('GET', '/miler/logs');
static Future<ApiResult> postStatus(String status) =>
_send('POST', '/miler/status', body: {'status': status});
static Future<ApiResult> 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<ApiResult> tenantLocations(Object tenantId) =>
_send('GET', '/admin/tenants/$tenantId/locations');
/// A JSON **array**, even for one entry — the handler decodes a list.
static Future<ApiResult> postConsignmentLogs(
List<ConsignmentLogEntry> entries,
) => _send(
'POST',
'/miler/consignments/logs',
body: [for (final e in entries) e.toJson()],
);
static Future<ApiResult> consignmentLogs(Object consignmentId) =>
_send('GET', '/miler/consignments/logs/$consignmentId');
/// Must be your own userid; anyone else's is rejected.
static Future<ApiResult> 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<ApiResult> 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<ApiResult> markNotificationRead(Object id) =>
_send('PATCH', '/miler/notifications/$id/read');
static Future<ApiResult> createSupportTicket({
required String subject,
required String description,
}) => _send(
'POST',
'/miler/support',
body: {'subject': subject, 'description': description},
);
static Future<ApiResult> 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<String> 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<String> photos;
const ParcelEntry({
this.parcelId,
required this.weight,
this.length = 0,
this.width = 0,
this.height = 0,
this.photos = const <String>[],
});
Map<String, dynamic> 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<String, dynamic> data, {
String preferredCategory = '',
}) {
final found = data['found'] == true;
final results = (data['results'] is List)
? (data['results'] as List).whereType<Map>().toList()
: const <Map>[];
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<String, dynamic> 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<String, dynamic> get map => data is Map
? Map<String, dynamic>.from(data as Map)
: <String, dynamic>{};
/// [data] as a list, tolerating the envelope shapes seen in the wild:
/// `[…]`, `{data: […]}`, `{data: {items: […]}}`, `{bookings: […]}`.
List<dynamic> 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;
}