Files
doormile_milderapp/lib/providers/pickuplog/pickuplog_provider.dart
Thiru-tenext d612916fe4 Session expiry, arrival geofence guard, multi-destination stops
Three fixes found by running the app on a real handset against production.

1. An expired token left the app looking signed in and unable to work.
   MilerApi.onUnauthorized was declared and called on every 401 but never
   assigned, so the token was dropped and nothing else happened: the profile
   stayed on disk, logged_out stayed false, and the rider saw his own name over
   a dashboard whose every call returned 401. He reads that as "no work today".
   The teardown now lives in endSession() and both ways out of a session — the
   Log out button and the 401 path — use it.

2. Arrived was written locally even when the rider was not there.
   updateArrivedStatus answers false for three different things and the caller
   treated all of them as "the write did not land", which is only true of one.
   A geofence refusal and a server refusal now stop the rung and hand back the
   reason; a dead network still advances, as it should.

3. A multi-destination customer pickup collapsed onto one stop.
   GET /miler/bookings returns a row per destination once collected, all with
   the same bookingid and reference. Every local store keys on that id, so the
   accepted store deduped two of three drops away and their consignment ids
   were unrecoverable. orderid is now the stop key; bookingreference stays the
   booking's name. Cards show "Stop 2 of 3" and the receiver's own name and
   number rather than the sender's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
2026-09-18 11:05:40 +05:30

791 lines
34 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/foundation.dart';
import 'package:miler/data/api_config.dart';
import 'package:miler/data/assignment_lookup.dart';
import 'package:miler/data/lifecycle.dart';
import 'package:miler/data/consignment_state.dart';
import 'package:miler/data/miler_api.dart';
import 'package:miler/data/accepted_store.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE PER-STOP WRITE PATH
///
/// Everything the rider does at a door goes through here.
///
/// ── What changed ──
///
/// This file used to carry two complete implementations of every call: a
/// legacy one that spoke to the retired backend through a hand-built
/// `IOClient` — one that disabled certificate checking, pinned a hardcoded IP
/// and did its own TLS upgrade to work around a carrier's broken DNS — and a
/// v1 one behind `if (ApiConfig.useNewApi)`. The legacy backend is gone, and
/// with it the reason to ship a client that trusts any certificate presented to
/// it. Both are deleted.
///
/// ── The four calls that were missing ──
///
/// The v1 pickup flow is four ordered steps, and only two of them were ever
/// sent. `parcel` and `payment` were collected from the rider at the door and
/// then dropped on the floor:
///
/// 1. `reached` ✅ was wired
/// 2. `parcel` ❌ the weight and count he typed went nowhere
/// 3. `payment` ❌ the cash he took went nowhere
/// 4. `pickup-complete` ✅ was wired
///
/// Step 2 is the one that costs money: `pickup-complete` recomputes chargeable
/// weight from those dimensions, so a booking completed without them bills on
/// the customer's estimate rather than on what the rider actually weighed. And
/// step 3 is the rider's own accountability record for cash he is carrying.
///
/// `skip` and `cancel` were worse than missing — they returned a *fabricated
/// success*, so the app told the rider his skip had registered while nothing
/// left the phone. Both endpoints exist and are now called.
/// ─────────────────────────────────────────────────────────────────────────
/// Per-stop GPS breadcrumbs, tied to a consignment.
class CreatePickupLogProvider {
/// Was a no-op returning a synthetic success, on the grounds that "there is
/// no audit-log endpoint in v1". There is: `POST /miler/consignments/logs`,
/// which takes a JSON **array** and wants its numbers as strings.
///
/// Best-effort by design. Telemetry is Redis-backed and is explicitly not the
/// system of record — losing a breadcrumb costs a line on a map, and blocking
/// the rider to retry one would cost him a stop.
Future<Map<String, dynamic>?> createPickupLog(
Map<String, dynamic> data,
) async {
final consignmentId =
data['consignmentid'] ?? data['orderheaderid'] ?? data['pickupid'];
final lat = double.tryParse(
'${data['latitude'] ?? data['riderslat'] ?? ''}',
);
final lon = double.tryParse(
'${data['longitude'] ?? data['riderslon'] ?? ''}',
);
if (consignmentId == null || lat == null || lon == null) {
// Not enough to place the breadcrumb. Silently skipping is right — this
// fires on a timer and a missing fix is normal, not an error.
return ApiConfig.okEnvelope();
}
final res = await MilerApi.postConsignmentLogs([
ConsignmentLogEntry(
consignmentId: consignmentId,
latitude: lat,
longitude: lon,
status: (data['status'] ?? data['orderstatus'])?.toString(),
speed: double.tryParse('${data['speed'] ?? ''}'),
heading: double.tryParse('${data['heading'] ?? ''}'),
battery: int.tryParse('${data['battery'] ?? ''}'),
remarks: (data['remarks'] ?? '').toString(),
isBackground: data['is_background'] == true,
),
]);
return res.ok ? ApiConfig.okEnvelope() : {'status': false};
}
}
class UpdatePickupProvider {
/// Maps a legacy `updatepickup` payload onto the v1 route its `orderstatus`
/// means.
///
/// The legacy payload shape is kept because ~8 call sites in
/// `PickupsController` build it, and rewriting those is a separate change to
/// a file this one has no business touching. What matters is that every
/// branch now ends in a real request or an honest failure — there are no
/// invented successes left.
/// The booking this payload is about, or null when it carries none.
///
/// Treats `0`, `''` and an unparseable value as absent — the three shapes a
/// missing id actually arrives in, none of which is the `null` a `??` chain
/// is looking for.
static Object? _bookingId(Map<String, dynamic> data) {
for (final key in const ['pickupid', 'orderheaderid']) {
final raw = data[key];
if (raw == null) continue;
final s = raw.toString().trim();
if (s.isEmpty) continue;
final n = int.tryParse(s);
if (n != null) {
if (n > 0) return n;
continue;
}
// A non-numeric reference — a tracking number — is a real id.
return s;
}
return null;
}
Future<Map<String, dynamic>?> updatePickup(Map<String, dynamic> data) async {
final String status = (data['orderstatus'] ?? '').toString();
final String notes = (data['notes'] ?? '').toString();
// ── `??` was catching null, and a missing id is never null here ──
//
// This read `data['pickupid'] ?? data['orderheaderid']`. Every caller
// builds its payload with `int.tryParse('${'$'}{stop['pickupid'] ?? 0}') ?? 0`,
// so a row without a booking id arrives as **`0`** — which is not null, so
// the fallback never fired and the app posted to
// `/miler/bookings/0/reached`.
//
// Nothing complained. The rider's rung advanced anyway (arrival is his own
// report and is deliberately not gated on the write landing), so from the
// phone it looked identical to a healthy arrival — while the hub was told
// about booking zero, and the console showed no arrival at all.
//
// Zero and empty are *absent* now, and an absent id refuses the call
// outright rather than aiming it at a booking that does not exist. See
// [_bookingId].
final id = _bookingId(data);
if (id == null) {
ApiConfig.logGap(
'update:$status',
'no usable booking id on the payload — pickupid='
'${data['pickupid']}, orderheaderid=${data['orderheaderid']}. '
'Refusing to post; the hub would have been told about booking 0.',
);
return {
'status': false,
'message': 'This stop is missing its booking reference.',
};
}
final double? lat = double.tryParse(
'${data['riderslat'] ?? data['pickuplat'] ?? ''}',
);
final double? lon = double.tryParse(
'${data['riderslon'] ?? data['pickuplong'] ?? ''}',
);
Map<String, dynamic> envelope(ApiResult r) => r.ok
? ApiConfig.toLegacyEnvelope(r.raw ?? {'success': true})
: {
'status': false,
'code': r.status,
'message': r.message,
// The backend's stable failure name, alongside the HTTP status
// this envelope has always called `code`. Callers that need to
// branch on *why* a write was refused read this one — message text
// is prose, and prose gets reworded.
'errorcode': r.code,
};
switch (status) {
case 'accepted':
case 'rejected':
// Keyed on bookingassignmentid, not bookingid — different sequences.
// Sending the booking id makes the backend answer 404 while the rider
// is shown success and the booking is never actually accepted.
final assignmentId = await AssignmentLookup.idForBooking(id);
if (assignmentId == null) {
ApiConfig.logGap(
'update:$status',
'No assignment row for booking $id — cannot $status.',
);
return {
'status': false,
'message': 'This stop is no longer available.',
};
}
AssignmentLookup.invalidate();
final r = status == 'accepted'
? await MilerApi.acceptAssignment(assignmentId)
: await MilerApi.rejectAssignment(
assignmentId,
reason: notes.isEmpty ? 'Declined by rider' : notes,
);
return envelope(r);
case 'arrived':
// ── A 200 is not proof the hub recorded an arrival ──
//
// Verified against production 21 Aug 2026: `reached` returns
// `{"success":true,"data":{"bookingid":78,"status":"Miler_Assigned"}}`
// and the booking's status is unchanged afterwards. The endpoint is a
// no-op that reports success, so `Arrived_At_Pickup` never reaches the
// console — and the app, which read only `ok`, drew ARRIVED anyway.
//
// The transition is now read for what it proves. The envelope carries
// the verdict so the caller can advance the rider without the app
// claiming an agreement that does not exist. See [MilerLifecycle].
final reached = await MilerApi.reached(id, lat: lat, lon: lon);
final arrival = MilerLifecycle.reached(reached);
MilerLifecycle.report('reached', arrival);
if (arrival.isUnconfirmed) {
ApiConfig.logGap(
'reached',
'booking $id: the call succeeded and the booking status did not '
'become Arrived_At_Pickup — ${arrival.evidence}. The hub '
'cannot show this rider as arrived. Backend deployment '
'mismatch; see MILER_API_REQUIREMENTS.md request 15.',
);
}
return {
...envelope(reached),
'confirmed': arrival.isConfirmed,
'serverstatus': arrival.bookingStatus.name,
'evidence': arrival.evidence,
};
case 'Picked up':
case 'picked':
// ── The pivot returns the thing the delivery leg runs on ──
//
// `pickup-complete` is what *creates* the consignment, and `deliver`
// and `skip` are consignment routes. The response was being wrapped and
// discarded, and the row the rider works from on Deliveries is a
// snapshot taken before this call — so nothing downstream knew the
// consignment id, and pressing **Delivered** at the door was refused by
// the app itself. Recorded here, at the only moment it is guaranteed to
// be in hand. See [rememberConsignmentId].
// ── What the rider captured, and what the contract can carry ──
//
// `POST /miler/bookings/:id/pickup-complete` takes **latitude and
// longitude and nothing else**. The door flow, meanwhile, *demands* a
// photograph of the parcels and — on a short count or a broken seal —
// a written explanation before it will let the rider confirm
// (`StopVerificationPage`, `_needsPickupNote`).
//
// So the app was compelling a rider to produce exactly the evidence
// that matters in a dispute, uploading the photo to object storage, and
// then dropping the URL and the note on the floor: this method reads
// `dropimage` for the delivery leg and has never read `pickupimage`,
// `proofimage` or `notes` on this one. Nobody at the hub could see any
// of it, and nobody knew it was missing.
//
// Two things change here, and neither of them invents a field:
//
// * the photo and the note are kept **on the device**, against the
// order, so the record exists and the office can ask for it — see
// `ProofStore` and the completed-stop record;
// * the gap is *logged*, every time, with what was dropped. It is the
// same channel every other missing route reports through, so it
// shows up where the rest of the contract gaps do instead of being
// a thing one person remembers.
//
// BACKEND DEPENDENCY: see handoff BE-2. When `pickup-complete` accepts
// `pickupimageurl` / `notes` / `condition`, send them here and delete
// this block.
final droppedProof = (data['pickupimage'] ?? data['proofimage'] ?? '')
.toString()
.trim();
if (droppedProof.isNotEmpty || notes.trim().isNotEmpty) {
ApiConfig.logGap(
'pickup-complete',
'The rider captured evidence this route cannot carry — '
'photo=${droppedProof.isNotEmpty ? 'yes' : 'no'}, '
'note=${notes.trim().isNotEmpty ? '"${notes.trim()}"' : 'none'}. '
'pickup-complete accepts latitude/longitude only. Held on the '
'device against booking $id. See BE-2.',
);
}
final picked = await MilerApi.pickupComplete(id, lat: lat, lon: lon);
// ── Which lifecycle the server is running, read from the server ──
//
// `Collected_By_Miler` + `next_action: start_delivery` is the new
// two-step flow; a bare `Out_for_Delivery` is compatibility mode, in
// which the pivot releases the consignment itself. Both are real, both
// ship today depending on a server-side flag, and the app must never
// mirror that flag — it reads which one happened.
final pivot = MilerLifecycle.pickupComplete(picked);
MilerLifecycle.report('pickup-complete', pivot);
// ── The Picked half of the lifecycle trace ──
//
// Unconditional, and it prints the response body. `_send` logs only
// failures, so a pivot that succeeded and wrote the *wrong* state —
// which is the entire production question — left no record at all, and
// every investigation of it started by adding this line by hand.
//
// Read with the `[TRACE][START-RIDE]` line in `releaseForDelivery`:
// together they answer "did the app release at Picked?" from one log,
// with no console access and no packet capture. See MilerLifecycle.
debugPrint(
'[TRACE][PICKED] booking=$id '
'POST /miler/bookings/$id/pickup-complete '
'-> ${picked.status} booking="${pivot.bookingStatus.name}" '
'consignment="${pivot.consignmentState.name}" '
'id="${pivot.consignmentId}" next="${pivot.nextAction}" '
'compatibilityMode=${pivot.isCompatibilityMode} '
'startDeliveryCalledHere=false '
'raw=${picked.raw}',
);
if (pivot.isCompatibilityMode) {
ApiConfig.logGap(
'pickup-complete',
'booking $id went straight to Out_for_Delivery — the backend is '
'running compatibility mode (MILER_COLLECTED_STATE_ENABLED '
'off). The consignment IS out for delivery, so the console '
'showing Active is correct and the app must agree with it.',
);
}
if (picked.ok) {
final newId = _consignmentIdFrom(picked);
final orderKey = (data['orderid'] ?? id ?? '').toString().trim();
// ── What the pivot said about what happens next, RECORDED ──
//
// Since 21 Aug 2026 the response carries `status` and `next_action`:
// a hyperlocal booking lands on `Collected_By_Miler` with
// `start_delivery`, and a hub-routed one on `Created` with
// `inward_at_hub`.
//
// This used to be logged and dropped, on the reasoning that the
// states it produces are read back from the consignment itself. That
// holds for the *release*, and not for the **routing**: the pivot is
// the only place the server ever names the leg, `GET /miler/bookings`
// does not carry `next_action`, and `Created` — the state a
// hub-routed parcel sits on — is also the state a consignment holds
// while the pivot is still routing it. So on the next poll the two
// routings are indistinguishable, and the app either strands a
// hyperlocal parcel or offers a customer delivery for a parcel bound
// for Chennai.
//
// Recorded per order, and read as the **lowest** rung of
// [NextLegResolver]'s precedence — continuity across a rebuild, never
// an override of live server state.
//
// Written outside the `newId` branch on purpose: the routing answer
// is worth keeping even on a response that named no consignment,
// which is a real shape (see the gap logged below).
await rememberPivotNextAction(orderKey, pivot.nextAction);
if (newId.isNotEmpty) {
await rememberConsignmentId(orderKey, newId);
debugPrint(
'[PICKUP] booking $orderKey → consignment $newId '
'(${_field(picked, 'status')}, next: '
'${_field(picked, 'next_action')})',
);
} else {
ApiConfig.logGap(
'pickup-complete',
'no consignment id in the response for booking $id — the '
'delivery leg will have to wait for the queue to carry it.',
);
// The one thing that makes this diagnosable next time. Debug-only:
// a response body is not something to write into a release log.
if (kDebugMode) {
debugPrint('[PICKUP] pickup-complete body: ${picked.raw}');
}
}
}
return {
...envelope(picked),
'confirmed': pivot.isConfirmed,
// What the stop actually became, in the consignment's own words.
// The caller stamps this rather than assuming 'picked' — the whole
// point of the exercise is that the rider's rung and the office's
// rung are the same rung.
'consignmentstatus':
pivot.consignmentState == ConsignmentState.unknown
? ''
: pivot.consignmentState.name,
'nextaction': pivot.nextAction,
'compatibilitymode': pivot.isCompatibilityMode,
'evidence': pivot.evidence,
};
case 'delivered':
case 'Delivered':
// ── This route keys on the CONSIGNMENT, not the booking ──
//
// Same trap as accept/reject and `bookingassignmentid`: two ids from
// two sequences, and passing the wrong one gets a 404 while the rider
// is shown success. This branch was passing `id`, which is the booking
// — so every delivery it sent was addressed to whatever consignment
// happened to share that number.
//
// The consignment exists only after `pickup-complete` has converted the
// booking; `MilerGetMyBookings` returns it and the stop adapter carries
// it through. Without one there is nothing to deliver *yet*, and saying
// so is better than guessing an id.
final consignmentId =
(data['consignmentid'] ?? data['consignmentId'] ?? '')
.toString()
.trim();
if (consignmentId.isEmpty) {
ApiConfig.logGap(
'update:delivered',
'Booking $id has no consignmentid — it has not been collected yet, '
'so there is nothing to deliver.',
);
return {
'status': false,
'message': 'This order has not been picked up yet.',
};
}
return envelope(
await MilerApi.deliver(
consignmentId,
deliveredToName: (data['pickupcustomer'] ?? '').toString(),
// Optional: nothing generates a delivery OTP, so the handler
// records whether one was presented rather than checking it. A
// milk-run drop has none and must not invent one.
otp: (data['otp'] ?? '').toString(),
photoUrl: (data['dropimage'] ?? '').toString(),
receiverSignatureUrl: (data['signature'] ?? '').toString(),
lat: lat,
lon: lon,
),
);
case 'skipped':
// ── The booking skip, not the consignment skip ──
//
// It was a fabricated success once — `okEnvelope('skip not supported
// by backend')` — so the rider watched a stop move to "skipped" while
// nothing left the phone. The fix that replaced it posted to
// `POST /miler/consignments/:id/skip` with a **booking** id, because
// that was the only skip route on the contract. Two different
// sequences: the call either 404'd or, worse, skipped whichever
// consignment happened to carry that number.
//
// Every caller of this branch is pre-collection — the skip sheet opens
// from a pickup card — and the pre-pickup skip now has its own route,
// which keeps the booking assigned and resumable. A skip *after*
// collection never comes through here: it goes through
// `closeDelivery`, which resolves the real consignment id first.
return envelope(
await MilerApi.skipBooking(
id,
reason: notes.isEmpty ? 'Skipped by rider' : notes,
lat: lat,
lon: lon,
),
);
case 'cancelled':
// Also previously fabricated. Refused server-side once the stop is
// picked up, which is correct and now surfaces as a real failure.
return envelope(
await MilerApi.cancelBooking(
id,
reason: notes.isEmpty ? 'Cancelled by rider' : notes,
),
);
case 'active':
// `Pickup_Scheduled` is system-set; there is no rider endpoint for it.
// Nudging availability is the closest true statement the app can make.
return envelope(await MilerApi.setAvailability('On_Pickup'));
default:
// ── An unmapped status is a failure, not a success ──
//
// This returned `okEnvelope()`: nothing left the phone, and the caller
// was told the write had landed. The rider watched the stop advance,
// the local stores recorded it, and the hub was never told — which is
// the single most expensive shape of bug this app can have, because
// every screen downstream then trusts a transition that does not exist
// server-side.
//
// It also defeats the typed-status handling upstream: a status this
// build does not recognise must never resolve to a settled state, and
// silently succeeding here is exactly that, one layer lower.
ApiConfig.logGap('update:unknown', 'Unmapped orderstatus="$status".');
return {
'status': false,
'message': 'This app cannot record "$status" yet — nothing was sent.',
};
}
}
/// The consignment id out of a `pickup-complete` response, whatever shape it
/// arrives in.
///
/// Tolerant on purpose. The contract now names the field — `consignment_id`,
/// alongside `status` and `next_action` — but this ran for a long time
/// against a route whose response body was written down nowhere, riders are
/// on builds that met both, and the cost of a miss is somebody at a door
/// being told there is nothing to deliver. The search stays.
/// One shallow field off a mutation response, for logging.
///
/// Deliberately not recursive and deliberately not stored: these values are
/// a description of a moment that has already passed by the time anything
/// would read them back.
String _field(ApiResult res, String key) {
final v = res.data[key];
if (v == null || v is Map || v is List) return '?';
final str = v.toString().trim();
return str.isEmpty ? '?' : str;
}
String _consignmentIdFrom(ApiResult res) {
// Searched rather than looked up, at every depth. The response body for
// this route is written down nowhere — not in the API reference, not in the
// flow doc — so a fixed list of key names is a guess, and the cost of
// guessing wrong is a rider standing at a door being told there is nothing
// to deliver. Anything whose key names a consignment and whose value is a
// plain scalar counts.
String found = '';
void walk(dynamic node, int depth) {
if (found.isNotEmpty || depth > 4 || node == null) return;
if (node is List) {
for (final item in node) {
walk(item, depth + 1);
}
return;
}
if (node is! Map) return;
// Exact names first, so a nested `id` never wins over a real one.
for (final key in const [
'consignmentid',
'consignmentId',
'consignment_id',
'consignmentno',
]) {
final v = node[key];
if (v != null && v is! Map && v is! List) {
final str = v.toString().trim();
if (str.isNotEmpty && str != '0' && str != 'null') {
found = str;
return;
}
}
}
// Then a `consignment` object, whose own id is the one wanted.
final nested = node['consignment'];
if (nested is Map) {
for (final key in const ['consignmentid', 'consignmentId', 'id']) {
final v = nested[key];
if (v != null && v is! Map && v is! List) {
final str = v.toString().trim();
if (str.isNotEmpty && str != '0') {
found = str;
return;
}
}
}
}
for (final v in node.values) {
walk(v, depth + 1);
}
}
walk(res.data, 0);
if (found.isEmpty) walk(res.raw, 0);
return found;
}
/// **Step 2** — what the rider actually took, measured at the door.
///
/// Reads the verification screen's payload directly rather than taking a
/// dozen arguments, because that map is already the record of the door and
/// splitting it would give two places to keep in step.
///
/// Dimensions are not collected by the app today — the verify screen asks for
/// total weight and a count, not L×W×H per parcel — so they go as zero and
/// the backend bills on weight alone. Collecting them is a UI change waiting
/// on a decision about how much to ask a rider for at a doorstep.
Future<Map<String, dynamic>?> submitParcels(
Object bookingId,
Map<String, dynamic> verification,
) async {
final pickup = verification['pickup'];
if (pickup is! Map) return ApiConfig.okEnvelope('no pickup leg');
final int count = int.tryParse('${pickup['collected'] ?? 0}') ?? 0;
final double totalWeight =
double.tryParse('${pickup['weight'] ?? ''}') ?? 0;
if (count <= 0 || totalWeight <= 0) {
ApiConfig.logGap(
'submitParcels',
'booking $bookingId: count=$count weight=$totalWeight — nothing to send.',
);
return {'status': false, 'message': 'Parcel details are incomplete.'};
}
// The rider weighs the consignment, not each box. Splitting the total
// evenly is the only honest distribution available from what he was asked,
// and it keeps the chargeable total correct even though the per-parcel
// figures are an even split rather than a measurement.
final double each = totalWeight / count;
// ── The photographs, at last ──
//
// `photos` has been on this contract since the route shipped and the app
// has never sent it. The keys arrive on the verification map (see
// `_withParcelPhotos`); an empty list means the upload failed, and the
// field is then omitted rather than sent empty — an empty array is a claim
// that nobody photographed anything.
//
// Attached to the FIRST parcel only. The rider takes one photograph of the
// load, not one per box — repeating the same key on every parcel would
// report N photographs where one was taken.
final photoKeys = <String>[
for (final k in (pickup['photos'] as List?) ?? const [])
if (k.toString().trim().isNotEmpty) k.toString().trim(),
];
final parcels = [
for (var i = 0; i < count; i++)
ParcelEntry(
weight: each,
photos: i == 0 ? photoKeys : const <String>[],
),
];
if (photoKeys.isEmpty) {
ApiConfig.logGap(
'submitParcels',
'booking $bookingId: no photo key available — the parcels are being '
'recorded without the door photograph.',
);
}
final res = await MilerApi.submitParcels(bookingId, parcels);
debugPrint(
'[PARCEL] booking $bookingId: $count parcel(s), ${totalWeight}kg '
'→ ${res.ok ? 'ok' : 'FAILED ${res.status} ${res.message}'}',
);
return res.ok
? ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true})
: {'status': false, 'code': res.status, 'message': res.message};
}
/// **Step 1b** — the FROM and TO the rider confirmed with the customer.
///
/// Reads the shipment-capture screen's payload, same as [submitParcels] reads
/// the verification screen's, so there is one record of the door rather than
/// two argument lists to keep in step.
///
/// Sent before the parcel and payment steps because `pickup-complete` builds
/// the consignment — its routing and its pricing zone — from these values,
/// and the handler refuses them once that conversion has happened.
Future<Map<String, dynamic>?> submitAddresses(
Object bookingId,
Map<String, dynamic> capture,
) async {
final Map? from = capture['from'] is Map ? capture['from'] as Map : null;
final Map? to = capture['to'] is Map ? capture['to'] as Map : null;
if (from == null && to == null) {
return ApiConfig.okEnvelope('no addresses captured');
}
String str(Map? m, String k) => (m?[k] ?? '').toString().trim();
double? coord(Map? m, String k) => double.tryParse('${m?[k] ?? ''}');
final res = await MilerApi.updateBookingAddresses(
bookingId,
pickupAddress: str(from, 'address'),
pickupPincode: str(from, 'pincode'),
pickupLat: coord(from, 'lat'),
pickupLon: coord(from, 'lon'),
deliveryAddress: str(to, 'address'),
deliveryPincode: str(to, 'pincode'),
deliveryLat: coord(to, 'lat'),
deliveryLon: coord(to, 'lon'),
deliveryCity: str(to, 'city'),
);
debugPrint(
'[ADDRESS] booking $bookingId '
'→ ${res.ok ? 'ok' : 'FAILED ${res.status} ${res.message}'}',
);
return res.ok
? ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true})
: {'status': false, 'code': res.status, 'message': res.message};
}
/// **Step 2b** — what this shipment costs, from the pricing table.
///
/// Returns null when the call itself failed, and a [ShipmentQuote] with
/// `found: false` when the table simply has no rule for this combination.
/// The two are different things to tell a rider — "try again" versus "call
/// the hub, this cannot be priced here" — so they stay distinguishable, and
/// neither one produces a number.
Future<ShipmentQuote?> fetchQuote({
required double weight,
required String serviceType,
String? pickupPincode,
String? deliveryPincode,
String? category,
}) async {
final res = await MilerApi.checkPrice(
weight: weight,
serviceType: serviceType,
pickupPincode: pickupPincode,
deliveryPincode: deliveryPincode,
category: category,
);
if (!res.ok) {
debugPrint('[PRICE] FAILED ${res.status} ${res.message}');
return null;
}
final quote = ShipmentQuote.fromData(
res.map,
preferredCategory: category ?? '',
);
debugPrint(
'[PRICE] ${quote.zone}/${quote.serviceType} ${quote.weight}kg → '
'${quote.found ? '${quote.currency} ${quote.payable}' : quote.message}',
);
return quote;
}
/// **Step 3** — the money, from the collect-payment screen's payload.
///
/// Returns a success envelope without calling anything when nothing was
/// collected: a prepaid stop has no payment to record, and posting a zero
/// would be rejected anyway (`amount` must be > 0).
Future<Map<String, dynamic>?> submitPayment(
Object bookingId,
Map<String, dynamic> payment,
) async {
if (payment['paid'] != true) return ApiConfig.okEnvelope('not collected');
final double amount =
double.tryParse('${payment['amountCollected'] ?? 0}') ?? 0;
if (amount <= 0) return ApiConfig.okEnvelope('nothing collected');
// The screen's own wire names are lowercase; the contract's enum is not.
final String mode = switch ('${payment['method']}'.toLowerCase()) {
'upi' => 'UPI',
'card' => 'Card',
'wallet' => 'Wallet',
_ => 'Cash',
};
final res = await MilerApi.submitPayment(
bookingId,
amount: amount,
paymentMode: mode,
transactionRef: (payment['reference'] ?? '').toString(),
);
debugPrint(
'[PAYMENT] booking $bookingId: $mode ₹$amount '
'→ ${res.ok ? 'ok' : 'FAILED ${res.status} ${res.message}'}',
);
return res.ok
? ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true})
: {'status': false, 'code': res.status, 'message': res.message};
}
/// The rider cannot carry this one — it needs a van.
///
/// Wired but unreachable from the UI: there is no control for it anywhere in
/// the app. Left here because the endpoint exists and the flow it belongs to
/// is this one; adding the control is a design question about where a rider
/// says "this doesn't fit on a bike".
Future<Map<String, dynamic>?> requireVehicle(
Object bookingId, {
required String type,
required String reason,
}) async {
final res = await MilerApi.vehicleRequired(
bookingId,
type: type,
reason: reason,
);
return res.ok
? ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true})
: {'status': false, 'code': res.status, 'message': res.message};
}
}