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 _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 _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 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 debugStoreKey(String base) => _scopedKey(base); @visibleForTesting Future 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 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 _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> _decode(String? raw) { if (raw == null || raw.isEmpty) return []; try { final decoded = jsonDecode(raw); if (decoded is List) { return decoded .whereType() .map((e) => Map.from(e)) .toList(); } } catch (_) {} return []; } /// All bookings that have been locally accepted (each carries /// `orderstatus: 'accepted'`). Future>> 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> 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> getRejectedOrderIds() async { final prefs = await SharedPreferences.getInstance(); return (prefs.getStringList(await _scopedKey(_kRejectedOrderIdsKeyBase)) ?? []) .toSet(); } /// Remember the given order ids as rejected. Future addRejectedOrderIds(List 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 removeRejectedOrderIds(List 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 removeAcceptedBookings(List 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>> 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 addCompletedBookings( List> 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> 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.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>> 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> 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> 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 addSkippedBooking( Map booking, { String reason = '', }) async { final id = (booking['orderid'] ?? '').toString(); if (id.isEmpty) return; final prefs = await SharedPreferences.getInstance(); final now = DateTime.now(); final Map> byId = { for (final b in _decode( prefs.getString(await _scopedKey(_kSkippedBookingsKeyBase)), )) (b['orderid'] ?? '').toString(): b, }; final copy = Map.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 removeSkippedBookings(List 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 addAcceptedBookings(List> bookings) async { if (bookings.isEmpty) return; final prefs = await SharedPreferences.getInstance(); final Map> 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.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. const String _kCollectedOrderIdsKeyBase = 'collected_order_ids'; /// Order ids the rider has loaded and is carrying. Future> 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 addCollectedOrderIds(List 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 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> getConsignmentIds() async { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(await _scopedKey(_kConsignmentIdsKeyBase)); if (raw == null || raw.isEmpty) return {}; try { final decoded = jsonDecode(raw); if (decoded is! Map) return {}; return { for (final e in decoded.entries) e.key.toString(): e.value?.toString() ?? '', }..removeWhere((_, v) => v.isEmpty); } catch (_) { return {}; } } /// Drops ids for orders that are finished, so the map does not grow for the /// life of the install. Future forgetConsignmentIds(List 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 removeCollectedOrderIds(List 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> 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 addOutForDeliveryOrderIds(List 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 removeOutForDeliveryOrderIds(List 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> getNotLoadedOrderIds() async { final prefs = await SharedPreferences.getInstance(); return (prefs.getStringList(await _scopedKey(_kNotLoadedKeyBase)) ?? []) .toSet(); } Future addNotLoadedOrderIds(List 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 saveBagLabels(Map 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> getBagLabels() async { final prefs = await SharedPreferences.getInstance(); final rows = prefs.getStringList(await _scopedKey(_kBagLabelsKeyBase)) ?? const []; 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 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 _demoIdMarkers = ['mock-', 'demo-']; bool _looksLikeDemoRecord(Map 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 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 []; 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 []; 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; }