1445 lines
58 KiB
Dart
1445 lines
58 KiB
Dart
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`, 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');
|
||
|
||
/// ── 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 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.
|
||
static Future<ApiResult> signUpload({
|
||
required String purpose,
|
||
String contentType = 'image/jpeg',
|
||
Object? consignmentId,
|
||
}) => _send(
|
||
'POST',
|
||
'/miler/uploads/sign',
|
||
body: {
|
||
'purpose': purpose,
|
||
'contentType': contentType,
|
||
if (consignmentId != null) 'consignmentid': consignmentId,
|
||
},
|
||
);
|
||
|
||
/// 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,
|
||
}) 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,
|
||
);
|
||
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();
|
||
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 publicUrl;
|
||
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,
|
||
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');
|
||
|
||
/// 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.
|
||
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;
|
||
}
|