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:
2026-08-22 05:40:35 +05:30
parent c8350563d9
commit d7348e253f
387 changed files with 80693 additions and 12272 deletions

View File

@@ -1,6 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:miler/data/api_config.dart';
import 'package:miler/data/work_repository.dart';
import 'package:miler/data/load_state.dart';
import 'package:miler/data/miler_api.dart';
/// Reads the rider's bookings.
@@ -26,25 +26,56 @@ class PickupProvider {
/// The mapping lives in [ApiConfig.pickupFromBooking] rather than here: it is
/// the one place that knows how a v1 booking becomes a stop, and the same
/// translation is needed by anything else that receives a booking.
/// ── One request, however many screens ask ──
///
/// Home, Bookings, Activity and the post-delivery screen all call this, and
/// each of them used to produce its own `GET /miler/bookings` — on mount, on
/// tab switch, and on every poll. Four timers, four copies of the day, and
/// four ideas of what had been accepted, reconciled only by whichever
/// happened to refresh last.
///
/// They all go through [WorkRepository] now, which collapses concurrent
/// callers into one request, keeps one copy, and drops a response that lands
/// out of order. Nothing at the call sites changed: this still returns a list
/// or throws, which is the contract the screens were written against.
///
/// The richer answer — loading, empty, offline, unavailable — is on the
/// repository for screens that want to render it properly rather than
/// flattening it into an exception. See [LoadState].
Future<List<dynamic>> _bookings({String? status}) async {
final res = await MilerApi.bookings(status: status);
if (!res.ok) {
throw Exception('Failed (${res.status})${res.message.isEmpty ? '' : ': ${res.message}'}');
// A filtered read is a different question and is not the one the shared
// copy answers. Nothing calls this with a status today; if something does,
// it gets its own request rather than silently receiving the whole day.
if (status != null && status.isNotEmpty) {
final res = await MilerApi.bookings(status: status);
if (!res.ok) {
throw Exception(
'Failed (${res.status})'
'${res.message.isEmpty ? '' : ': ${res.message}'}',
);
}
return ApiConfig.pickupsFromBookings(res.list);
}
final mapped = ApiConfig.pickupsFromBookings(res.list);
debugPrint('[BOOKINGS] ${res.list.length} rows → ${mapped.length} stops');
if (mapped.isEmpty && res.list.isNotEmpty) {
// The rows arrived and none of them survived translation, which is a
// field-name mismatch rather than an empty day. Worth saying out loud —
// it is the difference between "no work" and "the adapter is wrong".
ApiConfig.logGap(
'pickupFromBooking',
'${res.list.length} bookings returned but none mapped — check the '
'field names against a real payload.',
);
}
return mapped;
final state = await WorkRepository.instance.load();
return switch (state) {
LoadData<List<Map<String, dynamic>>>(:final value) => value,
// A real empty day. The screens render their own empty state from this.
LoadEmpty<List<Map<String, dynamic>>>() => const <dynamic>[],
// No endpoint for this line of work — neither waiting nor retrying helps,
// so it must not arrive as an empty day. See [LineNotServedException].
LoadUnavailable<List<Map<String, dynamic>>>() =>
throw const LineNotServedException(),
LoadFailure<List<Map<String, dynamic>>>(:final kind, :final message) =>
throw Exception(
message.isEmpty ? 'Could not load your work ($kind)' : message,
),
// Only reachable if the repository handed back its pre-load state, which
// it does not. Treated as a failure rather than as an empty day.
LoadLoading<List<Map<String, dynamic>>>() => throw Exception(
'Still loading',
),
};
}
/// Everything the hub has put in front of this rider today.
@@ -57,3 +88,18 @@ class PickupProvider {
/// The stops he has accepted.
Future<List<dynamic>> getPickupQueuesPicked() => _bookings();
}
/// The rider's line of work has no backend to ask.
///
/// Distinct from an empty day and from a failed request, because the rider's
/// answer is different in each case: wait, retry, or "your hub has not switched
/// this on yet". Screens catch this to render the unavailable state rather than
/// the empty one.
class LineNotServedException implements Exception {
const LineNotServedException();
@override
String toString() =>
'This line of work is not served by the backend yet — there is no '
'bookings endpoint for it. See ServiceProfile.hasBookingsEndpoint.';
}