Files
doormile_milderapp/lib/data/work_repository.dart
2026-08-28 11:13:15 +05:30

322 lines
13 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.
unawaited(
stampOrderEvents(
mapped
.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,
),
);
_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');
}
}
/// 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;
}
}