Files
doormile_milderapp/lib/data/accepted_store.dart
2026-08-28 11:13:15 +05:30

954 lines
40 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}');
}
/// 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,
_kOutForDeliveryKeyBase,
_kNotLoadedKeyBase,
_kBagLabelsKeyBase,
]) {
await _drainLegacy(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,
_kOutForDeliveryKeyBase,
_kNotLoadedKeyBase,
_kBagLabelsKeyBase,
]) {
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';
/// 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;
// 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));
}
// 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),
);
}
/// 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(),
);
}
// ─────────────────────────────────────────────────────────────────────────
// BAG IDENTITY — which bag an order travels in, for as long as it travels
//
// ── Why this is stored rather than recomputed ──
//
// A bag number is the order's position in the load it came off — Joe is Bag 1
// of the five that left Vidhya Kitchen. Recomputing that later from whatever
// list happens to be on screen is how identity breaks: deliver Joe, and a
// position-derived Arun silently becomes Bag 1. The rider is then looking for a
// bag labelled 1 that is in a customer's hallway.
//
// So the pairing is fixed once, at the counter, at the moment the manifest is
// confirmed — and read back unchanged through delivery, skip, completion and a
// day of intermittent signal. See [BagManifest], which derives it; this only
// remembers what it derived.
// ─────────────────────────────────────────────────────────────────────────
const String _kBagLabelsKeyBase = 'order_bag_labels';
/// Remembers which bag each order travels in. Merges, never replaces: a rider
/// works two kitchens and the second load must not erase the first.
Future<void> saveBagLabels(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 getBagLabels(), ...clean};
// Stored as `id\u0000label` rows: SharedPreferences has no map type, and a
// NUL separator cannot collide with an order id or a bag label.
await prefs.setStringList(await _scopedKey(_kBagLabelsKeyBase), [
for (final e in merged.entries) '${e.key}\u0000${e.value}',
]);
}
/// The bag each order is in, as recorded at pickup. Empty before any load.
Future<Map<String, String>> getBagLabels() async {
final prefs = await SharedPreferences.getInstance();
final rows =
prefs.getStringList(await _scopedKey(_kBagLabelsKeyBase)) ??
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(_kBagLabelsKeyBase));
}
// ─────────────────────────────────────────────────────────────────────────
// 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 bags =
prefs.getStringList(await _scopedKey(_kBagLabelsKeyBase)) ??
const <String>[];
if (bags.isNotEmpty) {
final kept = bags
.where((row) => !_looksLikeDemoId(row.split('\u0000').first))
.toList();
if (kept.length != bags.length) {
dropped += bags.length - kept.length;
await prefs.setStringList(await _scopedKey(_kBagLabelsKeyBase), kept);
}
}
if (dropped > 0) {
debugPrint('[STORE] purged $dropped demo record(s) left by an old build');
}
return dropped;
}