Files
doormile_milderapp/lib/data/order_events.dart
Thiru-tenext d7348e253f 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>
2026-08-22 05:40:35 +05:30

201 lines
7.9 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 {
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<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. Two days rather than one, because a shift that crosses
/// midnight must not lose its own morning.
Future<void> 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 = <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');
}
}