/// ───────────────────────────────────────────────────────────────────────── /// WHEN EACH THING HAPPENED TO AN ORDER /// /// ── Why this exists ── /// /// Activity could show three moments — set off, arrived, finished — because /// those were the only three anything wrote down. Everything else the rider did /// to an order happened, changed a screen, went to the backend and left no /// local trace: he accepted it at 9:10, reached the kitchen at 9:28, took the /// bag at 9:35, and by the time he opened his own history all three were gone. /// /// The backend knows some of it and returns none of it: `GET /miler/bookings` /// carries a booking's *current* status and its `createdat`, and there is no /// per-order event feed on the contract. So a rider asking "when did I accept /// that?" had nothing to read, and the honest timeline was three rungs long. /// /// This is the ledger those moments go into. Each stamp is written **at the /// moment it is true**, by the code that already knows — the same argument /// `addCompletedBookings` makes for the two clocks it rescues out of /// SharedPreferences. /// /// ── What it is not ── /// /// Not a source of truth about the order, and not a substitute for one. The /// backend owns the lifecycle; this owns *when the rider did his half of it*, /// for the one screen that asks. Nothing reads it to decide anything — no gate, /// no filter, no status. If the file were deleted the app would behave /// identically and Activity would draw fewer rungs, which is exactly the /// degradation it is built for: **a moment with no stamp is not drawn.** /// /// ── Written once ── /// /// [stampOrderEvent] never overwrites. A rider who re-accepts a stop he /// un-rejected, or re-opens a map screen, is on the same errand; moving the /// clock forward would quietly erase the first time he did it. The one /// exception is a resumed skip, which is a genuinely new attempt — see /// [clearOrderEvents]. /// ───────────────────────────────────────────────────────────────────────── library; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; /// The moments an order passes through, in the order they happen. /// /// String values, not an index: they are written into JSON that outlives the /// build that wrote it, and renumbering an enum would silently re-label every /// stamp already on the device. class OrderEvent { OrderEvent._(); /// The booking first appeared in this rider's queue. /// /// Not the hub's assignment clock — the contract has none, and the booking's /// `updatedat` moves every time anything touches the row. This is when the /// work *reached him*, stamped by [WorkRepository] on the first fetch that /// carries it, which is the moment it became his as far as his phone is /// concerned. static const String assigned = 'assigned'; /// The rider took the stop on. `POST /assignments/:id/accept`. static const String accepted = 'accepted'; /// He reached the counter. `POST /bookings/:id/reached`. static const String arrivedAtPickup = 'arrived_at_pickup'; /// The bag is in his hands. `POST /bookings/:id/pickup-complete`. static const String pickedUp = 'picked_up'; /// He set off on the round. `POST /miler/deliveries/start`. static const String outForDelivery = 'out_for_delivery'; /// Every event, in the order a timeline should read them. static const List inOrder = [ assigned, accepted, arrivedAtPickup, pickedUp, outForDelivery, ]; } const String _kKey = 'order_events'; /// Every order's stamps: `{orderid: {event: iso8601}}`. Future>> _read(SharedPreferences p) async { try { final raw = p.getString(_kKey); if (raw == null || raw.isEmpty) return {}; final decoded = jsonDecode(raw); if (decoded is! Map) return {}; return { for (final entry in decoded.entries) entry.key.toString(): { if (entry.value is Map) for (final e in (entry.value as Map).entries) e.key.toString(): e.value.toString(), }, }; } catch (e) { // A corrupt ledger must never take the app down: this is the one store // nothing depends on, so an unreadable one is an empty one. debugPrint('[EVENTS] unreadable, starting empty: $e'); return {}; } } /// Records [event] against [orderId], if it has not been recorded already. Future stampOrderEvent( Object orderId, String event, { DateTime? at, }) async { final id = orderId.toString().trim(); if (id.isEmpty) return; try { final prefs = await SharedPreferences.getInstance(); final all = await _read(prefs); final mine = all[id] ?? {}; // Written once — see the class doc. if (mine.containsKey(event)) return; mine[event] = (at ?? DateTime.now()).toIso8601String(); all[id] = mine; await prefs.setString(_kKey, jsonEncode(all)); } catch (e) { debugPrint('[EVENTS] could not stamp $event on $id: $e'); } } /// Stamps one event against several orders at once — a kitchen handover, an /// accept-all, a released round. Future stampOrderEvents(Iterable orderIds, String event) async => stampOrderEventsAndRead(orderIds, event); /// [stampOrderEvents], and hands back the whole ledger it just wrote. /// /// ── Why the batch is one read and one write ── /// /// [stampOrderEvent] is a read-modify-write of the entire ledger, and the loop /// that called it did that **once per order**. A rider holding sixty bookings /// therefore paid sixty decodes and sixty encodes of a JSON blob that grows /// with his day, on every poll — which is why the call site had to fire it into /// the dark with `unawaited` and could never use the result. /// /// It is used now: [WorkRepository] merges the assignment stamp back onto the /// rows it just fetched, so "when did this reach me" is a fact on the stop /// rather than a second store every screen has to join for itself. That needs /// the ledger *after* the write, which is the other half of why this exists. /// /// Still written once — an order that already carries [event] keeps the clock /// it has. Future>> stampOrderEventsAndRead( Iterable orderIds, String event, ) async { try { final prefs = await SharedPreferences.getInstance(); final all = await _read(prefs); final at = DateTime.now().toIso8601String(); var added = 0; for (final raw in orderIds) { final id = raw.toString().trim(); if (id.isEmpty) continue; final mine = all.putIfAbsent(id, () => {}); if (mine.containsKey(event)) continue; mine[event] = at; added++; } if (added > 0) await prefs.setString(_kKey, jsonEncode(all)); return all; } catch (e) { debugPrint('[EVENTS] could not stamp $event on a batch: $e'); return const {}; } } /// What is known about one order, as `{event: iso8601}`. Future> getOrderEvents(Object orderId) async { final id = orderId.toString().trim(); if (id.isEmpty) return const {}; final prefs = await SharedPreferences.getInstance(); return (await _read(prefs))[id] ?? const {}; } /// Forgets an order's stamps. /// /// Called when a skipped stop is resumed: the rider is making a second attempt /// at the same door, and the first attempt's clocks describe a visit that has /// been superseded. Same argument `addCompletedBookings` makes when it clears /// the two prefs keys it has just read. Future clearOrderEvents(Object orderId) async { final id = orderId.toString().trim(); if (id.isEmpty) return; try { final prefs = await SharedPreferences.getInstance(); final all = await _read(prefs); if (all.remove(id) != null) { await prefs.setString(_kKey, jsonEncode(all)); } } catch (e) { debugPrint('[EVENTS] could not clear $id: $e'); } } /// Drops orders whose last stamp is older than [keepDays]. /// /// The ledger is per-order and nothing prunes it on read, so without this it /// grows for the life of the install — the same trap `removeCollectedOrderIds` /// exists to avoid. /// /// ── Why two days was not enough once Home read this ── /// /// It was two, on the reasoning that a shift crossing midnight must not lose /// its own morning. That is the right floor for a *timeline*, which is all this /// fed. It is the wrong floor for a **day filter**. /// /// [OrderEvent.assigned] is now what dates a booking the backend dates with /// nothing, and an order still sitting in the queue undecided carries that one /// stamp and no other. At two days it was pruned on the third morning, the next /// fetch stamped it afresh with *that* day's clock, and a booking from Monday /// reappeared on Thursday's Home as Thursday's work — the precise failure the /// filter was added to stop, arriving three days late. /// /// A fortnight is well past any open booking's life, and the cost is a few /// hundred bytes: the entry is an id and up to five short strings. Future pruneOrderEvents({int keepDays = 14}) async { try { final prefs = await SharedPreferences.getInstance(); final all = await _read(prefs); final cutoff = DateTime.now().subtract(Duration(days: keepDays)); final kept = >{}; for (final entry in all.entries) { DateTime? newest; for (final iso in entry.value.values) { final t = DateTime.tryParse(iso); if (t != null && (newest == null || t.isAfter(newest))) newest = t; } // An entry with no parseable stamp in it is kept: it is either from a // build that wrote a shape this one does not know, or corrupt, and // deleting somebody's history to tidy up is the worse failure. if (newest == null || newest.isAfter(cutoff)) kept[entry.key] = entry.value; } if (kept.length != all.length) { await prefs.setString(_kKey, jsonEncode(kept)); } } catch (e) { debugPrint('[EVENTS] prune failed: $e'); } }