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 _guarded( String key, Future Function() send, ) async => await MutationGuard.run(key, send) ?? const ApiResult( ok: false, status: 0, message: 'That is already going through — give it a moment.', ); /// Called once when an authenticated call is refused by the server. /// /// The transport does the irreversible half — dropping the dead token — and /// this hands the rest to whoever owns navigation, so this file never has to /// know what a screen is. Set by the app shell; null in tests unless one is /// asserting on it. static void Function()? onUnauthorized; /// Telemetry wants strings for values that are numbers everywhere else. static String _str(dynamic v) => v == null ? '' : v.toString(); /// `YYYY-MM-DD HH:MM:SS`, 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 _send( String method, String path, { Object? body, Map? query, bool auth = true, /// Sent as `Idempotency-Key`. A retry with the same key replays the first /// response instead of writing twice — see [_idempotencyKey]. String? idempotencyKey, /// How many times an `IDEMPOTENCY_IN_PROGRESS` answer has already been /// waited out on this call. Internal — see [_inFlightRetries]. int inFlightAttempt = 0, }) async { // ── Nothing leaves the device while the mock is on ── // // Intercepted here rather than per call site, because this is the one door // every `/miler/*` route goes through — and a mock that covers 38 routes by // covering one function cannot drift out of step with the ones it missed. // The canned body is handed to exactly the same envelope handling as a real // 200, so the app cannot tell the difference and neither can this file. if (MockBackend.enabled) { final canned = MockBackend.respond( method, path, body: body, query: query, ); if (canned != null) { return ApiResult( ok: true, status: 200, data: canned['data'] ?? canned, raw: canned, message: _str(canned['message']), ); } } final uri = Uri.parse( ApiConfig.url(path), ).replace(queryParameters: (query == null || query.isEmpty) ? null : query); final headers = { ...(auth ? await ApiConfig.authHeaders() : const { 'Content-Type': 'application/json', 'Accept': 'application/json', }), if (idempotencyKey != null && idempotencyKey.isNotEmpty) 'Idempotency-Key': idempotencyKey, }; final encoded = body == null ? null : json.encode(body); try { late http.Response res; switch (method) { case 'GET': res = await client.get(uri, headers: headers).timeout(_timeout); case 'POST': res = await client .post(uri, headers: headers, body: encoded) .timeout(_timeout); case 'PUT': res = await client .put(uri, headers: headers, body: encoded) .timeout(_timeout); case 'PATCH': res = await client .patch(uri, headers: headers, body: encoded) .timeout(_timeout); default: throw ArgumentError('Unsupported method $method'); } dynamic decoded; if (res.body.isNotEmpty) { try { decoded = json.decode(res.body); } catch (_) { decoded = res.body; } } final ok = res.statusCode >= 200 && res.statusCode < 300; // The envelope is `{success, data}` / `{success, message}`. A 2xx with // `success: false` is still a failure — the status line alone is not the // answer. final envelopeOk = decoded is Map ? decoded['success'] != false : true; final result = ApiResult( ok: ok && envelopeOk, status: res.statusCode, data: decoded is Map ? (decoded['data'] ?? decoded) : decoded, raw: decoded, message: decoded is Map ? _str(decoded['message']) : '', code: decoded is Map ? _str(decoded['code']) : '', ); // ── The duplicate that is not a failure ── // // `IDEMPOTENCY_IN_PROGRESS` means the server is *at this moment* running // the very request being retried — the first attempt's response was // lost, not its effect. Surfacing it would tell a rider his delivery // failed while it is in the act of succeeding, and a fresh press would // meet the same answer. // // It is also not a reason to declare success: the write in flight can // still fail. So the call waits and asks again, and the idempotent // replay hands back the first attempt's real outcome. Two short waits, // then whatever the server says is passed through as-is. if (!result.ok && result.isInFlight && inFlightAttempt < _inFlightRetries) { debugPrint('[API] $method $path -> in flight, waiting for the answer'); await Future.delayed(_inFlightBackoff * (inFlightAttempt + 1)); return _send( method, path, body: body, query: query, auth: auth, idempotencyKey: idempotencyKey, inFlightAttempt: inFlightAttempt + 1, ); } if (!result.ok) { debugPrint( '[API] $method $path -> ${res.statusCode} ' '${result.code.isEmpty ? '' : '[${result.code}] '}${result.message} ' '${res.body.length > 300 ? '${res.body.substring(0, 300)}…' : res.body}', ); // ── One place decides that a session is over ── // // A 401 used to surface as an ordinary failed call, so every screen // handled it — or, mostly, did not: the rider stayed visually signed in // with a dead token, every list refreshed to empty, and nothing said // why. Handled here because this is the one door all 38 routes pass // through, and because the decision is the same wherever it happens. // // The token is dropped so nothing retries with it, and [onUnauthorized] // lets the shell take the rider back to sign-in. Auth's own two routes // are exempt: a wrong PIN is a 401 about a credential, not an expired // session, and clearing state there would be a logout in response to a // typo. if (res.statusCode == 401 && auth) { await ApiConfig.clearToken(); onUnauthorized?.call(); } } return result; } catch (e) { debugPrint('[API] $method $path -> transport error: $e'); return ApiResult(ok: false, status: 0, message: '$e'); } } // ═══════════════════════════════════════════════════════════════════════ // AUTH (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 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 verifyPin({ required String phone, required String pin, String? deviceToken, }) async { final res = await _send( 'POST', '/miler/verify-pin', auth: false, body: { 'phone': phone, 'pin': pin, 'configid': configId, if (hasTenantId) 'tenantid': tenantId, if (deviceToken != null && deviceToken.isNotEmpty) 'device_token': deviceToken, }, ); if (res.ok && res.raw is Map) { final token = _str((res.raw as Map)['token']); if (token.isNotEmpty) await ApiConfig.setToken(token); } return res; } // ═══════════════════════════════════════════════════════════════════════ // PROFILE & DEVICE // ═══════════════════════════════════════════════════════════════════════ static Future getProfile() => _send('GET', '/miler/profile'); static Future 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 setDeviceToken(String token) => _send('PUT', '/miler/device-token', body: {'device_token': token}); // ═══════════════════════════════════════════════════════════════════════ // LOCATION & AVAILABILITY // ═══════════════════════════════════════════════════════════════════════ /// Redis only — a SET plus a GEOADD into `milers:locations`, which is the /// index dispatch searches. Numbers here, not strings; this is not telemetry. static Future pushLocation({ required double latitude, required double longitude, String? pincode, double? speed, double? heading, }) => _send( 'PUT', '/miler/location', body: { 'latitude': latitude, 'longitude': longitude, if (pincode != null && pincode.isNotEmpty) 'pincode': pincode, if (speed != null) 'speed': speed, if (heading != null) 'heading': heading, }, ); /// One of [availabilityStatuses]. Sent under both keys the backend has /// accepted at different times, so neither a doc nor a handler change can /// silently drop it. static Future setAvailability(String status) => _guarded( 'availability', () => _send( 'PUT', '/miler/availability', body: {'status': status, 'availabilitystatus': status}, ), ); /// Note `Break`, not `On_Break` — the obvious guess is the wrong one. static const List availabilityStatuses = [ 'Offline', 'Available', 'Assigned', 'On_Pickup', 'At_Customer', 'Picked_Up', 'On_Delivery', 'Break', 'Blocked', ]; // ═══════════════════════════════════════════════════════════════════════ // DUTY & BREAKS // // Ordering is enforced server-side: starting duty twice is an error, and a // break without duty is an error. Callers should treat those as state to // reconcile (re-read `dutyCurrent`), not as failures to retry. // ═══════════════════════════════════════════════════════════════════════ // Guarded on one key for both directions: starting duty twice is a // server-side error, and so is ending it twice — and a rider who taps the // switch again because the first tap "did nothing" is the commonest way to // produce either. static Future startDuty({double? lat, double? lon}) => _guarded( 'duty', () => _send( 'POST', '/miler/duty/start', body: {'lat': lat ?? 0, 'lon': lon ?? 0}, ), ); static Future endDuty() => _guarded('duty', () => _send('PUT', '/miler/duty/end')); static Future dutyCurrent() => _send('GET', '/miler/duty/current'); static Future startBreak(String breakType) => _guarded( 'break', () => _send( 'POST', '/miler/breaks/start', body: {'breaktype': breakType.isEmpty ? 'Personal' : breakType}, ), ); static Future endBreak() => _guarded('break', () => _send('PUT', '/miler/breaks/end')); // ═══════════════════════════════════════════════════════════════════════ // ASSIGNMENTS // ═══════════════════════════════════════════════════════════════════════ static Future assignments() => _send('GET', '/miler/assignments'); static Future assignment(Object id) => _send('GET', '/miler/assignments/$id'); /// Keyed on `bookingassignmentid`, **not** `bookingid` — they come from /// different sequences. Resolve through `AssignmentLookup` first or the /// backend answers 404 while the rider is shown success. static Future acceptAssignment(Object assignmentId) => _guarded( 'accept:$assignmentId', () => _send('POST', '/miler/assignments/$assignmentId/accept', body: {}), ); /// The deployed handler reads `reason` from the query string; the contract /// doc says the body. Sent both ways — this route has never had a real /// request against it, so neither source is confirmed. static Future rejectAssignment( Object assignmentId, { required String reason, }) => _guarded( 'reject:$assignmentId', () => _send( 'POST', '/miler/assignments/$assignmentId/reject', query: {'reason': reason}, body: {'reason': reason}, ), ); // ═══════════════════════════════════════════════════════════════════════ // THE PICKUP FLOW — in order, all keyed on bookingid // ═══════════════════════════════════════════════════════════════════════ /// Step 1. static Future reached( Object bookingId, { double? lat, double? lon, }) => _guarded( 'reached:$bookingId', () => _send( 'POST', '/miler/bookings/$bookingId/reached', body: { if (lat != null) 'latitude': lat, if (lon != null) 'longitude': lon, }, ), ); /// Step 1b — **the FROM and TO the rider confirms at the door.** /// /// A logistics booking often reaches the rider half-addressed: raised from a /// map pin, destination described as a phone number and a landmark. He is the /// first person who can stand in front of the sender and ask, and the answers /// decide the consignment's routing and its pricing zone. /// /// Every field is optional and only non-empty ones are applied server-side, /// so this is a correction and never a wipe. Must land **before** /// [pickupComplete], which builds the consignment from these values; the /// handler refuses it once the booking has been converted. static Future updateBookingAddresses( Object bookingId, { String? pickupAddress, String? pickupPincode, double? pickupLat, double? pickupLon, String? deliveryAddress, String? deliveryPincode, double? deliveryLat, double? deliveryLon, String? deliveryCity, }) => _send( 'PATCH', '/miler/bookings/$bookingId/addresses', body: { if (pickupAddress != null && pickupAddress.isNotEmpty) 'pickupaddress': pickupAddress, if (pickupPincode != null && pickupPincode.isNotEmpty) 'pickuppincode': pickupPincode, if (pickupLat != null && pickupLat != 0) 'pickuplatitude': pickupLat, if (pickupLon != null && pickupLon != 0) 'pickuplongitude': pickupLon, if (deliveryAddress != null && deliveryAddress.isNotEmpty) 'deliveryaddress': deliveryAddress, if (deliveryPincode != null && deliveryPincode.isNotEmpty) 'deliverypincode': deliveryPincode, if (deliveryLat != null && deliveryLat != 0) 'deliverylatitude': deliveryLat, if (deliveryLon != null && deliveryLon != 0) 'deliverylongitude': deliveryLon, if (deliveryCity != null && deliveryCity.isNotEmpty) 'deliverycity': deliveryCity, }, ); /// Step 2. The dimensions here are what `pickup-complete` recomputes /// chargeable weight from, so this is the one moment the parcel's measured /// size can be recorded at all. static Future submitParcels( Object bookingId, List parcels, ) => _guarded( 'parcel:$bookingId', () => _send( 'POST', '/miler/bookings/$bookingId/parcel', body: { 'parcels': [for (final p in parcels) p.toJson()], }, ), ); /// Step 3. `amount` must be > 0; `paymentmode` is one of [paymentModes]. static Future submitPayment( Object bookingId, { required double amount, required String paymentMode, String? transactionRef, }) => _guarded( 'payment:$bookingId', () => _send( 'POST', '/miler/bookings/$bookingId/payment', idempotencyKey: _idempotencyKey('payment', bookingId), body: { 'amount': amount, 'paymentmode': paymentMode, 'transactionref': transactionRef ?? '', }, ), ); static const List paymentModes = ['Cash', 'UPI', 'Card', 'Wallet']; /// Step 4, and the pivot of the whole flow: it converts the booking into a /// consignment, recomputes chargeable weight from the dimensions submitted in /// step 2, and decides routing — a shared 3-digit pincode prefix means /// hyperlocal and the consignment goes straight to `Out_for_Delivery` in this /// rider's hands, otherwise it routes via the hub. /// A key that identifies **one rider action**, so a retry after a dropped /// acknowledgement replays the first result instead of writing twice. /// /// Derived, not random: the same action retried must produce the *same* key /// or the replay never matches. `verb:resource:day` is stable across a /// retry, an app restart and a lost response, while still letting a genuine /// second attempt tomorrow through. /// /// This complements [MutationGuard] rather than replacing it — the guard /// stops a double *press* on this device, the key stops a double *write* on /// the server after the network lost the first answer. static String _idempotencyKey(String verb, Object resource) { final now = DateTime.now(); final day = '${now.year}${now.month.toString().padLeft(2, '0')}' '${now.day.toString().padLeft(2, '0')}'; return '$verb:$resource:$day'; } static Future pickupComplete( Object bookingId, { double? lat, double? lon, }) => _guarded( 'pickup-complete:$bookingId', () => _send( 'POST', '/miler/bookings/$bookingId/pickup-complete', idempotencyKey: _idempotencyKey('pickup-complete', bookingId), body: { if (lat != null) 'latitude': lat, if (lon != null) 'longitude': lon, }, ), ); // ═══════════════════════════════════════════════════════════════════════ // THE RELEASE — collected → out for delivery // ═══════════════════════════════════════════════════════════════════════ /// Moves a `Collected_By_Miler` consignment to `Out_for_Delivery`. /// /// ── This is the step the app spent months without ── /// /// `pickup-complete` used to push hyperlocal work straight to /// `Out_for_Delivery`, so the hub saw a rider "actively delivering" food /// still on the kitchen counter, and there was no server state for /// *collected, holding*. The app modelled the difference locally, which the /// hub could not see and a reinstall erased. /// /// Now `pickup-complete` lands on `Collected_By_Miler` and this call — the /// rider pressing **Start round** — makes the release. Two consequences /// worth knowing: /// /// • **The receiver OTP is issued here**, not at pickup, so a code is not /// in the customer's hands during the holding period. /// • **The rider's availability flips** `Picked_Up` → `On_Delivery`. /// /// Replaces the old `POST /miler/deliveries/start`, which never existed. static Future startDelivery(Object consignmentId) => _guarded( 'start-delivery:$consignmentId', () => _send( 'POST', '/miler/consignments/$consignmentId/start-delivery', idempotencyKey: _idempotencyKey('start-delivery', consignmentId), body: const {}, ), ); /// The consignment's current state and what may be done to it. /// /// Returns `status` plus the backend's own derived flags — `collected`, /// `out_for_delivery`, `delivered`, `can_start_delivery`, `can_deliver`, /// `can_skip` — and the COD figures. Replaces walking /// `GET /miler/consignments/logs/:id` and reading the last event, which was /// a history replay standing in for a state read. static Future consignment(Object consignmentId) => _send('GET', '/miler/consignments/$consignmentId'); /// Query strings, not a body — the handler reads `c.Query`. static Future vehicleRequired( Object bookingId, { required String type, required String reason, }) => _send( 'POST', '/miler/bookings/$bookingId/vehicle-required', query: {'type': type, 'reason': reason}, ); /// Refused once the booking is picked up. static Future cancelBooking( Object bookingId, { required String reason, }) => _guarded( 'cancel:$bookingId', () => _send( 'POST', '/miler/bookings/$bookingId/cancel', body: {'reason': reason}, ), ); // ═══════════════════════════════════════════════════════════════════════ // PRICING — what this shipment costs, from the real numbers // ═══════════════════════════════════════════════════════════════════════ /// The fare band for a shipment, quoted at the door. /// /// `POST /api/v1/pricing/check` — note it is **not** under `/miler`, and it /// takes no auth. The zone is derived server-side from the two pincodes when /// [zone] is omitted, which is the path the rider app uses: he has just /// captured both addresses and has no business deciding what a zone is. /// /// Returns `{found, zone, service_type, weight, currency, results: [{category, /// category_label, min_price, max_price}]}` — a *band* per category, not a /// single number, because that is what the pricing table holds. When `found` /// is false the combination has no rule configured and the rider must be told /// that rather than shown a zero. See [ShipmentQuote]. static Future checkPrice({ required double weight, required String serviceType, String? zone, String? pickupPincode, String? deliveryPincode, String? category, }) => _send( 'POST', '/pricing/check', body: { 'weight': weight, 'service_type': serviceType, if (zone != null && zone.isNotEmpty) 'zone': zone, if (pickupPincode != null && pickupPincode.isNotEmpty) 'pickup_pincode': pickupPincode, if (deliveryPincode != null && deliveryPincode.isNotEmpty) 'delivery_pincode': deliveryPincode, if (category != null && category.isNotEmpty) 'category': category, }, ); /// The service tiers the pricing table is keyed on. `Express` — not /// `Fast`/`Superfast`, which is what the *booking* service options use. static const List serviceTypes = ['Normal', 'Express']; /// Parcel categories the pricing table recognises. static const List parcelCategories = [ 'General', 'Documents', 'Electronics', 'Clothing', 'Fragile', 'Medical', 'Automotive', 'Food', ]; // ═══════════════════════════════════════════════════════════════════════ // DELIVERY // ═══════════════════════════════════════════════════════════════════════ // ── `POST /miler/deliveries/start` is not on this API ── // // It was declared here and called after every pickup. It is absent from the // contract's 38 routes and from the backend's route table, so it 404'd every // time — and the app was built to believe a consignment only became // deliverable once it had "worked". // // It never needed to. `pickup-complete` decides routing itself: matching // 3-digit pickup and delivery pincode prefixes are hyperlocal and the // consignment goes straight to `Out_for_Delivery` in the rider's hands. There // is no rider-facing release step to make, so there is no endpoint to call. // // Do not add it back without a contract change. What the rider presses is a // *local* record of having set off — see `OrderEvent.outForDelivery`. /// The consignment must be `Out_for_Delivery` or this is a 400. /// /// `otp` is **optional**. There is no delivery-OTP column on consignments and /// nothing generates one, so the handler cannot check a code against /// anything; it records whether one was presented. Requiring it used to force /// any caller without an OTP — a milk-run drop, where a subscriber's lunch is /// handed over with no code — to invent a value, which is worse than /// recording that there was none. Do not start sending a placeholder. /// /// `lat`/`lon` must be the actual delivery point: the server computes /// `riderkms` from the pickup coords by haversine and writes it onto the /// earnings record with `ridercharges`. Passing the pickup coords here would /// silently zero the rider's distance for that leg. static Future deliver( Object consignmentId, { required String deliveredToName, String? otp, String? photoUrl, String? receiverSignatureUrl, double? lat, double? lon, }) => _guarded( 'deliver:$consignmentId', () => _send( 'POST', '/miler/consignments/$consignmentId/deliver', idempotencyKey: _idempotencyKey('deliver', consignmentId), body: { 'deliveredtoname': deliveredToName, if (otp != null && otp.isNotEmpty) 'otp': otp, 'photourl': photoUrl ?? '', 'receiversignatureurl': receiverSignatureUrl ?? '', if (lat != null) 'lat': lat, if (lon != null) 'lon': lon, }, ), ); /// Bumps `attemptcount` rather than failing the consignment — a skip is a /// return visit, not an outcome. static Future skipConsignment( Object consignmentId, { required String reason, double? lat, double? lon, }) => _guarded( 'skip:$consignmentId', () => _send( 'POST', '/miler/consignments/$consignmentId/skip', idempotencyKey: _idempotencyKey('skip', consignmentId), body: { 'reason': reason, if (lat != null) 'lat': lat, if (lon != null) 'lon': lon, }, ), ); // ═══════════════════════════════════════════════════════════════════════ // BOOKINGS & EARNINGS // ═══════════════════════════════════════════════════════════════════════ static Future bookings({String? status, String? date}) => _send( 'GET', '/miler/bookings', query: { if (status != null && status.isNotEmpty) 'status': status, if (date != null && date.isNotEmpty) 'date': date, }, ); /// `bonuspoints` on this response is always zero — nothing writes it yet. static Future earnings({String period = 'daily', String? date}) => _send( 'GET', '/miler/earnings', query: { 'period': period, if (date != null && date.isNotEmpty) 'date': date, }, ); // ═══════════════════════════════════════════════════════════════════════ // TELEMETRY — Redis-backed, high frequency // // Redis is never the system of record here: a flush loses telemetry, not // business state. So every call in this section is best-effort and must // never block the rider. // ═══════════════════════════════════════════════════════════════════════ /// One `MilerLog`. Every numeric field goes as a **string** — sending real /// numbers fails to parse server-side. /// /// Deliberately takes no `userid`: identity comes from the token, and a body /// `userid` is how one rider's GPS could once be written under another's. static Future postLog({ required double latitude, required double longitude, String? status, Object? orderId, double? speed, double? heading, double? accuracy, int? battery, bool isCharging = false, String? connection, String? locationService, bool isBackground = false, DateTime? at, }) => _send( 'POST', '/miler/logs', body: { 'logdate': logStamp(at), 'latitude': _str(latitude), 'longitude': _str(longitude), if (speed != null) 'speed': _str(speed), if (heading != null) 'heading': _str(heading), if (accuracy != null) 'accuracy': _str(accuracy), if (status != null && status.isNotEmpty) 'status': status, 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 getLogs() => _send('GET', '/miler/logs'); static Future postStatus(String status) => _send('POST', '/miler/status', body: {'status': status}); static Future getStatus() => _send('GET', '/miler/status'); /// A JSON **array**, even for one entry — the handler decodes a list. static Future postConsignmentLogs( List entries, ) => _send( 'POST', '/miler/consignments/logs', body: [for (final e in entries) e.toJson()], ); static Future consignmentLogs(Object consignmentId) => _send('GET', '/miler/consignments/logs/$consignmentId'); /// Must be your own userid; anyone else's is rejected. static Future userLogs(Object userId) => _send('GET', '/miler/consignments/userlogs/$userId'); // ═══════════════════════════════════════════════════════════════════════ // NOTIFICATIONS & SUPPORT // ═══════════════════════════════════════════════════════════════════════ /// Synthesized fresh from `BookingAssignment` rows on every call, and `id` is /// just the array index — so an id is only valid until the next GET. static Future notifications() => _send('GET', '/miler/notifications'); /// **A stub.** Returns success without persisting anything, because there is /// no notifications table with read state. Read state cannot survive a /// refresh until that table exists, so nothing may depend on it. static Future markNotificationRead(Object id) => _send('PATCH', '/miler/notifications/$id/read'); static Future createSupportTicket({ required String subject, required String description, }) => _send( 'POST', '/miler/support', body: {'subject': subject, 'description': description}, ); static Future supportTickets() => _send('GET', '/miler/support'); // ═══════════════════════════════════════════════════════════════════════ /// What the rider app needs and the backend does not yet expose. Kept here /// rather than scattered as `logGap` calls so there is one list to hand the /// backend team. See the write-up in ABOUT_MILER.md §8. static const List missingFromBackend = [ '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 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 data, { String preferredCategory = '', }) { final found = data['found'] == true; final results = (data['results'] is List) ? (data['results'] as List).whereType().toList() : const []; if (!found || results.isEmpty) { return ShipmentQuote( found: false, zone: (data['zone'] ?? '').toString(), serviceType: (data['service_type'] ?? '').toString(), weight: _num(data['weight']), message: (data['message'] ?? 'no price configured for this shipment') .toString(), ); } Map? chosen; if (preferredCategory.isNotEmpty) { for (final r in results) { if ((r['category'] ?? '').toString().toLowerCase() == preferredCategory.toLowerCase()) { chosen = r; break; } } } chosen ??= results.reduce( (a, b) => _num(a['min_price']) <= _num(b['min_price']) ? a : b, ); return ShipmentQuote( found: true, zone: (data['zone'] ?? '').toString(), serviceType: (data['service_type'] ?? '').toString(), weight: _num(data['weight']), currency: (data['currency'] ?? 'INR').toString(), category: (chosen['category_label'] ?? chosen['category'] ?? '') .toString(), minPrice: _num(chosen['min_price']), maxPrice: _num(chosen['max_price']), ); } } /// One `ConsignmentLog`. Numeric fields are strings, as with [MilerApi.postLog]. class ConsignmentLogEntry { final Object consignmentId; final double latitude; final double longitude; final String? status; final double? speed; final double? heading; final int? battery; final String remarks; final bool isBackground; final DateTime? at; const ConsignmentLogEntry({ required this.consignmentId, required this.latitude, required this.longitude, this.status, this.speed, this.heading, this.battery, this.remarks = '', this.isBackground = false, this.at, }); Map toJson() => { 'consignmentid': consignmentId, 'logdate': MilerApi.logStamp(at), 'latitude': latitude.toString(), 'longitude': longitude.toString(), if (speed != null) 'speed': speed.toString(), if (heading != null) 'heading': heading.toString(), if (status != null) 'status': status, 'remarks': remarks, if (battery != null) 'battery': battery.toString(), 'is_background': isBackground, }; } /// The result of one call. /// /// [ok] folds two things the transport keeps apart: a 2xx status *and* an /// envelope that did not say `success: false`. A 200 carrying `success: false` /// is a failure, and treating it as one at the boundary is what stops it being /// re-checked, inconsistently, at every call site. class ApiResult { final bool ok; final int status; /// The backend's stable machine-readable failure code — `INVALID_STATE`, /// `OTP_REQUIRED`, `CONSIGNMENT_NOT_ASSIGNED`, `IDEMPOTENCY_IN_PROGRESS`… /// /// Added to the `/miler/*` 4xx envelope on 21 Aug 2026. **Branch on this, /// never on [message]** — string-matching a human sentence is how the app /// came to tell riders "the hub hasn't released this" for a consignment that /// had already been delivered. Empty on success and on any route that does /// not carry one yet. final String code; /// The envelope's `data`, or the whole body when there is no `data` key. final dynamic data; /// The undecorated decoded body — needed for `verify-pin`, whose payload sits /// under `user` rather than `data`. final dynamic raw; final String message; const ApiResult({ required this.ok, required this.status, this.data, this.raw, this.message = '', this.code = '', }); /// True when the backend refused because the entity is in the wrong state. bool get isInvalidState => code == 'INVALID_STATE'; /// True when an identical request is still running server-side — retry /// shortly rather than treating it as a failure. bool get isInFlight => code == 'IDEMPOTENCY_IN_PROGRESS'; /// [data] as a map, or an empty one. Map get map => data is Map ? Map.from(data as Map) : {}; /// [data] as a list, tolerating the envelope shapes seen in the wild: /// `[…]`, `{data: […]}`, `{data: {items: […]}}`, `{bookings: […]}`. List get list { dynamic d = data; if (d is List) return d; if (d is Map) { for (final k in const ['items', 'bookings', 'data', 'details', 'logs']) { if (d[k] is List) return d[k] as List; } } return const []; } /// True when the failure is an auth failure — the session is gone and the /// rider has to sign in again, which is a different recovery from a retry. bool get isUnauthorized => status == 401 || status == 403; }