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

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

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

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

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

1159 lines
48 KiB
Dart

import 'package:flutter/foundation.dart';
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/service_day.dart';
import 'package:miler/data/service_profile.dart';
import 'package:miler/data/work_scope.dart';
/// ── Every key in this file belongs to a session, not to the handset ──
///
/// These stores held finished work, skipped work, carried bags and released
/// orders under *global* keys — `completed_bookings` and friends — so one
/// phone had one drawer and whoever logged in last opened it. Logging out and
/// back in as another rider, or a tenant switch that moves the app between the
/// milk-man round and the logistics day, showed the previous scope's records
/// as if they were yours.
///
/// The key now carries the identity the session can prove: rider, tenant and
/// line. See [WorkScope]. Nothing else in this file changed shape — the
/// scoping happens here, at the data boundary, once.
Future<String> _scopedKey(String base) async =>
(await WorkScope.current()).scoped(base);
/// Legacy global keys, drained on first scoped access.
///
/// ── Why draining and not migrating ──
///
/// A legacy row cannot say whose it is: the global keys predate the identity
/// stamp, so a `completed_bookings` blob is *some* rider's, on *some* line,
/// and attributing it to whoever happens to log in first would be inventing
/// ownership — the exact leak this change exists to close. Rows that prove
/// their own ownership ([WorkScope.owns]) are carried across; the rest are
/// dropped. The cost is bounded and small: [getCompletedBookings] already
/// prunes to today, so at worst one day of local history is lost once, on one
/// upgrade, for records nobody can attribute anyway. The server-side history
/// is untouched by any of this.
Future<void> _drainLegacy(String base, WorkScope scope) async {
final prefs = await SharedPreferences.getInstance();
if (!prefs.containsKey(base)) return;
final scopedKey = scope.scoped(base);
final Object? legacy = prefs.get(base);
if (legacy is String && !prefs.containsKey(scopedKey)) {
// A JSON blob of records: keep only the rows that prove they are ours.
final rows = _decode(legacy);
final mine = [
for (final r in rows)
if (scope.owns(r)) scope.stamp(r),
];
if (mine.isNotEmpty) {
await prefs.setString(scopedKey, jsonEncode(mine));
}
}
// Id lists carry no identity at all, so there is nothing to carry across.
await prefs.remove(base);
debugPrint('[SCOPE] drained legacy "$base" into ${scope.key}');
}
/// Moves records out of the old line-suffixed keys into this scope's key.
///
/// ── Why this one MERGES where [_drainLegacy] drops ──
///
/// A line-suffixed key is not anonymous the way a global one is. It already
/// names the rider and the tenant — `completed_bookings::u38.t13.milkMan` —
/// so everything in it provably belongs to this scope. There is nothing to
/// attribute and nothing to guess, and dropping it would throw away work the
/// rider actually did.
///
/// Both old drawers are merged, because the whole point of removing the line
/// is that one rider's day is one day: a meal round and a logistics collection
/// finished in the same shift belong in the same history. Ids already present
/// win, so a re-run cannot duplicate a row.
Future<void> _drainLineScoped(String base, WorkScope scope) async {
final prefs = await SharedPreferences.getInstance();
final target = scope.scoped(base);
// Read the target through `get`, not the typed accessors: these base keys
// hold a JSON blob for record stores and a String list for id stores, and
// `getStringList` throws outright when it meets the blob.
final Object? existing = prefs.get(target);
final merged = <Map<String, dynamic>>[];
final mergedIds = <String>{};
final ids = <String>{};
if (existing is String) {
for (final row in _decode(existing)) {
merged.add(row);
final id = _rowKey(row);
if (id.isNotEmpty) mergedIds.add(id);
}
} else if (existing is List) {
ids.addAll(existing.map((e) => e.toString()));
}
var moved = false;
for (final legacy in scope.legacyScopedKeys(base)) {
if (legacy == target || !prefs.containsKey(legacy)) continue;
final Object? value = prefs.get(legacy);
if (value is String) {
for (final row in _decode(value)) {
final id = _rowKey(row);
if (id.isNotEmpty && !mergedIds.add(id)) continue;
merged.add(scope.stamp(row));
}
} else if (value is List) {
ids.addAll(value.map((e) => e.toString()));
}
await prefs.remove(legacy);
moved = true;
}
if (!moved) return;
if (merged.isNotEmpty) {
await prefs.setString(target, jsonEncode(merged));
} else if (ids.isNotEmpty) {
await prefs.setStringList(target, ids.toList());
}
debugPrint('[SCOPE] merged line-scoped "$base" into ${scope.key}');
}
/// The identity a stored row is de-duplicated on while merging.
String _rowKey(Map<String, dynamic> row) {
for (final k in const [
'orderid',
'OrderId',
'bookingid',
'consignmentid',
'id',
]) {
final v = row[k];
if (v != null && v.toString().trim().isNotEmpty) return v.toString();
}
return '';
}
/// Runs the one-time drain for every legacy key. Cheap after the first call —
/// `containsKey` on a loaded prefs map.
Future<void> migrateLegacyStores() async {
final scope = await WorkScope.current();
for (final base in const [
_kAcceptedBookingsKeyBase,
_kRejectedOrderIdsKeyBase,
_kCompletedBookingsKeyBase,
_kSkippedBookingsKeyBase,
_kArrivedOrderIdsKeyBase,
_kCollectedOrderIdsKeyBase,
_kConsignmentIdsKeyBase,
_kPivotNextActionKeyBase,
_kOutForDeliveryKeyBase,
_kNotLoadedKeyBase,
_kOrderLabelsKeyBase,
]) {
await _drainLegacy(base, scope);
// Then fold in anything the old line-suffixed keys still hold. Order
// matters: the global drain writes the scoped key, and this merges into it.
await _drainLineScoped(base, scope);
}
}
/// Seeds one of this scope's stores directly. **Tests only.**
///
/// Fixtures used to write the bare global key (`completed_bookings`) because
/// that is what the store read. Now that a store belongs to a rider, a tenant
/// and a line, a fixture that writes the bare key is seeding a drawer nothing
/// opens — so it goes through the same resolver the app does, and the tests
/// exercise the shipped key scheme rather than a retired one.
/// [value] is the JSON blob for a record store, or the id list for one of the
/// set-shaped stores (`collected_order_ids`, `out_for_delivery_order_ids`,
/// `mock_rejected_order_ids`) — the same two shapes the stores themselves use.
/// The key [debugSeedStore] writes to, for assertions that read prefs back.
@visibleForTesting
Future<String> debugStoreKey(String base) => _scopedKey(base);
@visibleForTesting
Future<void> debugSeedStore(String base, Object value) async {
final prefs = await SharedPreferences.getInstance();
final key = await _scopedKey(base);
if (value is List) {
await prefs.setStringList(key, [for (final v in value) v.toString()]);
} else {
await prefs.setString(key, value.toString());
}
}
/// Wipes **this scope's** stores. Called on logout so the next rider on this
/// handset starts empty — see `AuthController`.
Future<void> clearScopedStores() async {
final prefs = await SharedPreferences.getInstance();
final scope = await WorkScope.current();
for (final base in const [
_kAcceptedBookingsKeyBase,
_kRejectedOrderIdsKeyBase,
_kCompletedBookingsKeyBase,
_kSkippedBookingsKeyBase,
_kArrivedOrderIdsKeyBase,
_kCollectedOrderIdsKeyBase,
_kConsignmentIdsKeyBase,
_kPivotNextActionKeyBase,
_kOutForDeliveryKeyBase,
_kNotLoadedKeyBase,
_kOrderLabelsKeyBase,
]) {
await prefs.remove(scope.scoped(base));
await prefs.remove(base);
}
debugPrint('[SCOPE] cleared ${scope.key}');
}
/// Persistent store of bookings the rider has accepted while the app runs on
/// mock data (offline / demo mode). This lets the accept flow be functional
/// without a server: the Home queue drops accepted bookings (and keeps them
/// dropped across refetches/navigation), and the Bookings tab picks them up.
const String _kAcceptedBookingsKeyBase = 'mock_accepted_bookings';
const String _kRejectedOrderIdsKeyBase = 'mock_rejected_order_ids';
/// Stops finished today, for the Activity tab.
///
/// ── Why a local store and not just the API ──
///
/// Finishing a stop is optimistic everywhere in this app: the confirm sheet
/// records the completion and moves the rider on whether or not the status call
/// succeeded, because a rider who watches a completed stop bounce back stops
/// trusting the button. That leaves the completion existing *only* in the
/// Bookings tab's in-memory state — it is dropped from the working list and
/// removed from the accepted store, and nothing else remembers it.
///
/// So Activity, which reads the queue endpoints, showed nothing at all: on demo
/// data the backend never returns the finished stop, and on live data it does
/// not return it until the write lands and the next poll comes round. The
/// rider finished a booking and the tab meant to prove it stayed empty.
///
/// This is the record that survives that. It is the same trade the accepted
/// store already makes, for the same reason.
const String _kCompletedBookingsKeyBase = 'completed_bookings';
/// Stops the rider parked mid-shift for a return visit.
///
/// ── Why these are stored at all ──
///
/// A skip used to live in one place only: `_MyPickupsState._skippedOrdersCache`,
/// a static map in the Bookings screen's State. That was enough while the skip
/// was displayed on Home, because Home is rebuilt from the same queue the cache
/// re-stamps. It is not enough now that a skipped stop is shown on **Activity**,
/// which reads its own endpoints: on demo data the backend never reports the
/// skip at all, and on live data it does not report it until the write lands.
/// The rider would skip a stop and find the tab that is meant to account for it
/// empty — the same failure the completed store was written to fix.
///
/// So a skip is recorded here the moment it is taken, with the reason the rider
/// gave, and it is removed when he resumes the stop. Same trade as
/// [await _scopedKey(_kCompletedBookingsKeyBase)], for the same reason.
const String _kSkippedBookingsKeyBase = 'skipped_bookings';
List<Map<String, dynamic>> _decode(String? raw) {
if (raw == null || raw.isEmpty) return [];
try {
final decoded = jsonDecode(raw);
if (decoded is List) {
return decoded
.whereType<Map>()
.map((e) => Map<String, dynamic>.from(e))
.toList();
}
} catch (_) {}
return [];
}
/// All bookings that have been locally accepted (each carries
/// `orderstatus: 'accepted'`).
Future<List<Map<String, dynamic>>> getAcceptedBookings() async {
final prefs = await SharedPreferences.getInstance();
return _decode(prefs.getString(await _scopedKey(_kAcceptedBookingsKeyBase)));
}
/// The set of order ids that have been locally accepted.
Future<Set<String>> getAcceptedOrderIds() async {
final list = await getAcceptedBookings();
return list
.map((b) => (b['orderid'] ?? '').toString())
.where((s) => s.isNotEmpty)
.toSet();
}
/// Order ids the rider has locally rejected. Home filters these out so a
/// rejected booking leaves the pending list.
Future<Set<String>> getRejectedOrderIds() async {
final prefs = await SharedPreferences.getInstance();
return (prefs.getStringList(await _scopedKey(_kRejectedOrderIdsKeyBase)) ??
[])
.toSet();
}
/// Remember the given order ids as rejected.
Future<void> addRejectedOrderIds(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(_kRejectedOrderIdsKeyBase)) ?? [])
.toSet();
existing.addAll(clean);
await prefs.setStringList(
await _scopedKey(_kRejectedOrderIdsKeyBase),
existing.toList(),
);
}
/// Undo a rejection.
///
/// Rejecting is one tap on a moving bike, so it is genuinely easy to hit the
/// wrong stop. Without this the mistake is permanent for the rest of the shift
/// and the rider has to call the hub to fix it.
Future<void> removeRejectedOrderIds(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(_kRejectedOrderIdsKeyBase)) ?? [])
.where((id) => !drop.contains(id))
.toList();
await prefs.setStringList(
await _scopedKey(_kRejectedOrderIdsKeyBase),
remaining,
);
}
/// Remove the given order ids from the accepted store. Called when a pickup is
/// completed or cancelled so it stops counting as an open booking (and no longer
/// reappears on the Bookings tab or as a "next stop" after a rebuild).
Future<void> removeAcceptedBookings(List<String> ids) async {
final drop = ids.where((s) => s.isNotEmpty).toSet();
if (drop.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final remaining = _decode(
prefs.getString(await _scopedKey(_kAcceptedBookingsKeyBase)),
).where((b) => !drop.contains((b['orderid'] ?? '').toString())).toList();
await prefs.setString(
await _scopedKey(_kAcceptedBookingsKeyBase),
jsonEncode(remaining),
);
}
/// Stops the rider finished **today**, newest first.
///
/// Entries from earlier days are dropped on read rather than on a timer, so a
/// shift that runs past midnight keeps its stops until the rider next opens the
/// tab — and the store cannot grow without bound.
Future<List<Map<String, dynamic>>> getCompletedBookings() async {
final prefs = await SharedPreferences.getInstance();
final all = _decode(
prefs.getString(await _scopedKey(_kCompletedBookingsKeyBase)),
);
final today = _dayStamp(DateTime.now());
final mine = all.where((b) => (b['completedday'] ?? '') == today).toList()
..sort(
(a, b) => (b['completedat'] ?? '').toString().compareTo(
(a['completedat'] ?? '').toString(),
),
);
// Prune in the background if yesterday's rows are still in there.
if (mine.length != all.length) {
await prefs.setString(
await _scopedKey(_kCompletedBookingsKeyBase),
jsonEncode(mine),
);
}
return mine;
}
/// Prefs key holding the moment the rider opened a stop and set off for it.
/// Written by the stop map screen, read and cleared here. See [kStopStartedKey].
String kStopStartedKey(Object pickupId) => 'stop_started_$pickupId';
/// Prefs key holding the moment the rider confirmed he was at the door.
/// Written by the stop map screen when it starts the pickup.
String kStopArrivedKey(Object pickupId) => 'pickup_start_$pickupId';
/// Prefs key holding the kilometres the app measured for a stop as it closed.
///
/// ── The app measured this, posted it, and kept no copy ──
///
/// `PickupsController.updateToPicked` works out how far the rider came —
/// `Geolocator.distanceBetween`, rider fix to pickup point — and sends it as
/// `actualkms`. Then it dropped it. The number existed for the length of one
/// HTTP request.
///
/// Which is why Activity's `km ridden` read an em dash on a day the rider had
/// plainly ridden: [StopCompliance] looks for `actualkms` on the row, the row
/// never carried one, and `GET /miler/bookings` does not reliably send the
/// distance back. The app was the only thing that knew, and it forgot.
///
/// So it is written here as it is posted, and [addCompletedBookings] merges it
/// onto the record it files — the same rescue it already performs for the two
/// clocks beside it, for the same reason: the moment the fact is true is the
/// moment to write it down.
String kStopKmKey(Object pickupId) => 'stop_km_$pickupId';
/// Record a finished stop. [cancelled] separates "could not complete" from a
/// clean pickup/delivery — Activity shows both, worded differently.
///
/// ── Why the timings are collected here ──
///
/// A finished stop is a record, and the questions asked of it afterwards are
/// all about *time*: when did he set off, when did he get there, how long did
/// the door take, did he beat the ETA. Every one of those was being measured
/// somewhere in the app and then dropped on the floor — the two timestamps live
/// in SharedPreferences keys that the next stop overwrites, so by the time
/// anyone opened Activity they were gone or, worse, belonged to a different
/// stop.
///
/// This is the last moment they are all true at once, so this is where they are
/// written down and the keys are cleared. Same argument as [StopCompliance],
/// which is stamped one call earlier for the same reason.
/// [terminalStatus] names what "finished" means on this line, and defaults to
/// the active profile's answer.
///
/// A logistics stop ends at `picked` — collecting the parcel *is* the job. A
/// milk-run stop ends at `delivered`, and stamping `picked` on it would file
/// the crate as the finished work and report fifteen lunches complete at the
/// moment the rider left the kitchen. One store, two honest endings; see
/// [StopStatus.isTerminal].
Future<void> addCompletedBookings(
List<Map<String, dynamic>> bookings, {
bool cancelled = false,
String? terminalStatus,
}) async {
if (bookings.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final now = DateTime.now();
final String done = ServiceProfile.active.deliversToCustomer
? 'delivered'
: 'picked';
final Map<String, Map<String, dynamic>> byId = {
for (final b in _decode(
prefs.getString(await _scopedKey(_kCompletedBookingsKeyBase)),
))
(b['orderid'] ?? '').toString(): b,
};
final scope = await WorkScope.current();
for (final b in bookings) {
final id = (b['orderid'] ?? '').toString();
if (id.isEmpty) continue;
final copy = Map<String, dynamic>.from(b);
// Stamped so the same `stopStatusOf` test that drops a stop from Bookings
// is the one that picks it up here — the two can never disagree about what
// "done" means.
// `terminalStatus` is the caller naming the outcome exactly; `cancelled`
// is the older shorthand for "anything that was not a completion". The
// precise word wins, so a skipped delivery is filed as a skip rather than
// being flattened into a cancellation it was not.
copy['orderstatus'] = terminalStatus ?? (cancelled ? 'cancelled' : done);
copy['completedat'] = now.toIso8601String();
copy['completedday'] = _dayStamp(now);
final pickupId = (b['pickupid'] ?? '').toString();
if (pickupId.isNotEmpty) {
final started = prefs.getString(kStopStartedKey(pickupId));
final arrived = prefs.getString(kStopArrivedKey(pickupId));
if (started != null && started.isNotEmpty) copy['startedat'] = started;
if (arrived != null && arrived.isNotEmpty) copy['arrivedat'] = arrived;
// The distance the app measured as it closed this stop — see
// [kStopKmKey]. A figure already on the row wins: that one came from the
// hub, and this is the app's own measurement standing in for it.
final km = prefs.getString(kStopKmKey(pickupId));
if (km != null && km.isNotEmpty && (copy['actualkms'] ?? '') == '') {
copy['actualkms'] = km;
}
// Cleared, or a stop worked twice in a day (a resumed skip) reports the
// first attempt's clock against the second attempt's completion.
await prefs.remove(kStopStartedKey(pickupId));
await prefs.remove(kStopArrivedKey(pickupId));
await prefs.remove(kStopKmKey(pickupId));
}
// Stamped with the session that produced it, so a row read back later
// can prove its own ownership even if the key scheme changes again.
byId[id] = scope.stamp(copy);
}
await prefs.setString(
await _scopedKey(_kCompletedBookingsKeyBase),
jsonEncode(byId.values.toList()),
);
}
/// Stops skipped **today**, newest first.
///
/// Pruned on read like [getCompletedBookings]: a skip is a "come back to it
/// this shift" marker, and one left over from yesterday is noise the rider can
/// no longer act on.
Future<List<Map<String, dynamic>>> getSkippedBookings() async {
final prefs = await SharedPreferences.getInstance();
final all = _decode(
prefs.getString(await _scopedKey(_kSkippedBookingsKeyBase)),
);
final today = _dayStamp(DateTime.now());
final mine = all.where((b) => (b['skippedday'] ?? '') == today).toList()
..sort(
(a, b) => (b['skippedat'] ?? '').toString().compareTo(
(a['skippedat'] ?? '').toString(),
),
);
if (mine.length != all.length) {
await prefs.setString(
await _scopedKey(_kSkippedBookingsKeyBase),
jsonEncode(mine),
);
}
return mine;
}
/// The set of order ids finished today — picked up or written off.
///
/// The queue endpoints are a poll or more behind the rider, so this is the only
/// thing that knows a stop is done the moment he says so. Home stamps it over
/// whatever status the queue is still reporting — see `_fetchQueues` — the same
/// way it already does for skips, and for the same reason: a card that keeps
/// saying LIVE after the rider has closed the stop reads as the app not having
/// heard him.
Future<Set<String>> getCompletedOrderIds() async {
final list = await getCompletedBookings();
return list
.map((b) => (b['orderid'] ?? '').toString())
.where((s) => s.isNotEmpty)
.toSet();
}
/// The set of order ids currently parked as skipped.
Future<Set<String>> getSkippedOrderIds() async {
final list = await getSkippedBookings();
return list
.map((b) => (b['orderid'] ?? '').toString())
.where((s) => s.isNotEmpty)
.toSet();
}
/// Record a skip, with the reason the rider picked in the skip sheet.
Future<void> addSkippedBooking(
Map<String, dynamic> booking, {
String reason = '',
}) async {
final id = (booking['orderid'] ?? '').toString();
if (id.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final now = DateTime.now();
final Map<String, Map<String, dynamic>> byId = {
for (final b in _decode(
prefs.getString(await _scopedKey(_kSkippedBookingsKeyBase)),
))
(b['orderid'] ?? '').toString(): b,
};
final copy = Map<String, dynamic>.from(booking);
// Stamped so `stopStatusOf` reads it as skipped wherever it surfaces — the
// same trick the completed store uses, so one status test serves both.
copy['orderstatus'] = 'skipped';
copy['skipreason'] = reason;
copy['skippedat'] = now.toIso8601String();
copy['skippedday'] = _dayStamp(now);
byId[id] = copy;
await prefs.setString(
await _scopedKey(_kSkippedBookingsKeyBase),
jsonEncode(byId.values.toList()),
);
}
/// Forget a skip — the rider resumed the stop, so it is live work again.
Future<void> removeSkippedBookings(List<String> ids) async {
final drop = ids.where((s) => s.isNotEmpty).toSet();
if (drop.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final remaining = _decode(
prefs.getString(await _scopedKey(_kSkippedBookingsKeyBase)),
).where((b) => !drop.contains((b['orderid'] ?? '').toString())).toList();
await prefs.setString(
await _scopedKey(_kSkippedBookingsKeyBase),
jsonEncode(remaining),
);
}
/// The local calendar date a record belongs to.
///
/// Delegates to [ServiceDay] so the stamp written here and the day Activity
/// asks for are produced by the same line of code — two implementations of a
/// date format is how a shift ends up half in one day and half in the next.
String _dayStamp(DateTime t) => ServiceDay.stamp(t);
/// Persist the given bookings as accepted, deduped by `orderid`.
Future<void> addAcceptedBookings(List<Map<String, dynamic>> bookings) async {
if (bookings.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final Map<String, Map<String, dynamic>> byId = {
for (final b in _decode(
prefs.getString(await _scopedKey(_kAcceptedBookingsKeyBase)),
))
(b['orderid'] ?? '').toString(): b,
};
for (final b in bookings) {
final id = (b['orderid'] ?? '').toString();
if (id.isEmpty) continue;
final copy = Map<String, dynamic>.from(b);
copy['orderstatus'] = 'accepted';
byId[id] = copy;
}
await prefs.setString(
await _scopedKey(_kAcceptedBookingsKeyBase),
jsonEncode(byId.values.toList()),
);
}
// ─────────────────────────────────────────────────────────────────────────
// COLLECTED — parcels that are physically in the rider's hands
//
// A meal run has a state the parcel backend cannot express. Its ladder is
// `accepted → arrived → picked`, and `picked` is terminal: it clears the order
// out of the accepted store and files it on Activity as finished. That is the
// right shape for a parcel, where collecting it from the customer IS the job.
//
// It is the wrong shape for a service route, where collecting is the *middle*.
// A rider loads ten lunches at a kitchen at 11:50 and delivers the last one at
// 13:20; writing `picked` at the kitchen would report all ten orders complete
// ninety minutes before anybody ate, and DailyGrubs is billed on that record.
//
// So `picked` stays where it belongs — the hand-over at the customer's door —
// and the intermediate state lives here, on the device, as the set of orders
// the rider is carrying. It is what moves a card off Home and onto Bookings.
//
// BACKEND DEPENDENCY: this is a local stand-in. The hub cannot see that a
// rider has loaded a kitchen until the API grows a `collected` status (and a
// `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.
Future<Set<String>> getCollectedOrderIds() async {
final prefs = await SharedPreferences.getInstance();
return (prefs.getStringList(await _scopedKey(_kCollectedOrderIdsKeyBase)) ??
[])
.toSet();
}
/// Records a load. Called once per kitchen, with everything taken from it.
Future<void> addCollectedOrderIds(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(_kCollectedOrderIdsKeyBase)) ?? [])
.toSet();
existing.addAll(clean);
await prefs.setStringList(
await _scopedKey(_kCollectedOrderIdsKeyBase),
existing.toList(),
);
}
/// ── The consignment id, captured at the pivot ──
///
/// `pickup-complete` is the call that *creates* the consignment, and its id is
/// the key every delivery action needs: `deliver` and `skip` are consignment
/// routes, not booking ones, and passing a booking id gets a 404 while the
/// rider is shown success.
///
/// That id was being thrown away. The response was wrapped into a legacy
/// envelope and discarded, and the row the rider then worked from on Deliveries
/// is a snapshot taken *before* the conversion — so it had no consignment id
/// either. The result: he collected an order, drove it to the door, pressed
/// **Delivered**, and the app refused because it did not know what to deliver.
///
/// The queue does carry the id once the backend catches up, so this is a
/// bridge, not a second source of truth: [consignmentIdFor] prefers the row and
/// falls back to what was recorded here.
const String _kConsignmentIdsKeyBase = 'consignment_ids_by_order';
/// Records the consignment `pickup-complete` just created for [orderId].
Future<void> rememberConsignmentId(String orderId, String consignmentId) async {
if (orderId.isEmpty || consignmentId.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final map = await getConsignmentIds();
map[orderId] = consignmentId;
await prefs.setString(
await _scopedKey(_kConsignmentIdsKeyBase),
jsonEncode(map),
);
}
/// Every consignment id this device recorded at a pivot, by order id.
Future<Map<String, String>> getConsignmentIds() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(await _scopedKey(_kConsignmentIdsKeyBase));
if (raw == null || raw.isEmpty) return <String, String>{};
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) return <String, String>{};
return {
for (final e in decoded.entries)
e.key.toString(): e.value?.toString() ?? '',
}..removeWhere((_, v) => v.isEmpty);
} catch (_) {
return <String, String>{};
}
}
/// Drops ids for orders that are finished, so the map does not grow for the
/// life of the install.
Future<void> forgetConsignmentIds(List<String> orderIds) async {
if (orderIds.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final map = await getConsignmentIds();
var changed = false;
for (final id in orderIds) {
if (map.remove(id) != null) changed = true;
}
if (changed)
await prefs.setString(
await _scopedKey(_kConsignmentIdsKeyBase),
jsonEncode(map),
);
}
// ─────────────────────────────────────────────────────────────────────────
// THE PIVOT'S ROUTING ANSWER — which way this parcel went at pickup-complete
//
// `next_action` is the server naming the next leg: `start_delivery` for a
// parcel this rider delivers himself, `inward_at_hub` for one that enters the
// network. It is returned **once**, by the pivot, and it was being logged and
// thrown away — the only trace of it was `lastPivotNextAction`, a single
// in-memory string on a controller, which is not per order and does not
// survive a rebuild.
//
// So a hub-routed parcel was indistinguishable from a hyperlocal one on the
// very next poll, and `NextLegResolver` had nothing to fall back on when the
// consignment read `Created` — the state that cannot decide anything by itself.
//
// ── This is continuity, not authority ──
//
// It is rung 4 of four, below every live server reading, deliberately: it says
// what the server decided *at the pickup*, and if the server has since said
// something different the server wins. See [NextLegResolver] for the full
// precedence table.
//
// ── It should not have to exist ──
//
// The right home for this fact is the queue row. `GET /miler/bookings` does not
// carry `next_action`, which is the logged backend gap this store works around.
// When that field lands, `NextLegResolver` prefers it automatically and this
// becomes a cache nothing reads.
const String _kPivotNextActionKeyBase = 'pivot_next_action_by_order';
/// Records the `next_action` the pivot returned for [orderId].
///
/// Ignores an empty action rather than storing a blank: an absent instruction
/// and a recorded "no instruction" are different facts, and only the first is
/// true when the server said nothing.
Future<void> rememberPivotNextAction(String orderId, String action) async {
final id = orderId.trim();
final value = action.trim().toLowerCase();
if (id.isEmpty || value.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final map = await getPivotNextActions();
map[id] = value;
await prefs.setString(
await _scopedKey(_kPivotNextActionKeyBase),
jsonEncode(map),
);
debugPrint('[NEXTLEG] recorded $id → $value');
}
/// Every pivot instruction this device recorded, by order id.
Future<Map<String, String>> getPivotNextActions() async {
final prefs = await SharedPreferences.getInstance();
final raw = prefs.getString(await _scopedKey(_kPivotNextActionKeyBase));
if (raw == null || raw.isEmpty) return <String, String>{};
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) return <String, String>{};
return {
for (final e in decoded.entries)
e.key.toString(): (e.value?.toString() ?? '').toLowerCase(),
}..removeWhere((_, v) => v.isEmpty);
} catch (_) {
return <String, String>{};
}
}
/// Drops instructions for orders that are finished, so the map does not grow
/// for the life of the install.
Future<void> forgetPivotNextActions(List<String> orderIds) async {
if (orderIds.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final map = await getPivotNextActions();
var changed = false;
for (final id in orderIds) {
if (map.remove(id) != null) changed = true;
}
if (changed) {
await prefs.setString(
await _scopedKey(_kPivotNextActionKeyBase),
jsonEncode(map),
);
}
}
/// Clears ids once they are delivered — or once a short pick says they were
/// never in the box to begin with. Without this the set grows for the life of
/// the install and yesterday's run keeps today's cards off Home.
Future<void> removeCollectedOrderIds(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(_kCollectedOrderIdsKeyBase)) ?? [])
.where((id) => !drop.contains(id))
.toList();
await prefs.setStringList(
await _scopedKey(_kCollectedOrderIdsKeyBase),
remaining,
);
}
// ─────────────────────────────────────────────────────────────────────────
// OUT FOR DELIVERY — the load has been released and the round has begun
//
// ── Why this is separate from `collected` ──
//
// Collected means "in my hands". It is written the moment a source hands over
// a crate, and on a two-kitchen morning the rider is collected-but-not-driving
// for the better part of an hour.
//
// If collection alone opened the delivery list, his round would build itself
// underneath him: he finishes kitchen one, five drops appear, he sets off, and
// kitchen two's five arrive behind him — a route he has already half-driven
// past. So the round is held until the whole load is aboard and then released
// in one gesture, which is what the rider means when he presses START DELIVERY.
//
// That press is also a real server event — `POST /miler/deliveries/start` moves
// the consignments to `Out_for_Delivery`, which is the state the delivery route
// requires — so this set is a mirror of a server fact, not a substitute for
// one. It exists because the queue endpoints are a poll behind the rider and he
// must not watch his round appear late.
const String _kOutForDeliveryKeyBase = 'out_for_delivery_order_ids';
/// Order ids the rider is actively delivering.
Future<Set<String>> getOutForDeliveryOrderIds() async {
final prefs = await SharedPreferences.getInstance();
return (prefs.getStringList(await _scopedKey(_kOutForDeliveryKeyBase)) ?? [])
.toSet();
}
/// Releases the round. Called once, with the whole load, after the server has
/// confirmed the transition.
Future<void> addOutForDeliveryOrderIds(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(_kOutForDeliveryKeyBase)) ?? [])
.toSet();
existing.addAll(clean);
await prefs.setStringList(
await _scopedKey(_kOutForDeliveryKeyBase),
existing.toList(),
);
}
/// Clears ids once they are delivered. Without this the set grows for the life
/// of the install and yesterday's round keeps today's cards on the delivery
/// list — the same trap [removeCollectedOrderIds] exists to avoid.
Future<void> removeOutForDeliveryOrderIds(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(_kOutForDeliveryKeyBase)) ?? [])
.where((id) => !drop.contains(id))
.toList();
await prefs.setStringList(
await _scopedKey(_kOutForDeliveryKeyBase),
remaining,
);
}
// ─────────────────────────────────────────────────────────────────────────
// NOT LOADED — orders the kitchen could not supply
//
// Nine bags where the manifest said ten. The tenth is not skipped (nobody was
// visited), not cancelled (the customer did nothing wrong) and not delivered —
// it never entered the rider's box, and the only useful thing the app can do is
// take it off his route immediately and say why, rather than let him drive to a
// door at 12:50 for a meal that does not exist.
const String _kNotLoadedKeyBase = 'not_loaded_orders';
Future<Set<String>> getNotLoadedOrderIds() async {
final prefs = await SharedPreferences.getInstance();
return (prefs.getStringList(await _scopedKey(_kNotLoadedKeyBase)) ?? [])
.toSet();
}
Future<void> addNotLoadedOrderIds(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(_kNotLoadedKeyBase)) ?? []).toSet();
existing.addAll(clean);
await prefs.setStringList(
await _scopedKey(_kNotLoadedKeyBase),
existing.toList(),
);
}
// ─────────────────────────────────────────────────────────────────────────
// THE COUNTER'S OWN LABEL — carried for as long as the order travels
//
// ── Why this is stored rather than recomputed ──
//
// A kitchen that prints labels prints them once, at the counter, and the label
// is what the rider matches against the object in his hands for the rest of the
// day. Recomputing anything from whatever list happens to be on screen is how
// identity breaks — deliver one order and a position-derived label silently
// moves to another, sending the rider looking for something that is already in
// a customer's hallway.
//
// So the pairing is fixed once, at the moment the manifest is confirmed, and
// read back unchanged through delivery, skip, completion and a day of
// intermittent signal. See [OrderManifest.labelFor], which reads it off the
// payload; this only remembers what was read.
//
// ── It is empty on most routes now, and that is correct ──
//
// This used to hold `Bag 1 … Bag n`, manufactured from each order's position
// when the payload printed nothing — so every route stored a set of references
// no counter had ever issued. Only a label the kitchen actually printed is
// stored now, which on a route that prints none means nothing is stored at all
// and every screen simply shows no tag.
// ─────────────────────────────────────────────────────────────────────────
const String _kOrderLabelsKeyBase = 'order_bag_labels';
/// Remembers the label a counter printed on each order. Merges, never
/// replaces: a rider works two kitchens and the second load must not erase the
/// first. Orders with no printed label contribute nothing.
Future<void> saveOrderLabels(Map<String, String> byOrderId) async {
final clean = {
for (final e in byOrderId.entries)
if (e.key.trim().isNotEmpty && e.value.trim().isNotEmpty)
e.key.trim(): e.value.trim(),
};
if (clean.isEmpty) return;
final prefs = await SharedPreferences.getInstance();
final merged = {...await getOrderLabels(), ...clean};
// Stored as `id\u0000label` rows: SharedPreferences has no map type, and a
// NUL separator cannot collide with an order id or a printed label.
await prefs.setStringList(await _scopedKey(_kOrderLabelsKeyBase), [
for (final e in merged.entries) '${e.key}\u0000${e.value}',
]);
}
/// The label each order carries, as recorded at pickup. Empty before any load,
/// and empty all day on a route whose counters print nothing.
Future<Map<String, String>> getOrderLabels() async {
final prefs = await SharedPreferences.getInstance();
final rows =
prefs.getStringList(await _scopedKey(_kOrderLabelsKeyBase)) ??
const <String>[];
return {
for (final row in rows)
if (row.contains('\u0000'))
row.split('\u0000').first: row.split('\u0000').last,
};
}
/// Clears every trace of a day's service run.
///
/// Both sets above are keyed by order id with no date on them, so without this
/// a rider who never finished yesterday's last drop would find today's Home
/// quietly hiding an unrelated order that happened to reuse the id.
Future<void> clearServiceRunState() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(await _scopedKey(_kCollectedOrderIdsKeyBase));
await prefs.remove(await _scopedKey(_kOutForDeliveryKeyBase));
await prefs.remove(await _scopedKey(_kNotLoadedKeyBase));
await prefs.remove(await _scopedKey(_kOrderLabelsKeyBase));
}
// ─────────────────────────────────────────────────────────────────────────
// PURGING THE OLD DEMO LAYER OFF A DEVICE
//
// The mock data is gone from the source, and that is not enough. Every store
// above is SharedPreferences, and a phone that ran a build with the demo layer
// still has those rows on it — the accepted-store key is literally
// `mock_accepted_bookings`. Bookings merges the accepted store into its list
// and Activity reads the completed and skipped ones, so the demo stops keep
// appearing on exactly those two tabs with nothing in the repo producing them.
// Deleting the generator cannot reach data it already wrote.
//
// So this runs once at startup and drops anything the old layer left behind.
// It is keyed on the ids that layer used — `MOCK-Q-1001`, `del-mock-d-2003` —
// which is safe because a real order id comes from the backend and has never
// looked like that. A blanket wipe would take the rider's genuine accepted
// bookings with it.
const List<String> _demoIdMarkers = <String>['mock-', 'demo-'];
bool _looksLikeDemoRecord(Map<String, dynamic> booking) {
for (final key in const ['orderid', 'pickupid', 'orderheaderid']) {
final v = (booking[key] ?? '').toString().toLowerCase();
if (v.isEmpty) continue;
for (final marker in _demoIdMarkers) {
if (v.startsWith(marker) || v.contains('-$marker')) return true;
}
}
return false;
}
bool _looksLikeDemoId(String id) {
final v = id.toLowerCase();
return _demoIdMarkers.any((m) => v.startsWith(m) || v.contains('-$m'));
}
/// Removes every record the retired demo layer wrote, from all six stores.
///
/// Returns how many were dropped, so a one-off cleanup is visible in the log
/// rather than being a silent mutation of the rider's data.
Future<int> purgeDemoRecords() async {
final prefs = await SharedPreferences.getInstance();
var dropped = 0;
for (final key in [
await _scopedKey(_kAcceptedBookingsKeyBase),
await _scopedKey(_kCompletedBookingsKeyBase),
await _scopedKey(_kSkippedBookingsKeyBase),
]) {
final existing = _decode(prefs.getString(key));
if (existing.isEmpty) continue;
final kept = existing.where((b) => !_looksLikeDemoRecord(b)).toList();
if (kept.length != existing.length) {
dropped += existing.length - kept.length;
await prefs.setString(key, jsonEncode(kept));
}
}
for (final key in [
await _scopedKey(_kRejectedOrderIdsKeyBase),
await _scopedKey(_kCollectedOrderIdsKeyBase),
await _scopedKey(_kOutForDeliveryKeyBase),
await _scopedKey(_kNotLoadedKeyBase),
]) {
final existing = prefs.getStringList(key) ?? const <String>[];
if (existing.isEmpty) continue;
final kept = existing.where((id) => !_looksLikeDemoId(id)).toList();
if (kept.length != existing.length) {
dropped += existing.length - kept.length;
await prefs.setStringList(key, kept);
}
}
// The bag map is keyed by order id, so it needs the same sweep — otherwise a
// demo day leaves `MOCK-M-1001 → Bag 1` behind for the life of the install.
// Harmless on its own, but this store exists to be the one place that knows
// which bag an order is in, and a stale entry is exactly the kind of thing
// that is trusted later precisely because it is stored.
final labels =
prefs.getStringList(await _scopedKey(_kOrderLabelsKeyBase)) ??
const <String>[];
if (labels.isNotEmpty) {
final kept = labels
.where((row) => !_looksLikeDemoId(row.split('\u0000').first))
.toList();
if (kept.length != labels.length) {
dropped += labels.length - kept.length;
await prefs.setStringList(await _scopedKey(_kOrderLabelsKeyBase), kept);
}
}
if (dropped > 0) {
debugPrint('[STORE] purged $dropped demo record(s) left by an old build');
}
return dropped;
}