production
This commit is contained in:
@@ -66,6 +66,7 @@ Future<void> migrateLegacyStores() async {
|
||||
_kRejectedOrderIdsKeyBase,
|
||||
_kCompletedBookingsKeyBase,
|
||||
_kSkippedBookingsKeyBase,
|
||||
_kArrivedOrderIdsKeyBase,
|
||||
_kCollectedOrderIdsKeyBase,
|
||||
_kConsignmentIdsKeyBase,
|
||||
_kOutForDeliveryKeyBase,
|
||||
@@ -111,6 +112,7 @@ Future<void> clearScopedStores() async {
|
||||
_kRejectedOrderIdsKeyBase,
|
||||
_kCompletedBookingsKeyBase,
|
||||
_kSkippedBookingsKeyBase,
|
||||
_kArrivedOrderIdsKeyBase,
|
||||
_kCollectedOrderIdsKeyBase,
|
||||
_kConsignmentIdsKeyBase,
|
||||
_kOutForDeliveryKeyBase,
|
||||
@@ -529,6 +531,72 @@ Future<void> addAcceptedBookings(List<Map<String, dynamic>> bookings) async {
|
||||
// `delivered` one, so the terminal event can be named for what it is). Until
|
||||
// then a crash between the kitchen and the first door loses only the ordering,
|
||||
// not the work: every order is still accepted server-side and still appears.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// ARRIVED — the rider is standing at the source
|
||||
//
|
||||
// ── Why this has to be stored at all ──
|
||||
//
|
||||
// Every other rung the rider walks is confirmed by the server and comes back
|
||||
// on the next poll, so the app never had to remember it. Arrival does not:
|
||||
// `POST /miler/bookings/:id/reached` answers 200 and leaves the booking on
|
||||
// `Miler_Assigned` (verified in production, see MILER_API_REQUIREMENTS.md
|
||||
// request 15). So the rung existed only as a field on an in-memory row, and
|
||||
// the very next `_fetchQueues` — which the arrival sheet itself triggers —
|
||||
// rebuilt that row from the server and put it back on ACCEPTED.
|
||||
//
|
||||
// The rider's report was being overwritten roughly one second after he made
|
||||
// it. That is the whole of the "mark as arrived does nothing" bug: the write
|
||||
// was fine, the rung was fine, and nothing kept it.
|
||||
//
|
||||
// So arrival is kept here, on the same footing as [_kCollectedOrderIdsKeyBase]
|
||||
// and [_kOutForDeliveryKeyBase] — a local mirror of a rung the queue endpoints
|
||||
// cannot yet carry. **Delete this store the day `reached` persists**, and not
|
||||
// before: a local record that outranks the server is a liability the moment
|
||||
// the server has the answer.
|
||||
//
|
||||
// It is dropped as soon as the stop moves on, so it can never outrank a rung
|
||||
// the server *does* know about.
|
||||
const String _kArrivedOrderIdsKeyBase = 'arrived_order_ids';
|
||||
|
||||
/// Order ids the rider has marked arrived and not yet collected.
|
||||
Future<Set<String>> getArrivedOrderIds() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return (prefs.getStringList(await _scopedKey(_kArrivedOrderIdsKeyBase)) ?? [])
|
||||
.toSet();
|
||||
}
|
||||
|
||||
/// Records an arrival. Called with every stop at the counter he walked up to.
|
||||
Future<void> addArrivedOrderIds(List<String> ids) async {
|
||||
final clean = ids.where((s) => s.isNotEmpty).toSet();
|
||||
if (clean.isEmpty) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final existing =
|
||||
(prefs.getStringList(await _scopedKey(_kArrivedOrderIdsKeyBase)) ?? [])
|
||||
.toSet();
|
||||
existing.addAll(clean);
|
||||
await prefs.setStringList(
|
||||
await _scopedKey(_kArrivedOrderIdsKeyBase),
|
||||
existing.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Drops ids the moment they leave the arrived rung — collected, skipped,
|
||||
/// cancelled or rejected. Without this the set outlives the stop and yesterday's
|
||||
/// arrival pins today's row to ARRIVED.
|
||||
Future<void> removeArrivedOrderIds(List<String> ids) async {
|
||||
final drop = ids.where((s) => s.isNotEmpty).toSet();
|
||||
if (drop.isEmpty) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final remaining =
|
||||
(prefs.getStringList(await _scopedKey(_kArrivedOrderIdsKeyBase)) ?? [])
|
||||
.where((id) => !drop.contains(id))
|
||||
.toList();
|
||||
await prefs.setStringList(
|
||||
await _scopedKey(_kArrivedOrderIdsKeyBase),
|
||||
remaining,
|
||||
);
|
||||
}
|
||||
|
||||
const String _kCollectedOrderIdsKeyBase = 'collected_order_ids';
|
||||
|
||||
/// Order ids the rider has loaded and is carrying.
|
||||
|
||||
@@ -458,6 +458,48 @@ class ApiConfig {
|
||||
// position zero.
|
||||
'step': pick(sequenceFieldNames) ?? 0,
|
||||
|
||||
// ── The stamp that says whether `step` means anything ──
|
||||
//
|
||||
// Dropped here until now, which made the whole sequencing contract
|
||||
// unreadable: [RouteOrder.isSequenced] looks for this key and the adapter
|
||||
// never wrote it, so every adapted row looked unsequenced and the app
|
||||
// fell back to nearest-first on routes the hub had actually solved.
|
||||
//
|
||||
// The backend's rule, confirmed 25 Aug: **`sequencedat` is the
|
||||
// authority, not `step`.** Non-null → a route was assigned, follow `step`
|
||||
// exactly. Null → no route, and the fallback is correct. `step: 0` with a
|
||||
// null stamp is not a bug: it is a rider holding fewer than two active
|
||||
// stops, or a stop without coordinates — neither of which is a route.
|
||||
'sequencedat': pick(RouteOrder.sequencedAtKeys),
|
||||
|
||||
// ── The arrival, as the backend records it ──
|
||||
//
|
||||
// Confirmed by the backend team and shipped with their redeploy:
|
||||
// `/reached` writes an arrival **event**, and `GET /miler/bookings`
|
||||
// returns it on the row. There is no `Arrived_At_Pickup` booking status
|
||||
// and there never was — the status stays `Pickup_Scheduled` and the
|
||||
// stamp beside it is what says he is there.
|
||||
//
|
||||
// Carried through here so the rung can be rebuilt from server data alone
|
||||
// after a refresh or a restart, which is the thing the local arrival
|
||||
// record exists to stand in for. See [riderStageOf].
|
||||
'reachedat': pick(const [
|
||||
'reachedat',
|
||||
'reachedAt',
|
||||
'reached_at',
|
||||
'arrivedat',
|
||||
]),
|
||||
'arrivallatitude': pick(const [
|
||||
'arrivallatitude',
|
||||
'arrivalLatitude',
|
||||
'arrival_latitude',
|
||||
]),
|
||||
'arrivallongitude': pick(const [
|
||||
'arrivallongitude',
|
||||
'arrivalLongitude',
|
||||
'arrival_longitude',
|
||||
]),
|
||||
|
||||
// ── Which leg this stop is ──
|
||||
//
|
||||
// Also live, also previously hardcoded: every row came through as
|
||||
|
||||
@@ -121,12 +121,40 @@ extension ConsignmentStateX on ConsignmentState {
|
||||
/// Still inside the hub's half of the network. **This is the case the
|
||||
/// "not released yet" guard is for** — a logistics consignment sitting at a
|
||||
/// hub genuinely cannot be delivered by this rider, and must stay blocked.
|
||||
///
|
||||
/// ── `Created` is not one of these, and treating it as one stranded riders ──
|
||||
///
|
||||
/// It was listed here, and it is the wrong half of the network. The backend's
|
||||
/// own contract for the pivot is:
|
||||
///
|
||||
/// ```
|
||||
/// hyperlocal pickup-complete → Out_for_Delivery (compatibility mode)
|
||||
/// → Collected_By_Miler (flag on)
|
||||
/// hub-routed pickup-complete → Created + next_action: inward_at_hub
|
||||
/// ```
|
||||
///
|
||||
/// So `Created` means *the consignment exists and nothing has happened to it
|
||||
/// yet* — the parcel is *in the rider's own hands*, waiting either on his
|
||||
/// release or on him carrying it to the hub. The hub has never seen it. The
|
||||
/// rider slid **Start ride**, and the app answered "This parcel is with the
|
||||
/// hub — it will be delivered from there, not by you" about a bag on his own
|
||||
/// back, with no way forward from that screen.
|
||||
///
|
||||
/// Genuine hub custody starts at [ConsignmentState.inwardedAtHub] — the state
|
||||
/// whose name says the hub took it in. See [awaitsHubInward] for `Created`.
|
||||
bool get awaitsHub =>
|
||||
this == ConsignmentState.created ||
|
||||
this == ConsignmentState.inwardedAtHub ||
|
||||
this == ConsignmentState.tripsheetLoaded ||
|
||||
this == ConsignmentState.inTransit;
|
||||
|
||||
/// Converted, and not yet moved anywhere by anyone.
|
||||
///
|
||||
/// The parcel is with **this rider**. On a hyperlocal run this is a state the
|
||||
/// release moves out of; on a hub-routed one his next act is to inward it at
|
||||
/// the hub, which is not something this app does yet. Either way it is not a
|
||||
/// reason to tell him the parcel is somebody else's — see [awaitsHub].
|
||||
bool get awaitsHubInward => this == ConsignmentState.created;
|
||||
|
||||
/// After a successful `skip`, whether the stop is **still the rider's
|
||||
/// problem**.
|
||||
///
|
||||
@@ -167,6 +195,13 @@ enum DeliverGate {
|
||||
/// A real hub-side hold. Block, and say so.
|
||||
awaitingHub,
|
||||
|
||||
/// `Created` — converted, never released, never inwarded. The parcel is in
|
||||
/// this rider's hands, so the hub-hold wording is a lie; but it is not
|
||||
/// `Out_for_Delivery` either, so `deliver` will refuse it. Blocked, with the
|
||||
/// one sentence that is actually true about it. See
|
||||
/// [ConsignmentStateX.awaitsHubInward].
|
||||
awaitingInward,
|
||||
|
||||
/// Closed some other way (cancelled, returned). Not deliverable, not an
|
||||
/// error the rider caused.
|
||||
closed,
|
||||
@@ -306,6 +341,7 @@ class ConsignmentGate {
|
||||
if (state.isDelivered) return DeliverGate.alreadyDelivered;
|
||||
if (state.needsRelease) return DeliverGate.needsRelease;
|
||||
if (state.awaitsHub) return DeliverGate.awaitingHub;
|
||||
if (state.awaitsHubInward) return DeliverGate.awaitingInward;
|
||||
if (state.isClosed) return DeliverGate.closed;
|
||||
return DeliverGate.unknown;
|
||||
}
|
||||
|
||||
@@ -165,7 +165,26 @@ abstract final class MilerLifecycle {
|
||||
'booking_status',
|
||||
]);
|
||||
final parsed = BookingStatus.parse(raw);
|
||||
final confirmed = parsed == BookingStatus.arrivedAtPickup;
|
||||
|
||||
// ── Arrival is a timestamp, not a status ──
|
||||
//
|
||||
// This asked whether the booking's `status` had become
|
||||
// `Arrived_At_Pickup`, and answered *unconfirmed* forever — because the
|
||||
// backend team has since confirmed there is **no Arrived rung in the
|
||||
// booking-status lifecycle at all**. It goes
|
||||
// `pickup_scheduled → converted_to_consignment → …`, and arrival is
|
||||
// recorded beside it as `reachedat`.
|
||||
//
|
||||
// So the app was demanding evidence of a transition the backend never
|
||||
// claimed to make, and logging a gap every time it did not get it. The
|
||||
// proof of arrival is the arrival stamp coming back; the status echoing
|
||||
// `Miler_Assigned` or `pickup_scheduled` is correct and expected.
|
||||
final stamp = _str(res.data, const [
|
||||
'reachedat',
|
||||
'reached_at',
|
||||
'arrivedat',
|
||||
]);
|
||||
final confirmed = stamp.isNotEmpty;
|
||||
|
||||
return StateTransition(
|
||||
outcome: confirmed
|
||||
@@ -173,9 +192,11 @@ abstract final class MilerLifecycle {
|
||||
: TransitionOutcome.unconfirmed,
|
||||
bookingStatus: parsed,
|
||||
consignmentState: ConsignmentState.unknown,
|
||||
evidence: raw.isEmpty
|
||||
? 'the response named no status at all'
|
||||
: 'the response reported status="$raw"',
|
||||
evidence: confirmed
|
||||
? 'the response stamped the arrival at "$stamp"'
|
||||
: raw.isEmpty
|
||||
? 'the response carried no arrival stamp and named no status'
|
||||
: 'the response carried no arrival stamp; status="$raw"',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
@@ -363,11 +364,22 @@ class MilerApi {
|
||||
|
||||
static Future<ApiResult> getProfile() => _send('GET', '/miler/profile');
|
||||
|
||||
/// ── Email and address are saveable now ──
|
||||
///
|
||||
/// The edit screen has always collected both and only `displayname` could be
|
||||
/// sent, so the other two lived on the device and were lost on a reinstall.
|
||||
/// The handler takes them as of this contract.
|
||||
///
|
||||
/// **Email is unique across users.** A collision comes back `409` with code
|
||||
/// `EMAIL_IN_USE`; sending the address the rider already has is a no-op
|
||||
/// rather than a conflict. Branch on [ApiResult.code], never the message.
|
||||
static Future<ApiResult> updateProfile({
|
||||
String? displayName,
|
||||
String? profilePhotoUrl,
|
||||
String? defaultVehicleType,
|
||||
String? phone,
|
||||
String? email,
|
||||
String? address,
|
||||
}) => _send(
|
||||
'PUT',
|
||||
'/miler/profile',
|
||||
@@ -376,6 +388,8 @@ class MilerApi {
|
||||
if (profilePhotoUrl != null) 'profilephotourl': profilePhotoUrl,
|
||||
if (defaultVehicleType != null) 'defaultvehicletype': defaultVehicleType,
|
||||
if (phone != null) 'phone': phone,
|
||||
if (email != null) 'email': email,
|
||||
if (address != null) 'address': address,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -827,6 +841,157 @@ class MilerApi {
|
||||
),
|
||||
);
|
||||
|
||||
/// ── Skipping a stop the rider has **not** collected yet ──
|
||||
///
|
||||
/// `POST /miler/consignments/:id/skip` keys on a consignment, and a booking
|
||||
/// he has not picked up does not have one — so the pre-pickup case had no
|
||||
/// route at all and the app recorded it locally, where the hub could not see
|
||||
/// it. This is that route.
|
||||
///
|
||||
/// It keeps the booking **assigned and resumable** (`resumable: true` comes
|
||||
/// back on success), which is the whole difference from
|
||||
/// [cancelBooking]: cancel gives the booking up and releases it for
|
||||
/// reassignment; this says *not now*.
|
||||
///
|
||||
/// Which of the three to call, in one line each:
|
||||
///
|
||||
/// ```
|
||||
/// not collected yet → skipBooking (this) resumable
|
||||
/// already collected → skipConsignment attemptcount++
|
||||
/// giving it up → cancelBooking released
|
||||
/// ```
|
||||
static Future<ApiResult> skipBooking(
|
||||
Object bookingId, {
|
||||
required String reason,
|
||||
double? lat,
|
||||
double? lon,
|
||||
}) => _guarded(
|
||||
'skip-booking:$bookingId',
|
||||
() => _send(
|
||||
'POST',
|
||||
'/miler/bookings/$bookingId/skip',
|
||||
idempotencyKey: _idempotencyKey('skip-booking', bookingId),
|
||||
body: {
|
||||
'reason': reason,
|
||||
if (lat != null) 'lat': lat,
|
||||
if (lon != null) 'lon': lon,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// PROOF UPLOAD — a signed URL, then a direct PUT
|
||||
//
|
||||
// The photograph a rider takes at a door had nowhere to go: `deliver` takes
|
||||
// `photourl`, which wants a URL, and nothing on the contract accepted an
|
||||
// upload — so proof lived on the phone and died with the next reinstall.
|
||||
//
|
||||
// Two steps, and the second does not touch this API at all:
|
||||
//
|
||||
// 1. POST /miler/uploads/sign → { uploadurl, url, method, headers }
|
||||
// 2. PUT the bytes to `uploadurl` with **exactly** those headers
|
||||
// 3. send `url` as `photourl` on deliver
|
||||
//
|
||||
// The object-store credentials stay server-side, which is the point of the
|
||||
// signed-URL shape: the app never holds a key.
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// What a proof upload is for. The server picks the bucket path from this,
|
||||
/// so it is not free text.
|
||||
static const String proofDelivery = 'delivery_proof';
|
||||
static const String proofPickup = 'pickup_proof';
|
||||
static const String proofSignature = 'receiver_signature';
|
||||
static const String proofSupport = 'support';
|
||||
|
||||
/// Step 1 — asks for somewhere to put an image.
|
||||
///
|
||||
/// The signature expires in ten minutes, so a failed upload is re-*signed*
|
||||
/// rather than retried against the old URL.
|
||||
static Future<ApiResult> signUpload({
|
||||
required String purpose,
|
||||
String contentType = 'image/jpeg',
|
||||
Object? consignmentId,
|
||||
}) => _send(
|
||||
'POST',
|
||||
'/miler/uploads/sign',
|
||||
body: {
|
||||
'purpose': purpose,
|
||||
'contentType': contentType,
|
||||
if (consignmentId != null) 'consignmentid': consignmentId,
|
||||
},
|
||||
);
|
||||
|
||||
/// Steps 1–3 in one call: sign, PUT the bytes, hand back the public URL.
|
||||
///
|
||||
/// Returns `null` when the photograph could not be uploaded, which is a
|
||||
/// **survivable** answer and never a reason to block a hand-over — the
|
||||
/// delivery is the thing that matters and `deliver` accepts an empty
|
||||
/// `photourl`. The caller records the delivery either way and keeps the
|
||||
/// local copy, so nothing is lost that was not already only local.
|
||||
///
|
||||
/// ── Why the headers are sent back verbatim ──
|
||||
///
|
||||
/// `x-amz-acl` is part of what was signed. Dropping it, or adding a header
|
||||
/// of our own, invalidates the signature and the store answers 403 — so the
|
||||
/// map returned by the sign call is used as-is rather than merged with
|
||||
/// anything this app thinks a request should carry. In particular the
|
||||
/// bearer token must **not** go to the object store.
|
||||
static Future<String?> uploadProof(
|
||||
File file, {
|
||||
required String purpose,
|
||||
Object? consignmentId,
|
||||
}) async {
|
||||
if (!file.existsSync()) return null;
|
||||
|
||||
final contentType = file.path.toLowerCase().endsWith('.png')
|
||||
? 'image/png'
|
||||
: 'image/jpeg';
|
||||
|
||||
final signed = await signUpload(
|
||||
purpose: purpose,
|
||||
contentType: contentType,
|
||||
consignmentId: consignmentId,
|
||||
);
|
||||
if (!signed.ok) {
|
||||
debugPrint('[UPLOAD] sign failed: ${signed.status} ${signed.message}');
|
||||
return null;
|
||||
}
|
||||
|
||||
final data = signed.data;
|
||||
final map = data is Map ? Map<String, dynamic>.from(data) : null;
|
||||
final uploadUrl = (map?['uploadurl'] ?? map?['uploadUrl'] ?? '')
|
||||
.toString()
|
||||
.trim();
|
||||
final publicUrl = (map?['url'] ?? '').toString().trim();
|
||||
if (uploadUrl.isEmpty || publicUrl.isEmpty) {
|
||||
debugPrint('[UPLOAD] sign returned no url pair');
|
||||
return null;
|
||||
}
|
||||
|
||||
final headers = <String, String>{};
|
||||
final raw = map?['headers'];
|
||||
if (raw is Map) {
|
||||
raw.forEach((k, v) => headers['$k'] = '$v');
|
||||
}
|
||||
// The store needs a content type even if the signer did not name one.
|
||||
headers.putIfAbsent('Content-Type', () => contentType);
|
||||
|
||||
try {
|
||||
final res = await http
|
||||
.put(
|
||||
Uri.parse(uploadUrl),
|
||||
headers: headers,
|
||||
body: await file.readAsBytes(),
|
||||
)
|
||||
.timeout(const Duration(seconds: 30));
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) return publicUrl;
|
||||
debugPrint('[UPLOAD] PUT ${res.statusCode} — ${res.body}');
|
||||
} catch (e) {
|
||||
debugPrint('[UPLOAD] PUT failed: $e');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Bumps `attemptcount` rather than failing the consignment — a skip is a
|
||||
/// return visit, not an outcome.
|
||||
static Future<ApiResult> skipConsignment(
|
||||
@@ -926,6 +1091,19 @@ class MilerApi {
|
||||
|
||||
static Future<ApiResult> getStatus() => _send('GET', '/miler/status');
|
||||
|
||||
/// The pickup locations a tenant runs — the counters, branches or kitchens
|
||||
/// the rider collects from.
|
||||
///
|
||||
/// `GET /admin/tenants/:tenantid/locations`. **Note the path is under
|
||||
/// `/admin`, not `/miler`**, which is the one thing worth knowing about it:
|
||||
/// every other route this class calls is on the rider surface, and whether a
|
||||
/// rider's bearer token is accepted here is the backend's decision, not
|
||||
/// ours. A 401 or 403 is therefore an ordinary outcome and not a bug —
|
||||
/// [PickupLocations] treats it as "no names available" and the app carries
|
||||
/// on with whatever the booking row said.
|
||||
static Future<ApiResult> tenantLocations(Object tenantId) =>
|
||||
_send('GET', '/admin/tenants/$tenantId/locations');
|
||||
|
||||
/// A JSON **array**, even for one entry — the handler decodes a list.
|
||||
static Future<ApiResult> postConsignmentLogs(
|
||||
List<ConsignmentLogEntry> entries,
|
||||
@@ -974,12 +1152,20 @@ class MilerApi {
|
||||
/// rather than scattered as `logGap` calls so there is one list to hand the
|
||||
/// backend team. See the write-up in ABOUT_MILER.md §8.
|
||||
static const List<String> missingFromBackend = [
|
||||
'per-stop type (pickup | delivery) and step ordering on GET /miler/bookings',
|
||||
'COD amount on the booking object',
|
||||
'a booking-level resume after skip (skip lives on the consignment only)',
|
||||
// ── Delivered since this list was written, and removed from it ──
|
||||
//
|
||||
// Four of the seven entries here were shipped by the backend and the app
|
||||
// now reads them, so they are gone rather than kept as history:
|
||||
//
|
||||
// per-stop `stoptype` + `step` on GET /miler/bookings → read by
|
||||
// [ApiConfig.adaptBooking]; the mixed route is reachable
|
||||
// `codamount` / `paymentmode` on the booking → read
|
||||
// a booking-level skip → [skipBooking]
|
||||
// `cancelled_stops` / `total_stops` on earnings → read
|
||||
//
|
||||
// What is left is what is still genuinely absent.
|
||||
'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
|
||||
@@ -989,6 +1175,27 @@ class MilerApi {
|
||||
// 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)',
|
||||
// Proof of delivery is a local file path today: `photourl` wants a URL and
|
||||
// nothing in the contract accepts an upload, so the photograph the rider
|
||||
// takes never leaves his phone. See [ProofStore].
|
||||
'an upload route for the proof-of-delivery photograph',
|
||||
// ── Answered 24 Aug 2026, and off this list ──
|
||||
//
|
||||
// the route sequence → automatic on every assignment; `sequencedat`
|
||||
// is the authority signal and `step` stays on the row through the
|
||||
// pickup leg. `step: 0` with a null stamp now means one of three
|
||||
// stated things, none of which is a route. See [RouteOrder].
|
||||
// a trip / slot id → there is none, and none is planned. The
|
||||
// day-part split is client behaviour by agreement. See [TripSlots].
|
||||
// an upload route → shipped: `POST /miler/uploads/sign`, and the
|
||||
// proof photo reaches the hub. See [uploadProof].
|
||||
//
|
||||
// What is left is what is still genuinely absent.
|
||||
// A rider payout rate. `ridercharges` holds the *client's* order price,
|
||||
// not the rider's pay, and `bonuspoints` is unused — confirmed by the
|
||||
// backend, with a real rate-card named as a separate build. Nothing in
|
||||
// this app may present either figure as what a rider earned.
|
||||
'a rider payout rate — ridercharges is the client price, not rider pay',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:miler/Models/stop_status.dart';
|
||||
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
|
||||
import 'package:miler/data/pickup_locations.dart';
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
@@ -136,14 +137,33 @@ class MilkRun {
|
||||
/// prevent, produced by the two halves of the decision disagreeing.
|
||||
///
|
||||
/// One key list, one answer.
|
||||
static String sourceNameOf(Map<String, dynamic> stop) =>
|
||||
(stop['sourcename'] ??
|
||||
stop['SourceName'] ??
|
||||
stop['kitchenname'] ??
|
||||
stop['KitchenName'] ??
|
||||
'')
|
||||
.toString()
|
||||
.trim();
|
||||
///
|
||||
/// ── The tenant's own location list wins ──
|
||||
///
|
||||
/// The booking row's `sourcename` is filled by the backend from
|
||||
/// `providercompany` / `providerlocation`, and what lands there is often
|
||||
/// whoever is on the account rather than the counter — the route card's
|
||||
/// heading, the biggest type on Home, read `Sudharsan`.
|
||||
///
|
||||
/// The booking does carry the location's **id**, and the tenant's locations
|
||||
/// are a list with proper names on them, so the name is joined rather than
|
||||
/// read off the row. See [PickupLocations]: when the table is empty — not
|
||||
/// loaded yet, or the rider's token is not admitted to the admin route —
|
||||
/// this falls through to exactly what it returned before.
|
||||
static String sourceNameOf(Map<String, dynamic> stop) {
|
||||
final resolved = PickupLocations.nameFor(
|
||||
stop['sourceid'] ?? stop['kitchenid'] ?? stop['pickuplocationid'],
|
||||
);
|
||||
if (resolved.isNotEmpty) return resolved;
|
||||
|
||||
return (stop['sourcename'] ??
|
||||
stop['SourceName'] ??
|
||||
stop['kitchenname'] ??
|
||||
stop['KitchenName'] ??
|
||||
'')
|
||||
.toString()
|
||||
.trim();
|
||||
}
|
||||
|
||||
/// True when two stops are collected from the same counter.
|
||||
static bool sameSource(Map<String, dynamic> a, Map<String, dynamic> b) =>
|
||||
@@ -300,8 +320,8 @@ class MilkRun {
|
||||
.trim();
|
||||
return name.isEmpty ? 'customer' : name;
|
||||
}
|
||||
final kitchen = sourceNameOf(stop);
|
||||
if (kitchen.isNotEmpty) return kitchen;
|
||||
final place = pickupPlaceOf(stop);
|
||||
if (place.isNotEmpty) return place;
|
||||
|
||||
// ── The word "pickup" is never a place ──
|
||||
//
|
||||
@@ -313,11 +333,31 @@ class MilkRun {
|
||||
// promoted to a title.
|
||||
//
|
||||
// A rider thinks in PLACES, and the payload still knows one: the pickup
|
||||
// address. Its first non-numeric component is the neighbourhood — the
|
||||
// same reading the timeline's area line uses — and "RS Puram" is
|
||||
// something he can ride to in a way "pickup" is not. Only when the
|
||||
// payload has no address either does the label fall back to a word, and
|
||||
// then it is at least a capitalised noun.
|
||||
// address. Only when it has no address either does the label fall back to
|
||||
// a word, and then it is at least a capitalised noun. See [pickupPlaceOf].
|
||||
return 'Pickup';
|
||||
}
|
||||
|
||||
/// **Where the rider is collecting from**, or `''` when the payload says.
|
||||
///
|
||||
/// ── Why this is not the customer's name ──
|
||||
///
|
||||
/// The route card's heading is the biggest type on Home, and on a group of
|
||||
/// one order it was the *customer* — so a rider planning his next collection
|
||||
/// read `Sudharsan`, which is a person, not somewhere he can ride to. The
|
||||
/// place was on the card the whole time, one line down, in grey.
|
||||
///
|
||||
/// The order of preference is the order of usefulness at a kerb:
|
||||
///
|
||||
/// 1. the counter's own name — what is written on the sign
|
||||
/// 2. the neighbourhood off the pickup address — what he steers by
|
||||
///
|
||||
/// Split out of [navigationLabel] so a caller can tell "no place in this
|
||||
/// payload" from the word *Pickup*, which reads as a name and is not one.
|
||||
static String pickupPlaceOf(Map<String, dynamic> stop) {
|
||||
final kitchen = sourceNameOf(stop);
|
||||
if (kitchen.isNotEmpty) return kitchen;
|
||||
|
||||
for (final key in const ['pickupaddress', 'PickupAddress']) {
|
||||
final raw = (stop[key] ?? '').toString().trim();
|
||||
if (raw.isEmpty) continue;
|
||||
@@ -333,7 +373,7 @@ class MilkRun {
|
||||
return p.replaceFirst(RegExp(r'^[0-9][0-9/\-]*\s+'), '');
|
||||
}
|
||||
}
|
||||
return 'Pickup';
|
||||
return '';
|
||||
}
|
||||
|
||||
/// Stops still owing the rider a collection at a given counter.
|
||||
@@ -387,6 +427,23 @@ class MilkRun {
|
||||
required Set<String> collectedIds,
|
||||
Set<String> outForDeliveryIds = const {},
|
||||
Set<String> deliveredIds = const {},
|
||||
|
||||
/// Stops the rider has reported arriving at.
|
||||
///
|
||||
/// ── Why arrival is a set and not a status ──
|
||||
///
|
||||
/// The backend team confirmed there is no Arrived rung in the booking
|
||||
/// lifecycle: `/reached` records the arrival as a **timestamp**
|
||||
/// (`reachedat`) beside a status that stays `pickup_scheduled`. And
|
||||
/// `GET /miler/bookings` does not return that stamp — see
|
||||
/// `getArrivedOrderIds` — so there is no server field to reconstruct
|
||||
/// arrival from after a refresh.
|
||||
///
|
||||
/// It therefore arrives the same way the other two rider-owned facts do,
|
||||
/// as a set of ids, and it is subject to the same precedence: it speaks
|
||||
/// only where nothing further along has happened. Delete this parameter,
|
||||
/// not the rung, on the day the stamp appears on a booking row.
|
||||
Set<String> arrivedIds = const {},
|
||||
}) {
|
||||
final id = idOf(stop);
|
||||
final reported = stopStatusOf(stop);
|
||||
@@ -422,7 +479,15 @@ class MilkRun {
|
||||
reported == StopStatus.outForDelivery) {
|
||||
return StopStatus.picked;
|
||||
}
|
||||
if (reported == StopStatus.arrived) return StopStatus.arrived;
|
||||
// ── Arrived, from either source ──
|
||||
//
|
||||
// Below everything above it, deliberately: the pickup milestone outranks
|
||||
// the arrival, and the delivery rungs outrank both. A rider's own arrival
|
||||
// record can never walk a stop the hub has moved on backwards — the same
|
||||
// rule the local store obeys everywhere else in the app.
|
||||
if (reported == StopStatus.arrived || arrivedIds.contains(id)) {
|
||||
return StopStatus.arrived;
|
||||
}
|
||||
if (acceptedIds.contains(id) || reported == StopStatus.accepted) {
|
||||
return StopStatus.accepted;
|
||||
}
|
||||
|
||||
185
lib/data/pickup_locations.dart
Normal file
185
lib/data/pickup_locations.dart
Normal file
@@ -0,0 +1,185 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:miler/data/api_config.dart';
|
||||
import 'package:miler/data/miler_api.dart';
|
||||
import 'package:miler/data/service_profile.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// WHAT THE PICKUP IS CALLED, FROM THE TENANT'S OWN LOCATION LIST
|
||||
///
|
||||
/// ── The name on the card was a person ──
|
||||
///
|
||||
/// The route card's heading is the biggest type on Home, and it read
|
||||
/// `Sudharsan` — a contact person — above a distance and an ETA to a place
|
||||
/// that name does not identify. It comes from `sourcename` on the booking row,
|
||||
/// which the backend fills from `providercompany` / `providerlocation`, and
|
||||
/// what lands there is whoever is on the account rather than the counter.
|
||||
///
|
||||
/// The app cannot tell the two apart. `Sudharsan` and `Sri Balaji Stores` are
|
||||
/// both just strings, so there is no rule that fixes the bad ones without
|
||||
/// breaking every stop where the field is right.
|
||||
///
|
||||
/// ── The join that does answer it ──
|
||||
///
|
||||
/// The booking already carries the location's **id** (`pickuplocationid` /
|
||||
/// `sourceid`), and the tenant's locations are a list with proper names on
|
||||
/// them:
|
||||
///
|
||||
/// ```
|
||||
/// GET /admin/tenants/:tenantid/locations
|
||||
/// stop.pickuplocationid ──▶ location.name
|
||||
/// ```
|
||||
///
|
||||
/// So the name is looked up rather than read off the booking. One request per
|
||||
/// session for the whole tenant, held in memory, and every screen that names a
|
||||
/// pickup reads it through [MilkRun.sourceNameOf].
|
||||
///
|
||||
/// ── Why every failure here is silent ──
|
||||
///
|
||||
/// The route is under `/admin`, not `/miler`. Whether a rider's token is
|
||||
/// accepted on it is the backend's decision and not something this app should
|
||||
/// depend on. A 401, a 403, a shape this build cannot read, a dead network —
|
||||
/// all resolve to an empty table, and an empty table means the booking's own
|
||||
/// `sourcename` is used exactly as it is today. **Nothing on this path may
|
||||
/// ever stop a rider working.**
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
abstract final class PickupLocations {
|
||||
/// `locationid` → the name a rider can read off a sign. Empty until
|
||||
/// [ensureLoaded] has run and the backend has answered.
|
||||
static Map<String, String> _byId = const {};
|
||||
|
||||
/// In-flight load, so a screen rebuilding mid-fetch joins the request that
|
||||
/// is already running instead of starting a second one.
|
||||
static Future<void>? _loading;
|
||||
|
||||
/// True once a load has completed, however it went. A tenant with no
|
||||
/// locations and a tenant whose locations we were refused look the same from
|
||||
/// here, and both mean "stop asking".
|
||||
static bool _settled = false;
|
||||
|
||||
/// The name for a location id, or `''` when this build has none.
|
||||
///
|
||||
/// Synchronous on purpose: it is read from `build`, and a name that arrives
|
||||
/// one frame late is better than a widget tree that has to await.
|
||||
static String nameFor(Object? locationId) {
|
||||
final id = locationId?.toString().trim() ?? '';
|
||||
if (id.isEmpty || id == '0') return '';
|
||||
return _byId[id] ?? '';
|
||||
}
|
||||
|
||||
/// True when the table holds anything at all.
|
||||
static bool get isLoaded => _byId.isNotEmpty;
|
||||
|
||||
/// Loads the rider's tenant's locations, once.
|
||||
///
|
||||
/// Safe to call on every queue fetch — after the first completed attempt it
|
||||
/// returns immediately. [force] re-asks, for a rider who has just changed
|
||||
/// tenant.
|
||||
static Future<void> ensureLoaded({bool force = false}) {
|
||||
if (force) {
|
||||
_settled = false;
|
||||
_loading = null;
|
||||
}
|
||||
if (_settled) return Future<void>.value();
|
||||
return _loading ??= _load();
|
||||
}
|
||||
|
||||
static Future<void> _load() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final tenantId = prefs.getInt(TenantController.kTenantId) ?? 0;
|
||||
if (tenantId <= 0) {
|
||||
// No tenant on this device yet — not a failure, just too early. Left
|
||||
// unsettled so the next fetch tries again once the login has landed.
|
||||
_loading = null;
|
||||
return;
|
||||
}
|
||||
|
||||
final res = await MilerApi.tenantLocations(tenantId);
|
||||
if (!res.ok) {
|
||||
// The expected outcome if riders are not admitted to `/admin`. Logged
|
||||
// as a gap rather than an error: it is a question for the backend, and
|
||||
// the app is already correct without it.
|
||||
ApiConfig.logGap(
|
||||
'admin/tenants/:id/locations',
|
||||
'tenant $tenantId locations came back ${res.status} ${res.message}. '
|
||||
'Pickup headings will use the booking row\'s own `sourcename`, '
|
||||
'which is a contact person on some rows. If riders are meant to '
|
||||
'read this route, it needs to accept a miler token.',
|
||||
);
|
||||
_settled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
final table = <String, String>{};
|
||||
for (final row in res.list) {
|
||||
if (row is! Map) continue;
|
||||
final m = row.map((k, v) => MapEntry(k.toString(), v));
|
||||
final id = _first(m, const [
|
||||
'pickuplocationid',
|
||||
'pickupLocationId',
|
||||
'locationid',
|
||||
'locationId',
|
||||
'location_id',
|
||||
'id',
|
||||
]);
|
||||
final name = _first(m, const [
|
||||
'locationname',
|
||||
'locationName',
|
||||
'location_name',
|
||||
'name',
|
||||
'branchname',
|
||||
'branchName',
|
||||
'storename',
|
||||
'storeName',
|
||||
'kitchenname',
|
||||
'kitchenName',
|
||||
'title',
|
||||
]);
|
||||
if (id.isEmpty || name.isEmpty) continue;
|
||||
table[id] = name;
|
||||
}
|
||||
|
||||
_byId = table;
|
||||
_settled = true;
|
||||
debugPrint('[LOCATIONS] tenant $tenantId → ${table.length} named');
|
||||
if (table.isEmpty && kDebugMode) {
|
||||
// The one thing that makes a shape mismatch diagnosable without
|
||||
// another round trip. Debug-only: a response body is not something to
|
||||
// write into a release log.
|
||||
debugPrint('[LOCATIONS] no id/name pair recognised in: ${res.raw}');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[LOCATIONS] could not load: $e');
|
||||
_settled = true;
|
||||
} finally {
|
||||
_loading = null;
|
||||
}
|
||||
}
|
||||
|
||||
static String _first(Map<String, dynamic> m, List<String> keys) {
|
||||
for (final k in keys) {
|
||||
final v = m[k];
|
||||
if (v == null || v is Map || v is List) continue;
|
||||
final s = v.toString().trim();
|
||||
if (s.isNotEmpty && s.toLowerCase() != 'null' && s != '0') return s;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/// Drops the table. For sign-out and for tests.
|
||||
@visibleForTesting
|
||||
static void reset() {
|
||||
_byId = const {};
|
||||
_settled = false;
|
||||
_loading = null;
|
||||
}
|
||||
|
||||
/// Seeds the table directly, for tests that must not touch the network.
|
||||
@visibleForTesting
|
||||
static void seed(Map<String, String> byId) {
|
||||
_byId = Map<String, String>.from(byId);
|
||||
_settled = true;
|
||||
}
|
||||
}
|
||||
244
lib/data/rider_stage.dart
Normal file
244
lib/data/rider_stage.dart
Normal file
@@ -0,0 +1,244 @@
|
||||
import 'package:miler/Models/stop_status.dart';
|
||||
import 'package:miler/data/consignment_state.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE RIDER-FACING LIFECYCLE
|
||||
///
|
||||
/// Six stages, in the order a rider walks them:
|
||||
///
|
||||
/// ```
|
||||
/// Pending → Accepted → Arrived → Picked → Active → Delivered
|
||||
/// ↘ Cancelled
|
||||
/// ```
|
||||
///
|
||||
/// ── Why this is not the backend's status ──
|
||||
///
|
||||
/// The backend's booking lifecycle has five rungs and no arrival in it:
|
||||
///
|
||||
/// ```
|
||||
/// pending / miler_assigned the offer
|
||||
/// pickup_scheduled the rider accepted
|
||||
/// converted_to_consignment the pickup completed
|
||||
/// active / out_for_delivery the delivery is under way
|
||||
/// delivered done
|
||||
/// ```
|
||||
///
|
||||
/// Arrival is not on that list because it is not a booking status — it is an
|
||||
/// **event**, persisted by `POST /miler/bookings/:id/reached` as a timestamp
|
||||
/// beside a status that does not move. Asking the backend to add an `Arrived`
|
||||
/// rung would be asking it to model an event as a state; the app is the side
|
||||
/// that owes the richer workflow, and this is where it is owed.
|
||||
///
|
||||
/// So a stage is derived from *facts*, and which fact answers depends on which
|
||||
/// half of the day the order is in:
|
||||
///
|
||||
/// ```
|
||||
/// before the pickup the BOOKING's status, plus `reachedat` beside it
|
||||
/// after the pickup the CONSIGNMENT's status
|
||||
/// ```
|
||||
///
|
||||
/// The booking settles on `Converted_To_Consignment` and stops moving, so the
|
||||
/// later half cannot be read off it — every stop would freeze at Picked. The
|
||||
/// rider's own records remain as a fallback for a row the queue has not caught
|
||||
/// up with, and for a deployment that does not return `reachedat` yet.
|
||||
///
|
||||
/// ── One rule: progress never runs backwards ──
|
||||
///
|
||||
/// The list is walked from the far end. The first evidence found wins, so a
|
||||
/// stale local record can never pull a stop back down the ladder: an arrival
|
||||
/// note cannot un-pick a collected parcel, a collection cannot un-deliver an
|
||||
/// order. This is the same precedence the pickup-surface resolver
|
||||
/// ([MilkRun.stageOf]) applies within its own half of the day, stated once for
|
||||
/// the whole of it.
|
||||
///
|
||||
/// ── And it does not replace the pickup rung ──
|
||||
///
|
||||
/// [MilkRun.stageOf] answers *how far did the pickup get* and is what a pickup
|
||||
/// card reads — which is why a stop whose delivery is under way still shows
|
||||
/// **Picked** there. This answers *where is this order in the rider's day*, for
|
||||
/// the surfaces that need the whole arc: a record, a timeline, a status line.
|
||||
/// Both are derived, neither is stored, and the two are allowed to differ
|
||||
/// because they are answering different questions about the same order.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
enum RiderStage {
|
||||
/// Offered, or assigned and not yet taken.
|
||||
pending,
|
||||
|
||||
/// `pickup_scheduled` — the rider accepted it.
|
||||
accepted,
|
||||
|
||||
/// He is at the source. The booking's status is still `Pickup_Scheduled`;
|
||||
/// what says he is there is [hasArrivalStamp] — `reachedat` on the row.
|
||||
arrived,
|
||||
|
||||
/// The consignment exists at `Collected_By_Miler`, or is somewhere in the
|
||||
/// hub's half of the network. The pickup is complete and **stays** complete:
|
||||
/// nothing after this un-picks it.
|
||||
picked,
|
||||
|
||||
/// `Out_for_Delivery` — the rider pressed **Start delivery** and the round is
|
||||
/// his. Deliberately not the hub's rungs; see [riderStageOf].
|
||||
active,
|
||||
|
||||
/// Terminal.
|
||||
delivered,
|
||||
|
||||
/// Terminal, and not a failure of the rider's: a cancelled or rejected stop.
|
||||
cancelled;
|
||||
|
||||
/// What the rider is shown.
|
||||
String get label => switch (this) {
|
||||
RiderStage.pending => 'Pending',
|
||||
RiderStage.accepted => 'Accepted',
|
||||
RiderStage.arrived => 'Arrived',
|
||||
RiderStage.picked => 'Picked',
|
||||
RiderStage.active => 'Out for delivery',
|
||||
RiderStage.delivered => 'Delivered',
|
||||
RiderStage.cancelled => 'Cancelled',
|
||||
};
|
||||
|
||||
/// How far along the arc this is, for comparing two readings of one order.
|
||||
/// [cancelled] sits outside the run and is deliberately last.
|
||||
int get rank => switch (this) {
|
||||
RiderStage.pending => 0,
|
||||
RiderStage.accepted => 1,
|
||||
RiderStage.arrived => 2,
|
||||
RiderStage.picked => 3,
|
||||
RiderStage.active => 4,
|
||||
RiderStage.delivered => 5,
|
||||
RiderStage.cancelled => 6,
|
||||
};
|
||||
|
||||
/// True once the pickup half is behind him, whatever the delivery is doing.
|
||||
/// This is the fact a pickup surface renders **Picked** from.
|
||||
bool get pickupComplete =>
|
||||
rank >= RiderStage.picked.rank && this != RiderStage.cancelled;
|
||||
}
|
||||
|
||||
/// Reads one order's stage from the evidence available about it.
|
||||
///
|
||||
/// [reported] is the booking's own status, already parsed. The three sets are
|
||||
/// the rider's own records, passed in rather than read here so this stays pure
|
||||
/// and so a caller can answer for a stop it holds no records for.
|
||||
RiderStage riderStageOf(
|
||||
Map<String, dynamic> stop, {
|
||||
Set<String> arrivedIds = const {},
|
||||
Set<String> collectedIds = const {},
|
||||
Set<String> outForDeliveryIds = const {},
|
||||
Set<String> deliveredIds = const {},
|
||||
String? id,
|
||||
}) {
|
||||
final key = id ?? (stop['orderid'] ?? stop['pickupid'] ?? '').toString();
|
||||
final reported = stopStatusOf(stop);
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// POST-PICKUP · the consignment is the authority
|
||||
//
|
||||
// Once `pickup-complete` has run there is a consignment, and it — not the
|
||||
// booking — is what the rest of the day happens to. The booking's own status
|
||||
// settles on `Converted_To_Consignment` and stops moving, so reading the
|
||||
// later half of the lifecycle off it would freeze every stop at Picked.
|
||||
//
|
||||
// Checked first, and from the far end, so newer evidence always wins over
|
||||
// older: a stale arrival note cannot un-pick a collected parcel and a stale
|
||||
// collection cannot un-deliver an order.
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
final consignment = consignmentStateFromRaw(stop['consignmentstatus']);
|
||||
if (consignment != ConsignmentState.unknown) {
|
||||
switch (consignment) {
|
||||
case ConsignmentState.delivered:
|
||||
return RiderStage.delivered;
|
||||
case ConsignmentState.cancelled:
|
||||
case ConsignmentState.returnedToSender:
|
||||
case ConsignmentState.rtoInitiated:
|
||||
case ConsignmentState.missing:
|
||||
case ConsignmentState.damaged:
|
||||
return RiderStage.cancelled;
|
||||
case ConsignmentState.outForDelivery:
|
||||
return RiderStage.active;
|
||||
// ── The hub half of the network is NOT this rider being active ──
|
||||
//
|
||||
// A cross-city parcel goes `Collected_By_Miler → Inwarded_at_Hub →
|
||||
// Tripsheet_Loaded → In_Transit` and is delivered by somebody else
|
||||
// entirely. Reading any of those as *Active* would tell a rider he is out
|
||||
// delivering a parcel he handed to a hub yesterday — and would put a
|
||||
// delivery control on a stop he cannot act on at all.
|
||||
//
|
||||
// His own involvement ended at the collection, so that is the stage he
|
||||
// sees: **Picked**, and nothing further.
|
||||
case ConsignmentState.collectedByMiler:
|
||||
case ConsignmentState.created:
|
||||
case ConsignmentState.inwardedAtHub:
|
||||
case ConsignmentState.tripsheetLoaded:
|
||||
case ConsignmentState.inTransit:
|
||||
return RiderStage.picked;
|
||||
case ConsignmentState.unknown:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// The rider's own records, for a row whose consignment status has not caught
|
||||
// up yet — a stop collected seconds ago, or a queue a poll behind him.
|
||||
if (reported.isCancelled || reported.isRejected) return RiderStage.cancelled;
|
||||
if (deliveredIds.contains(key) || reported == StopStatus.delivered) {
|
||||
return RiderStage.delivered;
|
||||
}
|
||||
if (outForDeliveryIds.contains(key) ||
|
||||
reported == StopStatus.outForDelivery ||
|
||||
reported == StopStatus.deliveryArrived ||
|
||||
reported == StopStatus.active) {
|
||||
return RiderStage.active;
|
||||
}
|
||||
if (collectedIds.contains(key) || reported.isPicked) return RiderStage.picked;
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// PRE-PICKUP · the booking status, plus the arrival event beside it
|
||||
//
|
||||
// `Pickup_Scheduled` with no stamp is Accepted; the same status with a stamp
|
||||
// is Arrived. That is the whole of the backend's arrival contract, and it is
|
||||
// why there is no `Arrived_At_Pickup` to look for.
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
if (hasArrivalStamp(stop) ||
|
||||
arrivedIds.contains(key) ||
|
||||
reported == StopStatus.arrived) {
|
||||
return RiderStage.arrived;
|
||||
}
|
||||
if (reported == StopStatus.accepted) return RiderStage.accepted;
|
||||
return RiderStage.pending;
|
||||
}
|
||||
|
||||
/// Whether the server says this stop has been arrived at.
|
||||
///
|
||||
/// ── The one field, read defensively ──
|
||||
///
|
||||
/// `reachedat` is a timestamp the backend writes and returns. It is read for
|
||||
/// *presence*, not for its value: what the stage needs to know is whether an
|
||||
/// arrival happened, and any non-empty stamp answers that. A malformed one is
|
||||
/// still a stamp — the backend does not write the field for a booking nobody
|
||||
/// arrived at — so it is accepted rather than parsed and discarded.
|
||||
///
|
||||
/// The two obvious lies are refused: an empty string, and the literal `null`
|
||||
/// that arrives when a JSON null has been stringified somewhere upstream.
|
||||
///
|
||||
/// ── This is what retires the local record ──
|
||||
///
|
||||
/// While a deployment does not return the field, this answers false and the
|
||||
/// arrival set carries the rung — exactly as it does today. The moment a row
|
||||
/// carries a stamp, the server's answer is used and the local set is not
|
||||
/// consulted for it. Nothing has to be switched over: the rollout is per-row,
|
||||
/// and the set can be deleted once no row is missing the stamp.
|
||||
bool hasArrivalStamp(Map<String, dynamic> stop) {
|
||||
for (final key in const [
|
||||
'reachedat',
|
||||
'reachedAt',
|
||||
'reached_at',
|
||||
'arrivedat',
|
||||
]) {
|
||||
final raw = stop[key];
|
||||
if (raw == null) continue;
|
||||
final s = raw.toString().trim();
|
||||
if (s.isEmpty || s.toLowerCase() == 'null') continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -67,7 +67,7 @@ extension RouteOrderSourceX on RouteOrderSource {
|
||||
/// What the rider is told the list is ordered by. Short, because it sits
|
||||
/// under a heading and not in a paragraph.
|
||||
String get label => switch (this) {
|
||||
RouteOrderSource.adminSequence => 'Hub route',
|
||||
RouteOrderSource.adminSequence => 'Assigned route',
|
||||
RouteOrderSource.bookedTime => 'By booked time',
|
||||
RouteOrderSource.backendOrder => 'As assigned',
|
||||
RouteOrderSource.proximity => 'Nearest first',
|
||||
@@ -76,7 +76,8 @@ extension RouteOrderSourceX on RouteOrderSource {
|
||||
/// The longer form, for a place with room to explain — and specifically to
|
||||
/// keep the app from implying the hub planned an order it did not.
|
||||
String get explanation => switch (this) {
|
||||
RouteOrderSource.adminSequence => 'Ordered by the route your hub assigned.',
|
||||
RouteOrderSource.adminSequence =>
|
||||
'Ordered by the route your office assigned.',
|
||||
RouteOrderSource.bookedTime =>
|
||||
'No route assigned — ordered by booked time.',
|
||||
RouteOrderSource.backendOrder =>
|
||||
@@ -122,9 +123,43 @@ abstract final class RouteOrder {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Field names that have carried the moment the route was solved.
|
||||
static const List<String> sequencedAtKeys = [
|
||||
'sequencedat',
|
||||
'sequencedAt',
|
||||
'sequenced_at',
|
||||
'routesequencedat',
|
||||
];
|
||||
|
||||
/// True when this stop carries a solve timestamp.
|
||||
///
|
||||
/// ── The authority signal, confirmed by the backend ──
|
||||
///
|
||||
/// `sequencedat` is what says a route exists: **non-null → follow `step`
|
||||
/// exactly; null → no route was assigned, use a fallback.** Sequencing is
|
||||
/// automatic on every assignment — there is no operator action to wait for —
|
||||
/// so `step: 0` with a null stamp means one of exactly three things: the
|
||||
/// rider has fewer than two active stops, a stop is missing coordinates, or
|
||||
/// the row predates the fix. None of those is a route to follow.
|
||||
static bool isSequenced(Map<String, dynamic> stop) {
|
||||
for (final key in sequencedAtKeys) {
|
||||
final raw = stop[key];
|
||||
if (raw == null) continue;
|
||||
if (raw.toString().trim().isEmpty) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// True when the hub has solved an order for at least one of these stops.
|
||||
///
|
||||
/// The stamp is the authority and a positive `step` is accepted alongside
|
||||
/// it: a deployment that populates one without the other is still telling
|
||||
/// the app it has a route, and refusing to follow a numbered sequence
|
||||
/// because its timestamp is missing would be reading the contract against
|
||||
/// the rider. Either is enough; neither means no route.
|
||||
static bool hasAdminSequence(Iterable<Map<String, dynamic>> stops) =>
|
||||
stops.any((s) => sequenceOf(s) > 0);
|
||||
stops.any((s) => isSequenced(s) || sequenceOf(s) > 0);
|
||||
|
||||
/// Puts [stops] in the order they are to be worked, and says which rule it
|
||||
/// used.
|
||||
|
||||
@@ -31,6 +31,13 @@
|
||||
/// the rider would say too. Nothing here converts to UTC: doing so would move
|
||||
/// the boundary to 05:30 local in IST and split every evening shift in half.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
library;
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
|
||||
import 'package:miler/views/Dashboard/activity/activity_format.dart'
|
||||
show parseStamp;
|
||||
|
||||
abstract final class ServiceDay {
|
||||
/// `2026-08-21` for [at], in the local calendar. The same shape both stores
|
||||
/// write, so a stamp and a query can be compared as strings.
|
||||
@@ -51,11 +58,37 @@ abstract final class ServiceDay {
|
||||
|
||||
/// The timestamps to fall back on when a row carries no day stamp — an API
|
||||
/// row, say, which has never been through either local store.
|
||||
///
|
||||
/// ── Why this list is the one [happenedAt] uses ──
|
||||
///
|
||||
/// It used to read `completedat, skippedat, deliveredat, updatedat,
|
||||
/// createdat`, and only the first two of those are fields the backend
|
||||
/// actually sends. A booking finished yesterday comes back from
|
||||
/// `GET /miler/bookings` carrying `pickedtime` and `updatedon` — neither of
|
||||
/// which was looked at — so [of] returned `''`, [belongsToToday] took the
|
||||
/// undated branch and kept it, and yesterday's work sat among this morning's
|
||||
/// on a screen whose entire premise is that it shows today.
|
||||
///
|
||||
/// The filter was never the problem: it was asking the row a question in
|
||||
/// field names the row does not speak. So the key order is now the same one
|
||||
/// [happenedAt] sorts by — the app's own stamps first, then what the API
|
||||
/// really carries — because "when did this happen" and "which day does this
|
||||
/// belong to" are one fact, and reading it out of two different lists is how
|
||||
/// they came to disagree.
|
||||
static const List<String> timeKeys = [
|
||||
// Written by the app at the moment the rider acted: the most accurate, and
|
||||
// the only ones present on a stop the backend has not caught up with.
|
||||
'completedat',
|
||||
'skippedat',
|
||||
// What the backend sends.
|
||||
'pickedtime',
|
||||
'picked_time',
|
||||
'deliverytime',
|
||||
'deliveredat',
|
||||
'updatedon',
|
||||
'modifiedon',
|
||||
'updatedat',
|
||||
'expected_pickup_time',
|
||||
'createdat',
|
||||
];
|
||||
|
||||
@@ -69,27 +102,134 @@ abstract final class ServiceDay {
|
||||
if (v.length >= 10) return v.substring(0, 10);
|
||||
}
|
||||
for (final k in timeKeys) {
|
||||
final raw = (row[k] ?? '').toString().trim();
|
||||
if (raw.isEmpty) continue;
|
||||
final t = DateTime.tryParse(raw);
|
||||
if (t != null) return stamp(t.isUtc ? t.toLocal() : t);
|
||||
// ── Through [parseStamp], and *not* through `toLocal()` ──
|
||||
//
|
||||
// Doormile timestamps are IST wall-clock in naive Postgres columns, and
|
||||
// some come back with a trailing `Z` they never had. A bare
|
||||
// `DateTime.tryParse` believes the marker, `toLocal()` then shifts it by
|
||||
// +5:30, and a stop finished at 23:50 lands on the *next* calendar day —
|
||||
// so the last stop of an evening shift vanished from Activity the moment
|
||||
// it was completed, and reappeared as tomorrow's. [parseStamp] strips
|
||||
// the false marker and reads the digits the backend meant, which is the
|
||||
// day the rider would name.
|
||||
final t = parseStamp(row[k]);
|
||||
if (t != null) return stamp(t);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/// Whether [row] belongs to the service day in progress.
|
||||
///
|
||||
/// ── A row with no date is kept, and kept quietly ──
|
||||
/// ── A row that cannot prove it is today's is not today's ──
|
||||
///
|
||||
/// It is on this screen because this session produced it or the API returned
|
||||
/// it for this rider; the missing field is a data-quality problem, not
|
||||
/// evidence that the work happened yesterday. Dropping it would silently
|
||||
/// lose a rider's completed stop, and labelling it — the old `Undated` badge
|
||||
/// — puts an internal defect on a screen he is supposed to read at a glance
|
||||
/// and can do nothing about. So it stays, in today, unmarked.
|
||||
static bool belongsToToday(Map<String, dynamic> row, {String? now}) {
|
||||
/// This used to return `true` for a row carrying no usable stamp, on the
|
||||
/// reasoning that the API returned it for *this* session so the missing field
|
||||
/// was a data-quality problem rather than evidence of age. That reasoning was
|
||||
/// wrong in the one direction that matters: `GET /miler/bookings` returns the
|
||||
/// rider's whole open set, so "the API returned it" says nothing about when
|
||||
/// the work happened. The branch was not a safety net — it was the hole
|
||||
/// yesterday's finished bookings came through, and Activity's entire premise
|
||||
/// is that everything on it happened today.
|
||||
///
|
||||
/// So the burden of proof sits with the row. A stamp that resolves to today
|
||||
/// is admitted; anything else — yesterday's, tomorrow's, or nothing at all —
|
||||
/// is not. This is a **view filter only**: nothing is deleted from either
|
||||
/// local store or from the backend, and [of] still answers for any caller
|
||||
/// that legitimately wants history.
|
||||
///
|
||||
/// The undated case is genuinely rare now that [timeKeys] reads the fields
|
||||
/// the API actually sends, and it is a defect worth seeing rather than
|
||||
/// absorbing — so it is reported through [onUndated] rather than dropped in
|
||||
/// silence. See [logUndated], which is what Activity passes.
|
||||
static bool belongsToToday(
|
||||
Map<String, dynamic> row, {
|
||||
String? now,
|
||||
void Function(Map<String, dynamic> row)? onUndated,
|
||||
}) {
|
||||
final day = of(row);
|
||||
if (day.isEmpty) return true;
|
||||
if (day.isEmpty) {
|
||||
onUndated?.call(row);
|
||||
return false;
|
||||
}
|
||||
return day == (now ?? today);
|
||||
}
|
||||
|
||||
/// The standard [belongsToToday] undated reporter: names the row, says which
|
||||
/// fields were looked for, and stays out of release logs.
|
||||
///
|
||||
/// Deliberately not a `throw` and not a user-visible badge. A rider can do
|
||||
/// nothing about a backend row with no timestamp on it, and the old `Undated`
|
||||
/// chip put an internal defect on a screen he reads at a glance. This is for
|
||||
/// whoever is holding the console.
|
||||
static void logUndated(Map<String, dynamic> row) {
|
||||
final id = (row['orderid'] ?? row['pickupid'] ?? '?').toString();
|
||||
debugPrint(
|
||||
'[SERVICEDAY] undated row excluded from today — order $id '
|
||||
'carries none of $dayKeys or $timeKeys',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE DAY TURNING OVER UNDER A SCREEN THAT IS ALREADY OPEN
|
||||
///
|
||||
/// [ServiceDay] answers "which day is this record" at read time, which is the
|
||||
/// right shape for the data and not sufficient for a *screen*. Activity filters
|
||||
/// against `ServiceDay.today` as read at fetch time, so a rider who leaves the
|
||||
/// tab open through 23:59 — or backgrounds the app on it and picks the phone up
|
||||
/// at six — is looking at a list filtered against a day that has ended. Nothing
|
||||
/// is wrong in the store; the pixels are stale.
|
||||
///
|
||||
/// This is the small amount of state that notices. It is deliberately a plain
|
||||
/// object with an injectable clock rather than logic inside a `State`: the
|
||||
/// cases worth testing are all "the wall clock moved while nobody was looking",
|
||||
/// and those are impossible to write against a real midnight.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
class ServiceDayRollover {
|
||||
/// Reads the wall clock. Injectable so a test can move it.
|
||||
final DateTime Function() clock;
|
||||
|
||||
String _day;
|
||||
|
||||
ServiceDayRollover({DateTime Function()? clock, String? day})
|
||||
: clock = clock ?? DateTime.now,
|
||||
_day = day ?? ServiceDay.stamp((clock ?? DateTime.now)());
|
||||
|
||||
/// The service day the screen is currently showing.
|
||||
String get day => _day;
|
||||
|
||||
/// The service day the wall clock is actually in.
|
||||
String get currentDay => ServiceDay.stamp(clock());
|
||||
|
||||
/// Whether the day has moved on since [day] was adopted.
|
||||
bool get hasRolled => _day != currentDay;
|
||||
|
||||
/// Adopts the current day, reporting whether that was a change.
|
||||
///
|
||||
/// The caller does the clearing; this only answers the question. Idempotent,
|
||||
/// so the timer and the resume listener can both call it and whichever
|
||||
/// arrives second finds the day already current.
|
||||
bool rollIfNeeded() {
|
||||
final now = currentDay;
|
||||
if (now == _day) return false;
|
||||
_day = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// How long until the next local midnight, plus a second.
|
||||
///
|
||||
/// The second is not cosmetic. A timer woken *on* the boundary can find
|
||||
/// `DateTime.now()` still reading 23:59:59.999 after its own rounding, adopt
|
||||
/// the day it already had, and leave the screen on yesterday until something
|
||||
/// else happens to trigger it.
|
||||
///
|
||||
/// `day + 1` is safe across month and year ends — `DateTime` normalises 32
|
||||
/// January into 1 February — and it is the same local-calendar arithmetic
|
||||
/// [ServiceDay.stamp] does, so the wake lands on exactly the boundary the
|
||||
/// filter tests.
|
||||
Duration get untilNextDay {
|
||||
final now = clock();
|
||||
final midnight = DateTime(now.year, now.month, now.day + 1);
|
||||
return midnight.difference(now) + const Duration(seconds: 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:miler/data/api_config.dart';
|
||||
import 'package:miler/data/miler_api.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// WHICH LINE OF WORK THIS RIDER IS ON
|
||||
@@ -476,27 +479,108 @@ class ServiceProfile {
|
||||
///
|
||||
/// A free function rather than a controller method so it can be tested without
|
||||
/// a GetX container — see the note on [ServiceProfile.active].
|
||||
/// Re-reads the rider's tenant from `GET /miler/profile` and persists it.
|
||||
///
|
||||
/// ── Why a signed-in rider needed this ──
|
||||
///
|
||||
/// `tenantname` is written at verify-pin, so it only ever reached a device by
|
||||
/// way of a **login**. A rider already signed in when the backend began
|
||||
/// returning the field would never see it: his prefs carry no name, resolution
|
||||
/// falls back to the tenant id, and an id this app has not been told about
|
||||
/// lands him on logistics — no **Start delivery** button, and a parcel that
|
||||
/// strands at `Collected_By_Miler` the moment collected-state is enabled.
|
||||
///
|
||||
/// The fix is not to make every rider sign out. `GET /miler/profile` returns
|
||||
/// the same pair, so the tenant can be refreshed in place on launch.
|
||||
///
|
||||
/// ── What it costs, and when ──
|
||||
///
|
||||
/// Nothing, for a rider whose name is already known: he resolves from prefs on
|
||||
/// this launch and picks up any change on the next one, with no first frame
|
||||
/// spent waiting on a network call. A rider with **no** stored name is the case
|
||||
/// this exists for, and that one is worth a bounded wait.
|
||||
///
|
||||
/// Every failure is silent by design. A launch with no signal must land on the
|
||||
/// same screen it always did rather than on an error, and the stored pair is
|
||||
/// still there to resolve from.
|
||||
Future<void> refreshTenantFromProfile() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if ((prefs.getString('authtoken') ?? '').trim().isEmpty) return;
|
||||
|
||||
final known = (prefs.getString(TenantController.kTenantName) ?? '').trim();
|
||||
final fetch = _fetchTenantIntoPrefs();
|
||||
if (known.isNotEmpty) {
|
||||
unawaited(fetch);
|
||||
return;
|
||||
}
|
||||
await fetch.timeout(const Duration(seconds: 4), onTimeout: () {});
|
||||
}
|
||||
|
||||
Future<void> _fetchTenantIntoPrefs() async {
|
||||
try {
|
||||
final res = await MilerApi.getProfile();
|
||||
if (!res.ok) return;
|
||||
|
||||
final map = res.map;
|
||||
// The backend returns the pair top-level *and* inside `user`. Read both, so
|
||||
// a handler that later moves them cannot silently stop resolving riders.
|
||||
final user = map['user'] is Map ? map['user'] as Map : const {};
|
||||
Object? pick(String k) => map[k] ?? user[k];
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
final name = (pick('tenantname') ?? pick('tenantcode') ?? '')
|
||||
.toString()
|
||||
.trim();
|
||||
if (name.isNotEmpty) {
|
||||
await prefs.setString(TenantController.kTenantName, name);
|
||||
}
|
||||
|
||||
final id = int.tryParse('${pick('tenantid') ?? ''}'.trim());
|
||||
if (id != null && id > 0) {
|
||||
await prefs.setInt(TenantController.kTenantId, id);
|
||||
}
|
||||
} catch (_) {
|
||||
// Offline, timed out, malformed — all the same answer: keep what we have.
|
||||
}
|
||||
}
|
||||
|
||||
Future<ServiceProfile> resolveServiceProfile() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// ── What the rider's account says ──
|
||||
//
|
||||
// Name first: it is the field a human can check against the admin console,
|
||||
// and it means a new tenant does not need an app release. An unrecognised
|
||||
// name is not an answer, so it falls through to the id.
|
||||
// Both halves of the pair are read before either is acted on, because which
|
||||
// one answers depends on what the other said. See the note below.
|
||||
final name = (prefs.getString(TenantController.kTenantName) ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (name.isNotEmpty) {
|
||||
final byName = TenantController.profileForName(name);
|
||||
if (byName != null) return byName;
|
||||
}
|
||||
final byName = name.isEmpty ? null : TenantController.profileForName(name);
|
||||
|
||||
final id = prefs.getInt(TenantController.kTenantId) ?? 0;
|
||||
if (id != 0) {
|
||||
final byId = TenantController.profileForId(id);
|
||||
if (byId != null) return byId;
|
||||
}
|
||||
final byId = id == 0 ? null : TenantController.profileForId(id);
|
||||
|
||||
// ── When the two disagree, the id wins ──
|
||||
//
|
||||
// Name-first is right for the case it was written for: an unrecognised name
|
||||
// falls through to the id, so a tenant this build has never heard of costs no
|
||||
// release. That property is untouched below — a tenant absent from
|
||||
// [TenantController.milkManTenantIds] is still decided by its name alone.
|
||||
//
|
||||
// What it did not survive is the two lists *contradicting each other*. The id
|
||||
// list names one specific tenant on purpose; the name list is a pattern, and
|
||||
// patterns collide. Tenant 13 carrying a display name that normalises to
|
||||
// `doormile` — which is, after all, the company that owns the tenant record —
|
||||
// resolved that rider to Logistics and never consulted the id at all. On the
|
||||
// Logistics line `_unreleased` is empty by design, so the **Start round** bar
|
||||
// does not render, and once `MILER_COLLECTED_STATE_ENABLED` is on his
|
||||
// collected parcels strand at `Collected_By_Miler` with no control to release
|
||||
// them. A global flag gets one safe shot, and this was the loose end in it.
|
||||
//
|
||||
// A deliberate statement about a known tenant outranks a match on a word
|
||||
// nobody on either side of the API controls the spelling of.
|
||||
if (byId != null) return byId;
|
||||
if (byName != null) return byName;
|
||||
|
||||
// ── The tenant the session's own token claims ──
|
||||
//
|
||||
@@ -618,11 +702,34 @@ class TenantController extends GetxController {
|
||||
/// One lookup used by both the account path and the build override, so the
|
||||
/// two can never disagree about what a name means.
|
||||
static ServiceProfile? profileForName(String name) {
|
||||
if (milkManTenantNames.contains(name)) return ServiceProfile.milkMan;
|
||||
if (logisticsTenantNames.contains(name)) return ServiceProfile.parcel;
|
||||
final key = _nameKey(name);
|
||||
if (key.isEmpty) return null;
|
||||
if (milkManTenantNames.any((n) => _nameKey(n) == key)) {
|
||||
return ServiceProfile.milkMan;
|
||||
}
|
||||
if (logisticsTenantNames.any((n) => _nameKey(n) == key)) {
|
||||
return ServiceProfile.parcel;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// A tenant name reduced to the letters and digits in it.
|
||||
///
|
||||
/// ── Why separators cannot be allowed to decide a rider's day ──
|
||||
///
|
||||
/// This was an exact-string lookup against a lower-cased name, which meant
|
||||
/// `DailyGrubs` resolved and `Daily Grubs` did not — and the difference
|
||||
/// between them is whether the rider gets a **Start delivery** button at all.
|
||||
/// Nobody on either side of the API controls how a client's display name was
|
||||
/// typed into the tenant record, so matching on it was a coin flip we had no
|
||||
/// reason to take: `daily-grubs`, `DAILY_GRUBS` and `Daily Grubs` are the
|
||||
/// same client by any reading, and all three missed.
|
||||
///
|
||||
/// Applied to both sides of the comparison, so the literals above stay
|
||||
/// readable as the words they are.
|
||||
static String _nameKey(String raw) =>
|
||||
raw.toLowerCase().replaceAll(RegExp('[^a-z0-9]'), '');
|
||||
|
||||
/// The profile a tenant *id* means, or null when this app does not know it.
|
||||
static ServiceProfile? profileForId(int id) {
|
||||
if (milkManTenantIds.contains(id)) return ServiceProfile.milkMan;
|
||||
|
||||
@@ -108,9 +108,22 @@ class StopCompliance {
|
||||
return StopCompliance(
|
||||
onTime: onTime,
|
||||
lateBy: lateSeconds == null ? null : Duration(seconds: lateSeconds),
|
||||
// `riderkms` is the backend's own name for the distance ridden, so a row
|
||||
// that came straight from the API reads without a stamp.
|
||||
actualKm: _double(m['actualkm']) ?? _double(stop['riderkms']),
|
||||
// ── Three spellings, because the app writes two of them itself ──
|
||||
//
|
||||
// `compliance.actualkm` is the contract's. `riderkms` is the backend's
|
||||
// own name on a booking row that came straight from the API. And
|
||||
// `actualkms` is what **this app** posts on the arrival and pickup writes
|
||||
// (`PickupsController`), so it is the key on any record that was stamped
|
||||
// locally before the queue caught up.
|
||||
//
|
||||
// Reading only the first two meant a stop the rider had just finished
|
||||
// reported no distance at all until a poll replaced the row — which is
|
||||
// most of the rows on Activity for most of a shift, and it is why the
|
||||
// day's total read zero.
|
||||
actualKm:
|
||||
_double(m['actualkm']) ??
|
||||
_double(stop['riderkms']) ??
|
||||
_double(stop['actualkms']),
|
||||
plannedKm: _double(m['plannedkm']) ?? _double(stop['kms']),
|
||||
);
|
||||
}
|
||||
@@ -131,7 +144,14 @@ class StopCompliance {
|
||||
|
||||
static double? _double(dynamic v) {
|
||||
if (v == null) return null;
|
||||
if (v is num) return v.toDouble();
|
||||
// ── Zero is not a measurement, whichever type it arrives as ──
|
||||
//
|
||||
// A numeric `0` returned `0.0` here while the string `"0"` returned null,
|
||||
// so the same absent distance resolved two different ways depending on
|
||||
// whether the row came off JSON or off a locally stamped map. Callers all
|
||||
// guard with `> 0`, so nothing was visibly wrong — which is precisely why
|
||||
// it would have gone on being inconsistent.
|
||||
if (v is num) return v == 0 ? null : v.toDouble();
|
||||
final parsed = double.tryParse(
|
||||
v.toString().replaceAll(RegExp(r'[^0-9.\-]'), ''),
|
||||
);
|
||||
|
||||
@@ -95,9 +95,49 @@ abstract final class WorkBoundary {
|
||||
/// middle of the morning on one that does not — so it is asked of the line,
|
||||
/// through the same knob everything else here reads. See
|
||||
/// [ServiceProfile.endsAtHub].
|
||||
static bool isClosed(Map<String, dynamic> stop) {
|
||||
///
|
||||
/// ── Skipped closes, and why it has to close *here* ──
|
||||
///
|
||||
/// A skip used to leave the order open, and the Deliveries tab kept it under
|
||||
/// its own heading on the argument that the rider looks for it where he left
|
||||
/// it. The cost of that was **dual ownership**: the same stop sat in the
|
||||
/// active queue *and* in Activity, so the two screens disagreed about whether
|
||||
/// the rider still owed it a visit. One order, one owner — a stop he has
|
||||
/// written off is a record, and records live on Activity.
|
||||
///
|
||||
/// ── Why the status alone cannot answer it ──
|
||||
///
|
||||
/// On the delivery leg the backend has no failed-attempt outcome. A
|
||||
/// successful skip leaves the consignment `Out_for_Delivery`, so
|
||||
/// `/miler/bookings` keeps returning the row as live delivery work for the
|
||||
/// rest of the day, and [stopStatusOf] reads exactly what the server sent.
|
||||
/// Asking only the payload therefore re-admits the stop on every poll and on
|
||||
/// every cold start.
|
||||
///
|
||||
/// [closedIds] is the answer to that, and it is **not** a display flag: it is
|
||||
/// the persisted record of a mutation that already succeeded — day-stamped,
|
||||
/// scoped to this rider, tenant and line (see [WorkScope]) — written by the
|
||||
/// completed and skipped stores at the moment the rider's action came back
|
||||
/// OK. It is the same shape of evidence [pickupComplete] already takes from
|
||||
/// `collectedIds`, and it survives the process that wrote it, which is the
|
||||
/// whole point.
|
||||
///
|
||||
/// Clearing it is what a resume does: [removeSkippedBookings] drops the id
|
||||
/// and the stop is live work again on the very next read. There is no second
|
||||
/// place holding the same opinion.
|
||||
static bool isClosed(
|
||||
Map<String, dynamic> stop, {
|
||||
Set<String> closedIds = const <String>{},
|
||||
}) {
|
||||
if (closedIds.isNotEmpty && closedIds.contains(MilkRun.idOf(stop))) {
|
||||
return true;
|
||||
}
|
||||
final status = stopStatusOf(stop);
|
||||
if (status == StopStatus.delivered || status.isCancelled) return true;
|
||||
// Filed under its own word, never flattened into cancelled: a stop the
|
||||
// rider walked away from and one the office called off are different
|
||||
// records, and Activity slices them apart.
|
||||
if (status.isSkipped) return true;
|
||||
return status.isPicked && ServiceProfile.active.endsAtHub;
|
||||
}
|
||||
|
||||
@@ -110,8 +150,12 @@ abstract final class WorkBoundary {
|
||||
Map<String, dynamic> stop, {
|
||||
Set<String> collectedIds = const <String>{},
|
||||
Set<String> acceptedIds = const <String>{},
|
||||
|
||||
/// Orders written off today — delivered, cancelled or skipped — read back
|
||||
/// from the completed and skipped stores. See [isClosed].
|
||||
Set<String> closedIds = const <String>{},
|
||||
}) {
|
||||
if (isClosed(stop)) return WorkDomain.closed;
|
||||
if (isClosed(stop, closedIds: closedIds)) return WorkDomain.closed;
|
||||
|
||||
final handedOver = switch (ServiceProfile.active.handoffAt) {
|
||||
HandoffPoint.collected => pickupComplete(
|
||||
@@ -139,12 +183,14 @@ abstract final class WorkBoundary {
|
||||
Iterable<Map<String, dynamic>> day, {
|
||||
Set<String> collectedIds = const <String>{},
|
||||
Set<String> acceptedIds = const <String>{},
|
||||
Set<String> closedIds = const <String>{},
|
||||
}) => [
|
||||
for (final stop in day)
|
||||
if (domainOf(
|
||||
stop,
|
||||
collectedIds: collectedIds,
|
||||
acceptedIds: acceptedIds,
|
||||
closedIds: closedIds,
|
||||
) ==
|
||||
WorkDomain.delivery)
|
||||
stop,
|
||||
@@ -155,12 +201,14 @@ abstract final class WorkBoundary {
|
||||
Iterable<Map<String, dynamic>> day, {
|
||||
Set<String> collectedIds = const <String>{},
|
||||
Set<String> acceptedIds = const <String>{},
|
||||
Set<String> closedIds = const <String>{},
|
||||
}) => [
|
||||
for (final stop in day)
|
||||
if (domainOf(
|
||||
stop,
|
||||
collectedIds: collectedIds,
|
||||
acceptedIds: acceptedIds,
|
||||
closedIds: closedIds,
|
||||
) ==
|
||||
WorkDomain.pickup)
|
||||
stop,
|
||||
|
||||
@@ -201,7 +201,7 @@ class WorkRepository {
|
||||
return _publish(
|
||||
const LoadFailure<List<Map<String, dynamic>>>(
|
||||
LoadFailureKind.server,
|
||||
message: 'The hub sent work this app could not read.',
|
||||
message: 'Your office sent work this app could not read.',
|
||||
),
|
||||
seq,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user