Miler rider app: surface system, visible design language, backend lifecycle
Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,134 @@
|
||||
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,
|
||||
_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,
|
||||
_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 _kAcceptedBookingsKey = 'mock_accepted_bookings';
|
||||
const String _kRejectedOrderIdsKey = 'mock_rejected_order_ids';
|
||||
const String _kAcceptedBookingsKeyBase = 'mock_accepted_bookings';
|
||||
const String _kRejectedOrderIdsKeyBase = 'mock_rejected_order_ids';
|
||||
|
||||
/// Stops finished today, for the Activity tab.
|
||||
///
|
||||
@@ -26,7 +148,7 @@ const String _kRejectedOrderIdsKey = 'mock_rejected_order_ids';
|
||||
///
|
||||
/// This is the record that survives that. It is the same trade the accepted
|
||||
/// store already makes, for the same reason.
|
||||
const String _kCompletedBookingsKey = 'completed_bookings';
|
||||
const String _kCompletedBookingsKeyBase = 'completed_bookings';
|
||||
|
||||
/// Stops the rider parked mid-shift for a return visit.
|
||||
///
|
||||
@@ -43,8 +165,8 @@ const String _kCompletedBookingsKey = 'completed_bookings';
|
||||
///
|
||||
/// 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
|
||||
/// [_kCompletedBookingsKey], for the same reason.
|
||||
const String _kSkippedBookingsKey = 'skipped_bookings';
|
||||
/// [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 [];
|
||||
@@ -64,7 +186,7 @@ List<Map<String, dynamic>> _decode(String? raw) {
|
||||
/// `orderstatus: 'accepted'`).
|
||||
Future<List<Map<String, dynamic>>> getAcceptedBookings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return _decode(prefs.getString(_kAcceptedBookingsKey));
|
||||
return _decode(prefs.getString(await _scopedKey(_kAcceptedBookingsKeyBase)));
|
||||
}
|
||||
|
||||
/// The set of order ids that have been locally accepted.
|
||||
@@ -80,7 +202,9 @@ Future<Set<String>> getAcceptedOrderIds() async {
|
||||
/// rejected booking leaves the pending list.
|
||||
Future<Set<String>> getRejectedOrderIds() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return (prefs.getStringList(_kRejectedOrderIdsKey) ?? []).toSet();
|
||||
return (prefs.getStringList(await _scopedKey(_kRejectedOrderIdsKeyBase)) ??
|
||||
[])
|
||||
.toSet();
|
||||
}
|
||||
|
||||
/// Remember the given order ids as rejected.
|
||||
@@ -88,9 +212,14 @@ 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(_kRejectedOrderIdsKey) ?? []).toSet();
|
||||
final existing =
|
||||
(prefs.getStringList(await _scopedKey(_kRejectedOrderIdsKeyBase)) ?? [])
|
||||
.toSet();
|
||||
existing.addAll(clean);
|
||||
await prefs.setStringList(_kRejectedOrderIdsKey, existing.toList());
|
||||
await prefs.setStringList(
|
||||
await _scopedKey(_kRejectedOrderIdsKeyBase),
|
||||
existing.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Undo a rejection.
|
||||
@@ -102,10 +231,14 @@ 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(_kRejectedOrderIdsKey) ?? [])
|
||||
.where((id) => !drop.contains(id))
|
||||
.toList();
|
||||
await prefs.setStringList(_kRejectedOrderIdsKey, remaining);
|
||||
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
|
||||
@@ -116,9 +249,12 @@ Future<void> removeAcceptedBookings(List<String> ids) async {
|
||||
if (drop.isEmpty) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final remaining = _decode(
|
||||
prefs.getString(_kAcceptedBookingsKey),
|
||||
prefs.getString(await _scopedKey(_kAcceptedBookingsKeyBase)),
|
||||
).where((b) => !drop.contains((b['orderid'] ?? '').toString())).toList();
|
||||
await prefs.setString(_kAcceptedBookingsKey, jsonEncode(remaining));
|
||||
await prefs.setString(
|
||||
await _scopedKey(_kAcceptedBookingsKeyBase),
|
||||
jsonEncode(remaining),
|
||||
);
|
||||
}
|
||||
|
||||
/// Stops the rider finished **today**, newest first.
|
||||
@@ -128,7 +264,9 @@ Future<void> removeAcceptedBookings(List<String> ids) async {
|
||||
/// 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(_kCompletedBookingsKey));
|
||||
final all = _decode(
|
||||
prefs.getString(await _scopedKey(_kCompletedBookingsKeyBase)),
|
||||
);
|
||||
final today = _dayStamp(DateTime.now());
|
||||
|
||||
final mine = all.where((b) => (b['completedday'] ?? '') == today).toList()
|
||||
@@ -140,7 +278,10 @@ Future<List<Map<String, dynamic>>> getCompletedBookings() async {
|
||||
|
||||
// Prune in the background if yesterday's rows are still in there.
|
||||
if (mine.length != all.length) {
|
||||
await prefs.setString(_kCompletedBookingsKey, jsonEncode(mine));
|
||||
await prefs.setString(
|
||||
await _scopedKey(_kCompletedBookingsKeyBase),
|
||||
jsonEncode(mine),
|
||||
);
|
||||
}
|
||||
return mine;
|
||||
}
|
||||
@@ -169,19 +310,35 @@ String kStopArrivedKey(Object pickupId) => 'pickup_start_$pickupId';
|
||||
/// 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(_kCompletedBookingsKey)))
|
||||
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;
|
||||
@@ -189,7 +346,11 @@ Future<void> addCompletedBookings(
|
||||
// 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.
|
||||
copy['orderstatus'] = cancelled ? 'cancelled' : 'picked';
|
||||
// `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);
|
||||
|
||||
@@ -205,11 +366,13 @@ Future<void> addCompletedBookings(
|
||||
await prefs.remove(kStopArrivedKey(pickupId));
|
||||
}
|
||||
|
||||
byId[id] = copy;
|
||||
// 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(
|
||||
_kCompletedBookingsKey,
|
||||
await _scopedKey(_kCompletedBookingsKeyBase),
|
||||
jsonEncode(byId.values.toList()),
|
||||
);
|
||||
}
|
||||
@@ -221,7 +384,9 @@ Future<void> addCompletedBookings(
|
||||
/// no longer act on.
|
||||
Future<List<Map<String, dynamic>>> getSkippedBookings() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final all = _decode(prefs.getString(_kSkippedBookingsKey));
|
||||
final all = _decode(
|
||||
prefs.getString(await _scopedKey(_kSkippedBookingsKeyBase)),
|
||||
);
|
||||
final today = _dayStamp(DateTime.now());
|
||||
|
||||
final mine = all.where((b) => (b['skippedday'] ?? '') == today).toList()
|
||||
@@ -232,11 +397,30 @@ Future<List<Map<String, dynamic>>> getSkippedBookings() async {
|
||||
);
|
||||
|
||||
if (mine.length != all.length) {
|
||||
await prefs.setString(_kSkippedBookingsKey, jsonEncode(mine));
|
||||
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();
|
||||
@@ -258,7 +442,9 @@ Future<void> addSkippedBooking(
|
||||
final now = DateTime.now();
|
||||
|
||||
final Map<String, Map<String, dynamic>> byId = {
|
||||
for (final b in _decode(prefs.getString(_kSkippedBookingsKey)))
|
||||
for (final b in _decode(
|
||||
prefs.getString(await _scopedKey(_kSkippedBookingsKeyBase)),
|
||||
))
|
||||
(b['orderid'] ?? '').toString(): b,
|
||||
};
|
||||
|
||||
@@ -271,7 +457,10 @@ Future<void> addSkippedBooking(
|
||||
copy['skippedday'] = _dayStamp(now);
|
||||
byId[id] = copy;
|
||||
|
||||
await prefs.setString(_kSkippedBookingsKey, jsonEncode(byId.values.toList()));
|
||||
await prefs.setString(
|
||||
await _scopedKey(_kSkippedBookingsKeyBase),
|
||||
jsonEncode(byId.values.toList()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Forget a skip — the rider resumed the stop, so it is live work again.
|
||||
@@ -280,21 +469,29 @@ Future<void> removeSkippedBookings(List<String> ids) async {
|
||||
if (drop.isEmpty) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final remaining = _decode(
|
||||
prefs.getString(_kSkippedBookingsKey),
|
||||
prefs.getString(await _scopedKey(_kSkippedBookingsKeyBase)),
|
||||
).where((b) => !drop.contains((b['orderid'] ?? '').toString())).toList();
|
||||
await prefs.setString(_kSkippedBookingsKey, jsonEncode(remaining));
|
||||
await prefs.setString(
|
||||
await _scopedKey(_kSkippedBookingsKeyBase),
|
||||
jsonEncode(remaining),
|
||||
);
|
||||
}
|
||||
|
||||
String _dayStamp(DateTime t) =>
|
||||
'${t.year}-${t.month.toString().padLeft(2, '0')}-'
|
||||
'${t.day.toString().padLeft(2, '0')}';
|
||||
/// 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(_kAcceptedBookingsKey)))
|
||||
for (final b in _decode(
|
||||
prefs.getString(await _scopedKey(_kAcceptedBookingsKeyBase)),
|
||||
))
|
||||
(b['orderid'] ?? '').toString(): b,
|
||||
};
|
||||
for (final b in bookings) {
|
||||
@@ -305,7 +502,384 @@ Future<void> addAcceptedBookings(List<Map<String, dynamic>> bookings) async {
|
||||
byId[id] = copy;
|
||||
}
|
||||
await prefs.setString(
|
||||
_kAcceptedBookingsKey,
|
||||
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.
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user