/// ───────────────────────────────────────────────────────────────────────── /// 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 { final at = DateTime.now(); for (final id in orderIds) { await stampOrderEvent(id, event, at: at); } } /// 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. Two days rather than one, because a shift that crosses /// midnight must not lose its own morning. Future pruneOrderEvents({int keepDays = 2}) 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'); } }