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

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

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

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

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

617 lines
26 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 `queue.workolik.com` 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.
Future<Map<String, dynamic>?> updatePickup(Map<String, dynamic> data) async {
final String status = (data['orderstatus'] ?? '').toString();
final id = data['pickupid'] ?? data['orderheaderid'];
final String notes = (data['notes'] ?? '').toString();
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].
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);
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);
if (newId.isNotEmpty) {
final orderKey = (data['orderid'] ?? id ?? '').toString().trim();
await rememberConsignmentId(orderKey, newId);
// ── What the pivot now says about what happens next ──
//
// Since 21 Aug 2026 the response also 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`. Logged rather than acted on —
// the release is the rider's press, and the states it produces are
// read back from the consignment itself, never remembered from
// here. See `MyPickups.startRound`.
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':
// Was a fabricated success — `okEnvelope('skip not supported by
// backend')` — so the rider watched a stop move to "skipped" while
// nothing left the phone and the hub never learned.
return envelope(
await MilerApi.skipConsignment(
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;
final parcels = [for (var i = 0; i < count; i++) ParcelEntry(weight: each)];
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};
}
}