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>
504 lines
21 KiB
Dart
504 lines
21 KiB
Dart
import 'dart:convert';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import 'package:miler/data/api_status.dart';
|
|
import 'package:miler/data/consignment_state.dart';
|
|
import 'package:miler/data/route_order.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
/// Base URL, bearer token, and the adapter that turns a v1 booking into the
|
|
/// legacy stop shape the UI reads.
|
|
///
|
|
/// ── The flag is gone ──
|
|
///
|
|
/// This class used to carry `useNewApi`, a `--dart-define` switch between the
|
|
/// v1 backend and a legacy one (`jupiter.doormile.app` / `queue.workolik.com`).
|
|
/// Every provider branched on it, so the app shipped two implementations of
|
|
/// every call and only one of them was ever exercised.
|
|
///
|
|
/// It was also the switch that turned the mock layer on: `USE_NEW_API=false`
|
|
/// gave a fake "Demo Rider" login that accepted any four digits, seeded demo
|
|
/// routes, and invented earnings figures. A build flag that silently swaps
|
|
/// authentication for a bypass is not a development convenience.
|
|
///
|
|
/// One backend, one path, no flag. `MilerApi` is where the endpoints live; what
|
|
/// remains here is the base URL, the token, and the booking→stop mapping.
|
|
class ApiConfig {
|
|
ApiConfig._();
|
|
|
|
/// API base URL (no trailing slash).
|
|
static const String newBase = 'https://api.doormile.com/api/v1';
|
|
|
|
static String url(String path) =>
|
|
'$newBase${path.startsWith('/') ? path : '/$path'}';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Bearer token
|
|
// ---------------------------------------------------------------------------
|
|
static const String _kTokenKey = 'authtoken';
|
|
|
|
static Future<void> setToken(String token) async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(_kTokenKey, token);
|
|
}
|
|
|
|
static Future<String?> getToken() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final t = prefs.getString(_kTokenKey);
|
|
return (t != null && t.isNotEmpty) ? t : null;
|
|
}
|
|
|
|
static Future<void> clearToken() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.remove(_kTokenKey);
|
|
}
|
|
|
|
/// Headers for every authenticated request (Content-Type + Bearer token).
|
|
static Future<Map<String, String>> authHeaders() async {
|
|
final headers = <String, String>{
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
};
|
|
final token = await getToken();
|
|
if (token != null) headers['Authorization'] = 'Bearer $token';
|
|
return headers;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// WHAT THE TOKEN ALREADY SAYS ABOUT THE RIDER
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// The `tenantid` claim carried in the bearer token, or 0 when there is none.
|
|
///
|
|
/// ── Why the app reads its own token ──
|
|
///
|
|
/// The rider's tenant decides his entire operational mode, and the server has
|
|
/// always known it — `GenerateToken` signs `tenantid` into every miler JWT.
|
|
/// What it did *not* do was put it in the `verify-pin` response body, so the
|
|
/// app was told the answer and could not hear it: the login carried tenant 13
|
|
/// in its token and a body with no tenant at all, and every rider resolved to
|
|
/// the fallback line.
|
|
///
|
|
/// The handler now returns it too, but a deployed backend is not the same
|
|
/// thing as a merged one, and the app should not need a release to be
|
|
/// redeployed alongside. The claim is the same fact from the same source —
|
|
/// signed by the server, not asserted by the client — so reading it closes
|
|
/// the gap without waiting on anything.
|
|
///
|
|
/// ── What this is not ──
|
|
///
|
|
/// It is **not** a security decision and must never become one. The signature
|
|
/// is not verified here — the app has no key and does not need one, because
|
|
/// every request is still authorised server-side by the same token. A rider
|
|
/// who edited this claim would change which screens his own phone draws and
|
|
/// nothing else; the API would keep answering for the tenant it verified.
|
|
///
|
|
/// Returns 0 for a missing, malformed or unparseable token rather than
|
|
/// throwing: an unreadable token must fall through to the other signals, not
|
|
/// take the app down at launch.
|
|
static int tenantIdFromToken(String? token) {
|
|
if (token == null || token.isEmpty) return 0;
|
|
try {
|
|
final parts = token.split('.');
|
|
if (parts.length != 3) return 0;
|
|
// JWT uses base64url without padding; `base64Url.decode` demands it.
|
|
String payload = parts[1];
|
|
payload += '=' * ((4 - payload.length % 4) % 4);
|
|
final decoded = json.decode(utf8.decode(base64Url.decode(payload)));
|
|
if (decoded is! Map) return 0;
|
|
final raw = decoded['tenantid'];
|
|
if (raw is int) return raw;
|
|
if (raw is num) return raw.toInt();
|
|
return int.tryParse(raw?.toString() ?? '') ?? 0;
|
|
} catch (e) {
|
|
debugPrint('[AUTH] could not read tenant from token: $e');
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/// The stored session's tenant claim. See [tenantIdFromToken].
|
|
static Future<int> storedTenantId() async =>
|
|
tenantIdFromToken(await getToken());
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Response envelope adapter: {success,data,message} -> {status,details}
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Wrap a decoded new-API response into the legacy envelope the controllers
|
|
/// expect. `_isSuccess()` reads `status` (bool); readers read `details`.
|
|
static Map<String, dynamic> toLegacyEnvelope(dynamic decoded) {
|
|
if (decoded is Map<String, dynamic>) {
|
|
final success = decoded['success'];
|
|
final bool ok = success is bool ? success : success == true;
|
|
return <String, dynamic>{
|
|
'status': ok,
|
|
'code': ok ? 200 : 400,
|
|
'message': decoded['message']?.toString() ?? '',
|
|
'details': decoded['data'] ?? decoded['details'] ?? decoded,
|
|
};
|
|
}
|
|
// Bare list/value response.
|
|
return <String, dynamic>{'status': true, 'code': 200, 'details': decoded};
|
|
}
|
|
|
|
/// A generic "it worked" legacy envelope (for no-op / not-yet-supported flows).
|
|
static Map<String, dynamic> okEnvelope([String message = '']) =>
|
|
<String, dynamic>{'status': true, 'code': 200, 'message': message};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// STATUS mapping (new booking status <-> legacy orderstatus)
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// New (in order): Miler_Assigned -> Pickup_Scheduled -> At_Customer
|
|
// -> Picked_Up -> Converted_To_Consignment -> Cancelled
|
|
// Legacy the UI branches on: accepted / active / arrived / "Picked up"
|
|
// / picked / skipped / cancelled / rejected
|
|
|
|
static String legacyStatusFromNew(String? newStatus) {
|
|
// ── Parsed, not string-matched ──
|
|
//
|
|
// This was a `switch` on raw strings whose `default` returned the value
|
|
// unchanged, so two things went wrong quietly. `Pending_Pickup` and
|
|
// `Created` were not listed at all and fell through as themselves, and a
|
|
// status added by the backend tomorrow would do the same — arriving in the
|
|
// UI as an unrecognised string that the row logic then had to guess at.
|
|
//
|
|
// [BookingStatus] covers the contract exhaustively and folds everything
|
|
// else into `unknown`, which maps to the empty string here: a stop the app
|
|
// cannot classify renders as undecided, never as picked, cancelled or
|
|
// otherwise finished. Wrong-but-safe beats wrong-and-settled.
|
|
return switch (BookingStatus.parse(newStatus)) {
|
|
// Admin-assigned but NOT yet accepted by the rider. These land on Home as
|
|
// pending bookings to accept or reject; the client-side accepted marker
|
|
// is what moves one to the work tab.
|
|
BookingStatus.pendingPickup ||
|
|
BookingStatus.created ||
|
|
BookingStatus.milerAssigned => 'assigned',
|
|
// ── `Pickup_Scheduled` is the ACCEPTED rung, not the arrived one ──
|
|
//
|
|
// It is the status the backend writes when the rider accepts an
|
|
// assignment — the flow doc calls it "the pickup is on their route", and
|
|
// the console maps it to *accepted* for exactly that reason.
|
|
//
|
|
// This mapped it to `active`, which every row in this app reads as *the
|
|
// rider is physically on the stop* (see `stopStateOf`, where a raw
|
|
// `active` outranks the local accepted record). The effect was that
|
|
// accepting skipped a whole rung: the moment the queue came back, the
|
|
// stop reported as arrived, and selecting it offered **Mark as Picked**
|
|
// for a kitchen the rider had not reached yet. Arrival — the one rung
|
|
// that has a real endpoint behind it, `reached` — could not be recorded
|
|
// at all, so the hub never saw it.
|
|
//
|
|
// The two applications were reading one status two different ways. This
|
|
// is the app's half of that; the console's half already said accepted.
|
|
BookingStatus.pickupScheduled => 'accepted',
|
|
// The rung `reached` writes. It was only ever reachable through the
|
|
// undocumented `At_Customer` spelling, handled here as a special case
|
|
// ahead of the parse; `Arrived_At_Pickup` is the contract name and both
|
|
// now come through [BookingStatus].
|
|
BookingStatus.arrivedAtPickup => 'arrived',
|
|
BookingStatus.pickedUp => 'Picked up',
|
|
BookingStatus.convertedToConsignment => 'picked',
|
|
// ── Past the boundary, and it has to say so ──
|
|
//
|
|
// A hyperlocal booking is released for delivery by `pickup-complete`
|
|
// itself, so this is the status most collected DailyGrubs orders carry.
|
|
// It fell through as `unknown` → '' → *undecided*, which put a bag
|
|
// already in the rider's box back on Home as work to accept. See
|
|
// [BookingStatus.outForDelivery].
|
|
BookingStatus.outForDelivery => 'outfordelivery',
|
|
BookingStatus.delivered => 'delivered',
|
|
BookingStatus.cancelled => 'cancelled',
|
|
BookingStatus.unknown => '',
|
|
};
|
|
}
|
|
|
|
/// The delivery half of the same translation.
|
|
///
|
|
/// ── Why a second mapper exists ──
|
|
///
|
|
/// A booking's story ends at `Converted_To_Consignment`. From there the work
|
|
/// belongs to a different object with a different vocabulary, and the app
|
|
/// had no way to see it on a list: every collected stop — in the box, on the
|
|
/// road, handed over an hour ago — reported the same terminal booking word.
|
|
/// That is why a delivered stop kept sitting on the Deliveries tab until
|
|
/// something asked its consignment directly, one round trip per stop.
|
|
///
|
|
/// Since 21 Aug 2026 `GET /miler/bookings` carries `consignmentstatus` on
|
|
/// every row, so the list itself answers it.
|
|
///
|
|
/// Returns `''` for the hub-side states (`Created`, `Inwarded_at_Hub`,
|
|
/// `Tripsheet_Loaded`, `In_Transit`) and for anything unrecognised — the
|
|
/// caller then keeps the booking's own word. A hub-side consignment is not
|
|
/// this rider's to act on and has no rung on his card; inventing one would
|
|
/// put a parcel in somebody else's warehouse on his screen.
|
|
static String legacyStatusFromConsignment(Object? raw) {
|
|
return switch (consignmentStateFromRaw(raw)) {
|
|
// Collected and in the rider's hands. Same rung the booking's
|
|
// `Converted_To_Consignment` produces — but now it is the consignment
|
|
// itself saying so.
|
|
ConsignmentState.collectedByMiler => 'picked',
|
|
ConsignmentState.outForDelivery => 'outfordelivery',
|
|
ConsignmentState.delivered => 'delivered',
|
|
ConsignmentState.cancelled ||
|
|
ConsignmentState.returnedToSender => 'cancelled',
|
|
_ => '',
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// BOOKING -> legacy pickup object mapping
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// The UI reads legacy lowercase keys (pickupid, orderid, orderstatus,
|
|
// pickuplat/pickuplong, dropaddress/droplat/droplon, pickupcustomer,
|
|
// pickupcontactno, collectionamt, step, type ...). Translate a new `booking`
|
|
// object into that shape so the existing cards/flows render unchanged.
|
|
/// The sequence spellings this adapter looks for, in [RouteOrder]'s order.
|
|
static const List<String> sequenceFieldNames = RouteOrder.sequenceKeys;
|
|
|
|
static Map<String, dynamic> pickupFromBooking(Map booking) {
|
|
String s(dynamic v) => v == null ? '' : v.toString();
|
|
// First non-null value among several candidate keys — makes the adapter
|
|
// tolerant of field-name variants (camelCase / snake_case / short forms)
|
|
// so a naming mismatch can't map bookings to empty ids and drop them.
|
|
dynamic pick(List<String> keys) {
|
|
for (final k in keys) {
|
|
final v = booking[k];
|
|
if (v != null && v.toString().trim().isNotEmpty) return v;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Some endpoints already return the legacy STOP shape the UI reads
|
|
// (orderid/pickupid/orderstatus/pickupcustomer). Translating that as if it
|
|
// were a new-booking object would map everything to empty ids (→ dropped)
|
|
// and lose fields the translation doesn't cover (step, tenant, amounts).
|
|
// Detect it and pass it through untouched.
|
|
final looksNew =
|
|
booking['bookingid'] != null || booking['bookingreference'] != null;
|
|
final looksLegacy =
|
|
booking['orderid'] != null ||
|
|
booking['pickupid'] != null ||
|
|
booking['orderstatus'] != null ||
|
|
booking['pickupcustomer'] != null;
|
|
if (!looksNew && looksLegacy) {
|
|
return Map<String, dynamic>.from(booking);
|
|
}
|
|
|
|
final id = pick(['bookingid', 'bookingId', 'id', 'booking_id', 'pickupid']);
|
|
final ref = pick([
|
|
'bookingreference',
|
|
'bookingRef',
|
|
'reference',
|
|
'referenceno',
|
|
'orderid',
|
|
]);
|
|
final status = pick([
|
|
'status',
|
|
'bookingstatus',
|
|
'booking_status',
|
|
'orderstatus',
|
|
'state',
|
|
]);
|
|
|
|
// ── The consignment outranks the booking, when there is one ──
|
|
//
|
|
// Not a preference — a correction. `Converted_To_Consignment` is where the
|
|
// booking stops being informative, so if the row also reports where the
|
|
// consignment has got to, that is the newer fact and the one the card must
|
|
// draw. Falls back to the booking's word whenever the consignment says
|
|
// nothing this rider can act on. See [legacyStatusFromConsignment].
|
|
final consignmentStatus = pick([
|
|
'consignmentstatus',
|
|
'consignmentStatus',
|
|
'consignment_status',
|
|
]);
|
|
final fromConsignment = legacyStatusFromConsignment(consignmentStatus);
|
|
|
|
return <String, dynamic>{
|
|
// identity
|
|
'pickupid': id,
|
|
'orderid': ref ?? id,
|
|
'orderheaderid': id,
|
|
'bookingid': id, // keep original too
|
|
'bookingreference': s(ref),
|
|
|
|
// status
|
|
'orderstatus': fromConsignment.isNotEmpty
|
|
? fromConsignment
|
|
: legacyStatusFromNew(s(status)),
|
|
// Kept raw alongside, so anything that needs the consignment's own word
|
|
// reads it rather than inferring it back out of the legacy one.
|
|
'consignmentstatus': s(consignmentStatus),
|
|
|
|
// pickup side
|
|
'pickupcustomer': s(
|
|
pick([
|
|
'customername',
|
|
'customerName',
|
|
'customer_name',
|
|
'name',
|
|
'pickupcustomer',
|
|
]),
|
|
),
|
|
'pickupcontactno': s(
|
|
pick([
|
|
'customerphone',
|
|
'customerPhone',
|
|
'customer_phone',
|
|
'phone',
|
|
'pickupcontactno',
|
|
]),
|
|
),
|
|
'pickupaddress': s(
|
|
pick(['pickupaddress', 'pickupAddress', 'pickup_address']),
|
|
),
|
|
'pickuppincode': s(
|
|
pick(['pickuppincode', 'pickupPincode', 'pickup_pincode']),
|
|
),
|
|
'pickuplat': s(
|
|
pick([
|
|
'pickuplatitude',
|
|
'pickupLatitude',
|
|
'pickup_latitude',
|
|
'pickuplat',
|
|
]),
|
|
),
|
|
'pickuplong': s(
|
|
pick([
|
|
'pickuplongitude',
|
|
'pickupLongitude',
|
|
'pickup_longitude',
|
|
'pickuplong',
|
|
'pickuplon',
|
|
]),
|
|
),
|
|
'pickuplon': s(
|
|
pick([
|
|
'pickuplongitude',
|
|
'pickupLongitude',
|
|
'pickup_longitude',
|
|
'pickuplong',
|
|
'pickuplon',
|
|
]),
|
|
),
|
|
|
|
// delivery / drop side
|
|
'dropaddress': s(
|
|
pick(['deliveryaddress', 'deliveryAddress', 'delivery_address']),
|
|
),
|
|
'droppincode': s(
|
|
pick(['deliverypincode', 'deliveryPincode', 'delivery_pincode']),
|
|
),
|
|
'droplat': s(
|
|
pick(['deliverylatitude', 'deliveryLatitude', 'delivery_latitude']),
|
|
),
|
|
'droplon': s(
|
|
pick(['deliverylongitude', 'deliveryLongitude', 'delivery_longitude']),
|
|
),
|
|
|
|
// ── Where this stop is collected FROM ──
|
|
//
|
|
// On a milk run the rider works two or three sources in a morning and
|
|
// Home groups his stops under one heading per source, with the bulk
|
|
// collect belonging to that group. `stopSourceId` / `stopSourceName` read
|
|
// exactly these keys — and this adapter builds a fixed map, so a field it
|
|
// does not name is a field the UI can never see, however faithfully the
|
|
// backend sends it. That was the bug: every milk-run stop grouped into
|
|
// one nameless pile.
|
|
//
|
|
// Empty on a logistics booking, which is collected from a customer's door
|
|
// rather than from a source. Nothing is invented when the keys are
|
|
// absent: an empty string groups as "no source", which is the truth.
|
|
'sourceid': s(
|
|
pick([
|
|
'sourceid',
|
|
'sourceId',
|
|
'source_id',
|
|
'kitchenid',
|
|
'kitchenId',
|
|
'pickuplocationid',
|
|
'pickupLocationId',
|
|
]),
|
|
),
|
|
'sourcename': s(
|
|
pick([
|
|
'sourcename',
|
|
'sourceName',
|
|
'source_name',
|
|
'kitchenname',
|
|
'kitchenName',
|
|
'providerlocation',
|
|
'providercompany',
|
|
]),
|
|
),
|
|
'pickuplocationid': s(
|
|
pick(['pickuplocationid', 'pickupLocationId', 'pickup_location_id']),
|
|
),
|
|
|
|
// Present once the booking has been converted. It is what the delivery
|
|
// route keys on, so a milk-run drop cannot be closed without it.
|
|
'consignmentid': s(
|
|
pick(['consignmentid', 'consignmentId', 'consignment_id']),
|
|
),
|
|
|
|
// ── The hub's solved position in the route ──
|
|
//
|
|
// Verified live 21 Aug 2026: `GET /miler/bookings` carries `step` on
|
|
// every row. This adapter builds a **fixed map**, so a field it does not
|
|
// name is a field the UI can never see however faithfully the backend
|
|
// sends it — and `step` was not named. The delivery leg therefore had no
|
|
// sequence at all and fell back to ordering by distance, which is the
|
|
// app re-planning a route the hub had already solved.
|
|
//
|
|
// `0` means *not sequenced* and is passed through as such; see
|
|
// [RouteOrder.sequenceOf], which treats it as "no answer", never as
|
|
// position zero.
|
|
'step': pick(sequenceFieldNames) ?? 0,
|
|
|
|
// ── Which leg this stop is ──
|
|
//
|
|
// Also live, also previously hardcoded: every row came through as
|
|
// `'pickup'` because "backend has no per-stop type yet". It does now —
|
|
// 23 of this rider's 29 rows say `delivery`.
|
|
'type': s(pick(['stoptype', 'stopType', 'stop_type'])).isEmpty
|
|
? 'pickup'
|
|
: s(pick(['stoptype', 'stopType', 'stop_type'])).toLowerCase(),
|
|
|
|
// Route estimates, straight from the assignment. Zero until the hub's
|
|
// optimizer has run — the app shows its own estimate in that case and
|
|
// says so rather than drawing a confident 0.
|
|
'etaminutes': pick(['etaminutes', 'etaMinutes']) ?? 0,
|
|
'cumulativekms': pick(['cumulativekms', 'cumulativeKms']) ?? 0,
|
|
'cumulativeeta': pick(['cumulativeeta', 'cumulativeEta']) ?? 0,
|
|
|
|
// money — NOT provided by the new booking object yet (see gaps doc)
|
|
'collectionamt': booking['collectionamt'] ?? 0,
|
|
'pickupamt': booking['pickupamt'] ?? 0,
|
|
|
|
// misc passthrough
|
|
'parcels': booking['parcels'] ?? const [],
|
|
'starttime': s(booking['createdat']),
|
|
'eta': s(booking['eta']),
|
|
};
|
|
}
|
|
|
|
static List<Map<String, dynamic>> pickupsFromBookings(dynamic data) {
|
|
if (data is List) {
|
|
return data.whereType<Map>().map((b) => pickupFromBooking(b)).toList();
|
|
}
|
|
// A single booking object (some endpoints return one, not a list).
|
|
if (data is Map &&
|
|
(data['bookingid'] != null || data['bookingreference'] != null)) {
|
|
return <Map<String, dynamic>>[pickupFromBooking(data)];
|
|
}
|
|
return <Map<String, dynamic>>[];
|
|
}
|
|
|
|
static void logGap(String where, String detail) {
|
|
debugPrint('[API_GAP][$where] $detail');
|
|
}
|
|
}
|