import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:miler/Models/stop_status.dart'; import 'package:miler/data/api_config.dart'; import 'package:miler/data/route_order.dart'; import 'package:miler/data/order_events.dart'; import 'package:miler/data/assignment_lookup.dart'; import 'package:miler/data/load_state.dart'; import 'package:miler/data/meal_run_mock.dart'; import 'package:miler/data/miler_api.dart'; import 'package:miler/data/service_profile.dart'; /// ───────────────────────────────────────────────────────────────────────── /// ONE DAY'S WORK, FETCHED ONCE /// /// Home, Deliveries and Activity all answer questions about the same set of /// bookings, and each of them fetched it independently: three timers, three /// copies of the list, three ideas of what had been accepted. Switching tabs /// re-fetched. A rebuild re-fetched. Accepting a stop on Home updated Home's /// copy and left the other two showing yesterday's answer until their own poll /// came round. /// /// This is the one copy. The screens read [state] and listen to [changes]; none /// of them fetches. /// /// ── Three things it does that a plain `Future` does not ── /// /// * **Collapses concurrent callers.** Three screens mounting in the same frame /// produce one request, and all three get its result. The second and third /// callers await the *same* future rather than starting their own. /// * **Refuses stale writes.** Every load carries a sequence number, and a /// response whose sequence is behind the newest one is dropped. A slow /// refresh that lands after a fast one can no longer overwrite it — the /// classic "pull to refresh, then the old response arrives and the list goes /// backwards" bug. /// * **Distinguishes the three empties.** Nothing today, could not ask, and no /// endpoint for this line are different answers — see [LoadState]. /// /// ── What it deliberately does not do ── /// /// No polling of its own, no retry loop, no cache written to disk. The screens /// own when to ask; this owns making sure asking twice costs one request. /// ───────────────────────────────────────────────────────────────────────── class WorkRepository { WorkRepository._(); /// The app's instance. A plain static rather than a DI registration, matching /// how [ServiceProfile] and the stores in this layer are reached — data code /// here must not need a widget tree to exist. static final WorkRepository instance = WorkRepository._(); final _controller = StreamController>>>.broadcast(); LoadState>> _state = const LoadLoading>>(); /// The current answer. Safe to read during `build`. LoadState>> get state => _state; /// Every change, for screens that want to rebuild without polling. Stream>>> get changes => _controller.stream; /// The request in flight, if any. A second caller awaits this rather than /// starting a second request. Future>>>? _inFlight; /// Monotonic, so a late response can be recognised as late. int _sequence = 0; /// How long a result stays fresh enough to hand to a new caller without /// asking again. Covers the case this exists for: three screens mounting /// within a frame of each other, and a tab switch a second later. static const Duration freshFor = Duration(seconds: 10); DateTime? _loadedAt; bool get _isFresh { final at = _loadedAt; return at != null && DateTime.now().difference(at) < freshFor; } /// Loads the day, or hands back what is already loading or already fresh. /// /// [force] skips the freshness check — pull-to-refresh — but still collapses /// into an in-flight request rather than racing it. Future>>> load({bool force = false}) { // ── Collapse, unless the rider asked ── // // An *incidental* load — a screen mounting, a tab switch, a rebuild — // joins whatever is already running. That is the whole reason this exists, // and it is what stops three screens producing three requests. // // A **forced** load does not, because a pull-to-refresh that quietly hands // back the result of a request started before the rider's gesture is not a // refresh. It starts its own, and the sequence guard in [_publish] drops // whichever response lands out of order. final existing = _inFlight; if (existing != null && !force) return existing; if (!force && _isFresh && _state is LoadData) { return Future.value(_state); } final future = _fetch(); _inFlight = future; return future.whenComplete(() { if (identical(_inFlight, future)) _inFlight = null; }); } /// Marks the current answer stale and re-reads it. /// /// Called after every mutation the server has confirmed, so the screens take /// their new state from the API rather than from the button that was pressed. Future>>> invalidate() { _loadedAt = null; return load(force: true); } Future>>> _fetch() async { final seq = ++_sequence; // Keep whatever is on screen visible while the refresh runs. A pull to // refresh must not blank the list it is refreshing. final current = _state; _publish( current is LoadData>> ? LoadData(current.value, refreshing: true) : const LoadLoading>>(), seq, ); // The opt-in development fixture, checked first and named as such. See // [MealRunMock]; it is unreachable unless somebody passed MOCK_BACKEND. if (MealRunMock.active) { final day = MealRunMock.stops().cast>(); debugPrint('[WORK] OPT-IN FIXTURE, not the API: ${day.length} stops'); return _publish( day.isEmpty ? const LoadEmpty>>() : LoadData(day), seq, ); } // No fixture and no endpoint. Distinct from an empty day, because neither // waiting nor retrying will change it. if (!ServiceProfile.active.hasBookingsEndpoint) { return _publish( LoadUnavailable>>( '${ServiceProfile.active.label} work is not served by this backend ' 'yet.', ), seq, ); } final res = await MilerApi.bookings(); if (!res.ok) { return _publish( LoadFailure>>( _classify(res), message: res.message, ), seq, ); } final mapped = ApiConfig.pickupsFromBookings(res.list); // ── The hub's solved order, stamped on here and nowhere else ── // // `step` now ships on the booking row itself (verified live 21 Aug 2026), // so the common path is that this finds nothing to do — which is the // intended end state. It stays because it is the delivery leg's safety // net: the assignment row is the field's original home, and a booking that // arrives without one but has a live assignment carrying it must not lose // the hub's order. **A `step` already on the payload always wins.** // // Merged at the repository rather than on a screen, because all three // screens order by it and a stop that is third on Home must not be second // on Deliveries. // // Best-effort, and deliberately so: the flow contract says an optimizer // outage leaves work *assigned but unordered*, never undone. A failure here // leaves every stop as it arrived, and [RouteOrder] then falls back — and // says that it has, rather than passing its own guess off as the route. await _stampSequence(mapped); if (mapped.isEmpty && res.list.isNotEmpty) { // Rows arrived and none survived translation — a field-name mismatch, // not an empty day. Said out loud rather than rendered as "no work". ApiConfig.logGap( 'pickupFromBooking', '${res.list.length} bookings returned but none mapped — check the ' 'field names against a real payload.', ); return _publish( const LoadFailure>>( LoadFailureKind.server, message: 'Your office sent work this app could not read.', ), seq, ); } // ── When the work reached this rider ── // // The contract has no assignment timestamp: `GET /miler/assignments` // returns the rows and no clock on them, and the booking's own `updatedat` // moves every time anything touches it — the console warns against reading // it as an assignment time for exactly that reason. // // What the app can say truthfully is when a booking *first appeared in this // rider's queue*, which is the moment it became his. Stamped here because // this is the one place every screen's day comes through, and stamped once: // [stampOrderEvent] never overwrites, so a poll a second later cannot move // it. Read only by the Activity timeline; nothing decides anything on it. // // **Only work still in front of him.** Stamping every row the fetch carries // put an `Assigned 3:34 PM` on a stop that had been delivered at 11:57 that // morning: the ledger did not exist when the work arrived, so the first // sighting was the app being opened in the afternoon. A clock that lands // after the completion it is supposed to precede is worse than no clock — // it is the timeline contradicting itself in the rider's face. Anything the // backend already reports as past his hands is skipped, and stays skipped // forever because [stampOrderEvent] never overwrites. // // ── And it is read back onto the row ── // // It used to be fired into the dark with `unawaited`: written for one // screen's timeline, and joined by that screen alone. That left every other // screen with no clock at all on a row the backend dates with nothing, and // "does this belong to today" is a question three of them ask. // // So the stamp comes back as `assignedat` — a field on the stop, like any // other. Awaited rather than fired off, because a row that reaches Home // before its own clock does is a row Home cannot place, and the batch is // one read and one write of a small blob. See [stampOrderEventsAndRead]. await _stampAssignment(mapped); _loadedAt = DateTime.now(); return _publish( mapped.isEmpty ? const LoadEmpty>>() : LoadData(mapped), seq, ); } /// Writes the assignment sequence onto the day's stops, in place. Future _stampSequence(List> day) async { if (day.isEmpty) return; try { final steps = await AssignmentLookup.steps(); if (steps.isEmpty) return; var stamped = 0; for (final stop in day) { // Read through [RouteOrder] so "already sequenced" means the same // thing here as it does at every screen that orders by it. if (RouteOrder.sequenceOf(stop) > 0) continue; final key = (stop['bookingid'] ?? stop['orderheaderid'] ?? '') .toString() .trim(); final step = steps[key]; if (step == null) continue; stop['step'] = step; stamped++; } debugPrint('[WORK] sequenced $stamped/${day.length} stops from the hub'); final unsequenced = day .where((s) => RouteOrder.sequenceOf(s) == 0) .length; RouteOrder.logUnsequenced('work-repository', unsequenced); } catch (e) { // Never fails the day's work: an unordered route is workable, a missing // one is not. debugPrint('[WORK] could not read the assignment sequence: $e'); } } /// Writes each stop's assignment clock onto it, in place, as `assignedat`. /// /// ── Which rows are stamped, and which only read ── /// /// Only work still in front of the rider is *stamped* — see the note at the /// call site: the ledger did not exist when a stop that is already delivered /// arrived, so its first sighting is whenever the app happened to be opened, /// and an `Assigned 3:34 PM` above a `Delivered 11:57 AM` is the timeline /// contradicting itself. /// /// Every row is **read**, though, finished ones included. A stop the rider /// collected this morning was stamped this morning while it was still ahead /// of him, and that clock is exactly what keeps it inside today's trip — drop /// it and the progress ring loses its own denominator the moment the work is /// done. /// /// A backend clock always wins: this only fills a gap, never overwrites. static Future _stampAssignment(List> day) async { if (day.isEmpty) return; try { final ledger = await stampOrderEventsAndRead( day .where((s) { final st = stopStatusOf(s); return !st.isWorkComplete && !st.isCancelled && !st.isRejected && !st.isSkipped && !st.isPicked && !st.isDeliveryLeg; }) .map((s) => (s['orderid'] ?? '').toString()) .where((id) => id.isNotEmpty), OrderEvent.assigned, ); if (ledger.isEmpty) return; for (final stop in day) { final id = (stop['orderid'] ?? '').toString().trim(); if (id.isEmpty) continue; final at = ledger[id]?[OrderEvent.assigned]; if (at == null || at.isEmpty) continue; final existing = (stop['assignedat'] ?? '').toString().trim(); if (existing.isNotEmpty) continue; stop['assignedat'] = at; } } catch (e) { // A day with no assignment clocks is a day the screens date by whatever // the backend sent. Never worth failing the fetch over. debugPrint('[WORK] could not read the assignment ledger: $e'); } } /// Turns a refused call into the thing the rider does about it. static LoadFailureKind _classify(ApiResult res) => switch (res.status) { 0 => LoadFailureKind.offline, 401 || 403 => LoadFailureKind.unauthorized, 429 => LoadFailureKind.rateLimited, _ => LoadFailureKind.server, }; /// Publishes [next] unless a newer load has already started. LoadState>> _publish( LoadState>> next, int seq, ) { if (seq < _sequence) { // A slower earlier request finishing after a newer one. Dropping it is // the whole reason the sequence exists. debugPrint('[WORK] dropped stale response #$seq (newest is $_sequence)'); return _state; } _state = next; if (!_controller.isClosed) _controller.add(next); return next; } /// Test seam. Returns the repository to the state a fresh launch has. @visibleForTesting void resetForTest() { _state = const LoadLoading>>(); _inFlight = null; _sequence = 0; _loadedAt = null; } }