371 lines
15 KiB
Dart
371 lines
15 KiB
Dart
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<LoadState<List<Map<String, dynamic>>>>.broadcast();
|
|
|
|
LoadState<List<Map<String, dynamic>>> _state =
|
|
const LoadLoading<List<Map<String, dynamic>>>();
|
|
|
|
/// The current answer. Safe to read during `build`.
|
|
LoadState<List<Map<String, dynamic>>> get state => _state;
|
|
|
|
/// Every change, for screens that want to rebuild without polling.
|
|
Stream<LoadState<List<Map<String, dynamic>>>> get changes =>
|
|
_controller.stream;
|
|
|
|
/// The request in flight, if any. A second caller awaits this rather than
|
|
/// starting a second request.
|
|
Future<LoadState<List<Map<String, dynamic>>>>? _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<LoadState<List<Map<String, dynamic>>>> 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<LoadState<List<Map<String, dynamic>>>> invalidate() {
|
|
_loadedAt = null;
|
|
return load(force: true);
|
|
}
|
|
|
|
Future<LoadState<List<Map<String, dynamic>>>> _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<List<Map<String, dynamic>>>
|
|
? LoadData(current.value, refreshing: true)
|
|
: const LoadLoading<List<Map<String, dynamic>>>(),
|
|
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<Map<String, dynamic>>();
|
|
debugPrint('[WORK] OPT-IN FIXTURE, not the API: ${day.length} stops');
|
|
return _publish(
|
|
day.isEmpty
|
|
? const LoadEmpty<List<Map<String, dynamic>>>()
|
|
: 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<List<Map<String, dynamic>>>(
|
|
'${ServiceProfile.active.label} work is not served by this backend '
|
|
'yet.',
|
|
),
|
|
seq,
|
|
);
|
|
}
|
|
|
|
final res = await MilerApi.bookings();
|
|
|
|
if (!res.ok) {
|
|
return _publish(
|
|
LoadFailure<List<Map<String, dynamic>>>(
|
|
_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<List<Map<String, dynamic>>>(
|
|
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<List<Map<String, dynamic>>>()
|
|
: LoadData(mapped),
|
|
seq,
|
|
);
|
|
}
|
|
|
|
/// Writes the assignment sequence onto the day's stops, in place.
|
|
Future<void> _stampSequence(List<Map<String, dynamic>> 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<void> _stampAssignment(List<Map<String, dynamic>> 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<List<Map<String, dynamic>>> _publish(
|
|
LoadState<List<Map<String, dynamic>>> 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<List<Map<String, dynamic>>>();
|
|
_inFlight = null;
|
|
_sequence = 0;
|
|
_loadedAt = null;
|
|
}
|
|
}
|