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>
1238 lines
49 KiB
Dart
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;
|
|
}
|