Files
doormile_milderapp/lib/data/miler_api.dart
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 05:40:35 +05:30

1238 lines
49 KiB
Dart

import 'dart:convert';
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`, IST wall-clock, for telemetry `logdate`.
static String logStamp([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)} '
'${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 (3 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.
static Future<ApiResult> login(String phone) => _send(
'POST',
'/miler/login',
auth: false,
body: {
'phone': phone,
'configid': configId,
if (hasTenantId) 'tenantid': tenantId,
},
);
/// 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');
static Future<ApiResult> updateProfile({
String? displayName,
String? profilePhotoUrl,
String? defaultVehicleType,
String? phone,
}) => _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,
},
);
/// 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 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,
},
),
);
/// 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,
if (orderId != null) 'orderid': _str(orderId),
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');
/// 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 = [
'per-stop type (pickup | delivery) and step ordering on GET /miler/bookings',
'COD amount on the booking object',
'a booking-level resume after skip (skip lives on the consignment only)',
'a real notifications table with read state',
'anything that writes bonuspoints',
'cancelled / total counts on GET /miler/earnings',
// 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)',
];
}
/// 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.
class ParcelEntry {
final Object? parcelId;
final double weight;
final double length;
final double width;
final double height;
const ParcelEntry({
this.parcelId,
required this.weight,
this.length = 0,
this.width = 0,
this.height = 0,
});
Map<String, dynamic> toJson() => {
if (parcelId != null) 'parcel_id': parcelId,
'weight': weight,
'length': length,
'width': width,
'height': height,
};
}
/// 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;
}