993 lines
42 KiB
Dart
993 lines
42 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,
|
|
_kOrderLabelsKeyBase,
|
|
]) {
|
|
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,
|
|
_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),
|
|
);
|
|
}
|
|
|
|
/// 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;
|
|
}
|