production

This commit is contained in:
2026-08-28 11:13:15 +05:30
parent d7348e253f
commit 5723d373b2
162 changed files with 17924 additions and 7026 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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"',
);
}

View File

@@ -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',
];
}

View File

@@ -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;
}

View 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
View 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;
}

View File

@@ -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.

View File

@@ -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);
}
}

View File

@@ -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;

View File

@@ -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.\-]'), ''),
);

View File

@@ -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,

View File

@@ -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,
);