255 lines
10 KiB
Dart
255 lines
10 KiB
Dart
/// ─────────────────────────────────────────────────────────────────────────
|
|
/// 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<String> inOrder = [
|
|
assigned,
|
|
accepted,
|
|
arrivedAtPickup,
|
|
pickedUp,
|
|
outForDelivery,
|
|
];
|
|
}
|
|
|
|
const String _kKey = 'order_events';
|
|
|
|
/// Every order's stamps: `{orderid: {event: iso8601}}`.
|
|
Future<Map<String, Map<String, String>>> _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<void> 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] ?? <String, String>{};
|
|
// 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<void> stampOrderEvents(Iterable<Object> 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<Map<String, Map<String, String>>> stampOrderEventsAndRead(
|
|
Iterable<Object> 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, () => <String, String>{});
|
|
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<Map<String, String>> 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<void> 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<void> 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 = <String, Map<String, String>>{};
|
|
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');
|
|
}
|
|
}
|