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>
This commit is contained in:
321
lib/data/work_repository.dart
Normal file
321
lib/data/work_repository.dart
Normal file
@@ -0,0 +1,321 @@
|
||||
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: 'The hub 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user