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

@@ -62,11 +62,7 @@ Future<Map<String, dynamic>?> _startDuty(Map data) async {
return {
'status': true,
'code': 200,
'details': {
'logid': id,
'login': res.map['loginat'],
'onduty': 1,
},
'details': {'logid': id, 'login': res.map['loginat'], 'onduty': 1},
};
}
// "Already on duty" is a 400 and is not a failure — it is the app and the
@@ -133,7 +129,9 @@ Future<Map<String, dynamic>?> _heartbeat(Map data) async {
),
);
return res.ok ? ApiConfig.okEnvelope() : {'status': false, 'code': res.status};
return res.ok
? ApiConfig.okEnvelope()
: {'status': false, 'code': res.status};
}
/// True when the payload means "go off duty".

File diff suppressed because it is too large Load Diff

View File

@@ -368,7 +368,7 @@ class NotificationServce {
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
fontFamily: FontConstants.fontFamily,
),
),

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.';
}

View File

@@ -2,7 +2,10 @@ import 'package:flutter/foundation.dart';
import 'package:miler/data/api_config.dart';
import 'package:miler/data/assignment_lookup.dart';
import 'package:miler/data/lifecycle.dart';
import 'package:miler/data/consignment_state.dart';
import 'package:miler/data/miler_api.dart';
import 'package:miler/data/accepted_store.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE PER-STOP WRITE PATH
@@ -52,11 +55,14 @@ class CreatePickupLogProvider {
Future<Map<String, dynamic>?> createPickupLog(
Map<String, dynamic> data,
) async {
final consignmentId = data['consignmentid'] ?? data['orderheaderid'] ??
data['pickupid'];
final lat = double.tryParse('${data['latitude'] ?? data['riderslat'] ?? ''}');
final lon =
double.tryParse('${data['longitude'] ?? data['riderslon'] ?? ''}');
final consignmentId =
data['consignmentid'] ?? data['orderheaderid'] ?? data['pickupid'];
final lat = double.tryParse(
'${data['latitude'] ?? data['riderslat'] ?? ''}',
);
final lon = double.tryParse(
'${data['longitude'] ?? data['riderslon'] ?? ''}',
);
if (consignmentId == null || lat == null || lon == null) {
// Not enough to place the breadcrumb. Silently skipping is right — this
@@ -104,7 +110,16 @@ class UpdatePickupProvider {
Map<String, dynamic> envelope(ApiResult r) => r.ok
? ApiConfig.toLegacyEnvelope(r.raw ?? {'success': true})
: {'status': false, 'code': r.status, 'message': r.message};
: {
'status': false,
'code': r.status,
'message': r.message,
// The backend's stable failure name, alongside the HTTP status
// this envelope has always called `code`. Callers that need to
// branch on *why* a write was refused read this one — message text
// is prose, and prose gets reworded.
'errorcode': r.code,
};
switch (status) {
case 'accepted':
@@ -133,21 +148,150 @@ class UpdatePickupProvider {
return envelope(r);
case 'arrived':
return envelope(await MilerApi.reached(id, lat: lat, lon: lon));
// ── A 200 is not proof the hub recorded an arrival ──
//
// Verified against production 21 Aug 2026: `reached` returns
// `{"success":true,"data":{"bookingid":78,"status":"Miler_Assigned"}}`
// and the booking's status is unchanged afterwards. The endpoint is a
// no-op that reports success, so `Arrived_At_Pickup` never reaches the
// console — and the app, which read only `ok`, drew ARRIVED anyway.
//
// The transition is now read for what it proves. The envelope carries
// the verdict so the caller can advance the rider without the app
// claiming an agreement that does not exist. See [MilerLifecycle].
final reached = await MilerApi.reached(id, lat: lat, lon: lon);
final arrival = MilerLifecycle.reached(reached);
MilerLifecycle.report('reached', arrival);
if (arrival.isUnconfirmed) {
ApiConfig.logGap(
'reached',
'booking $id: the call succeeded and the booking status did not '
'become Arrived_At_Pickup — ${arrival.evidence}. The hub '
'cannot show this rider as arrived. Backend deployment '
'mismatch; see MILER_API_REQUIREMENTS.md request 15.',
);
}
return {
...envelope(reached),
'confirmed': arrival.isConfirmed,
'serverstatus': arrival.bookingStatus.name,
'evidence': arrival.evidence,
};
case 'Picked up':
case 'picked':
return envelope(await MilerApi.pickupComplete(id, lat: lat, lon: lon));
// ── The pivot returns the thing the delivery leg runs on ──
//
// `pickup-complete` is what *creates* the consignment, and `deliver`
// and `skip` are consignment routes. The response was being wrapped and
// discarded, and the row the rider works from on Deliveries is a
// snapshot taken before this call — so nothing downstream knew the
// consignment id, and pressing **Delivered** at the door was refused by
// the app itself. Recorded here, at the only moment it is guaranteed to
// be in hand. See [rememberConsignmentId].
final picked = await MilerApi.pickupComplete(id, lat: lat, lon: lon);
// ── Which lifecycle the server is running, read from the server ──
//
// `Collected_By_Miler` + `next_action: start_delivery` is the new
// two-step flow; a bare `Out_for_Delivery` is compatibility mode, in
// which the pivot releases the consignment itself. Both are real, both
// ship today depending on a server-side flag, and the app must never
// mirror that flag — it reads which one happened.
final pivot = MilerLifecycle.pickupComplete(picked);
MilerLifecycle.report('pickup-complete', pivot);
if (pivot.isCompatibilityMode) {
ApiConfig.logGap(
'pickup-complete',
'booking $id went straight to Out_for_Delivery — the backend is '
'running compatibility mode (MILER_COLLECTED_STATE_ENABLED '
'off). The consignment IS out for delivery, so the console '
'showing Active is correct and the app must agree with it.',
);
}
if (picked.ok) {
final newId = _consignmentIdFrom(picked);
if (newId.isNotEmpty) {
final orderKey = (data['orderid'] ?? id ?? '').toString().trim();
await rememberConsignmentId(orderKey, newId);
// ── What the pivot now says about what happens next ──
//
// Since 21 Aug 2026 the response also carries `status` and
// `next_action`: a hyperlocal booking lands on
// `Collected_By_Miler` with `start_delivery`, and a hub-routed one
// on `Created` with `inward_at_hub`. Logged rather than acted on —
// the release is the rider's press, and the states it produces are
// read back from the consignment itself, never remembered from
// here. See `MyPickups.startRound`.
debugPrint(
'[PICKUP] booking $orderKey → consignment $newId '
'(${_field(picked, 'status')}, next: '
'${_field(picked, 'next_action')})',
);
} else {
ApiConfig.logGap(
'pickup-complete',
'no consignment id in the response for booking $id — the '
'delivery leg will have to wait for the queue to carry it.',
);
// The one thing that makes this diagnosable next time. Debug-only:
// a response body is not something to write into a release log.
if (kDebugMode) {
debugPrint('[PICKUP] pickup-complete body: ${picked.raw}');
}
}
}
return {
...envelope(picked),
'confirmed': pivot.isConfirmed,
// What the stop actually became, in the consignment's own words.
// The caller stamps this rather than assuming 'picked' — the whole
// point of the exercise is that the rider's rung and the office's
// rung are the same rung.
'consignmentstatus':
pivot.consignmentState == ConsignmentState.unknown
? ''
: pivot.consignmentState.name,
'nextaction': pivot.nextAction,
'compatibilitymode': pivot.isCompatibilityMode,
'evidence': pivot.evidence,
};
case 'delivered':
case 'Delivered':
// ── This route keys on the CONSIGNMENT, not the booking ──
//
// Same trap as accept/reject and `bookingassignmentid`: two ids from
// two sequences, and passing the wrong one gets a 404 while the rider
// is shown success. This branch was passing `id`, which is the booking
// — so every delivery it sent was addressed to whatever consignment
// happened to share that number.
//
// The consignment exists only after `pickup-complete` has converted the
// booking; `MilerGetMyBookings` returns it and the stop adapter carries
// it through. Without one there is nothing to deliver *yet*, and saying
// so is better than guessing an id.
final consignmentId =
(data['consignmentid'] ?? data['consignmentId'] ?? '')
.toString()
.trim();
if (consignmentId.isEmpty) {
ApiConfig.logGap(
'update:delivered',
'Booking $id has no consignmentid — it has not been collected yet, '
'so there is nothing to deliver.',
);
return {
'status': false,
'message': 'This order has not been picked up yet.',
};
}
return envelope(
await MilerApi.deliver(
id,
consignmentId,
deliveredToName: (data['pickupcustomer'] ?? '').toString(),
// Only sent when there is one. The tenant flag decides whether it
// is required, and an empty string would fail a tenant that has it
// on while being pointless for one that does not.
// Optional: nothing generates a delivery OTP, so the handler
// records whether one was presented rather than checking it. A
// milk-run drop has none and must not invent one.
otp: (data['otp'] ?? '').toString(),
photoUrl: (data['dropimage'] ?? '').toString(),
receiverSignatureUrl: (data['signature'] ?? '').toString(),
@@ -185,11 +329,107 @@ class UpdatePickupProvider {
return envelope(await MilerApi.setAvailability('On_Pickup'));
default:
// ── An unmapped status is a failure, not a success ──
//
// This returned `okEnvelope()`: nothing left the phone, and the caller
// was told the write had landed. The rider watched the stop advance,
// the local stores recorded it, and the hub was never told — which is
// the single most expensive shape of bug this app can have, because
// every screen downstream then trusts a transition that does not exist
// server-side.
//
// It also defeats the typed-status handling upstream: a status this
// build does not recognise must never resolve to a settled state, and
// silently succeeding here is exactly that, one layer lower.
ApiConfig.logGap('update:unknown', 'Unmapped orderstatus="$status".');
return ApiConfig.okEnvelope();
return {
'status': false,
'message': 'This app cannot record "$status" yet — nothing was sent.',
};
}
}
/// The consignment id out of a `pickup-complete` response, whatever shape it
/// arrives in.
///
/// Tolerant on purpose. The contract now names the field — `consignment_id`,
/// alongside `status` and `next_action` — but this ran for a long time
/// against a route whose response body was written down nowhere, riders are
/// on builds that met both, and the cost of a miss is somebody at a door
/// being told there is nothing to deliver. The search stays.
/// One shallow field off a mutation response, for logging.
///
/// Deliberately not recursive and deliberately not stored: these values are
/// a description of a moment that has already passed by the time anything
/// would read them back.
String _field(ApiResult res, String key) {
final v = res.data[key];
if (v == null || v is Map || v is List) return '?';
final str = v.toString().trim();
return str.isEmpty ? '?' : str;
}
String _consignmentIdFrom(ApiResult res) {
// Searched rather than looked up, at every depth. The response body for
// this route is written down nowhere — not in the API reference, not in the
// flow doc — so a fixed list of key names is a guess, and the cost of
// guessing wrong is a rider standing at a door being told there is nothing
// to deliver. Anything whose key names a consignment and whose value is a
// plain scalar counts.
String found = '';
void walk(dynamic node, int depth) {
if (found.isNotEmpty || depth > 4 || node == null) return;
if (node is List) {
for (final item in node) {
walk(item, depth + 1);
}
return;
}
if (node is! Map) return;
// Exact names first, so a nested `id` never wins over a real one.
for (final key in const [
'consignmentid',
'consignmentId',
'consignment_id',
'consignmentno',
]) {
final v = node[key];
if (v != null && v is! Map && v is! List) {
final str = v.toString().trim();
if (str.isNotEmpty && str != '0' && str != 'null') {
found = str;
return;
}
}
}
// Then a `consignment` object, whose own id is the one wanted.
final nested = node['consignment'];
if (nested is Map) {
for (final key in const ['consignmentid', 'consignmentId', 'id']) {
final v = nested[key];
if (v != null && v is! Map && v is! List) {
final str = v.toString().trim();
if (str.isNotEmpty && str != '0') {
found = str;
return;
}
}
}
}
for (final v in node.values) {
walk(v, depth + 1);
}
}
walk(res.data, 0);
if (found.isEmpty) walk(res.raw, 0);
return found;
}
/// **Step 2** — what the rider actually took, measured at the door.
///
/// Reads the verification screen's payload directly rather than taking a
@@ -224,9 +464,7 @@ class UpdatePickupProvider {
// and it keeps the chargeable total correct even though the per-parcel
// figures are an even split rather than a measurement.
final double each = totalWeight / count;
final parcels = [
for (var i = 0; i < count; i++) ParcelEntry(weight: each),
];
final parcels = [for (var i = 0; i < count; i++) ParcelEntry(weight: each)];
final res = await MilerApi.submitParcels(bookingId, parcels);
debugPrint(
@@ -238,6 +476,85 @@ class UpdatePickupProvider {
: {'status': false, 'code': res.status, 'message': res.message};
}
/// **Step 1b** — the FROM and TO the rider confirmed with the customer.
///
/// Reads the shipment-capture screen's payload, same as [submitParcels] reads
/// the verification screen's, so there is one record of the door rather than
/// two argument lists to keep in step.
///
/// Sent before the parcel and payment steps because `pickup-complete` builds
/// the consignment — its routing and its pricing zone — from these values,
/// and the handler refuses them once that conversion has happened.
Future<Map<String, dynamic>?> submitAddresses(
Object bookingId,
Map<String, dynamic> capture,
) async {
final Map? from = capture['from'] is Map ? capture['from'] as Map : null;
final Map? to = capture['to'] is Map ? capture['to'] as Map : null;
if (from == null && to == null) {
return ApiConfig.okEnvelope('no addresses captured');
}
String str(Map? m, String k) => (m?[k] ?? '').toString().trim();
double? coord(Map? m, String k) => double.tryParse('${m?[k] ?? ''}');
final res = await MilerApi.updateBookingAddresses(
bookingId,
pickupAddress: str(from, 'address'),
pickupPincode: str(from, 'pincode'),
pickupLat: coord(from, 'lat'),
pickupLon: coord(from, 'lon'),
deliveryAddress: str(to, 'address'),
deliveryPincode: str(to, 'pincode'),
deliveryLat: coord(to, 'lat'),
deliveryLon: coord(to, 'lon'),
deliveryCity: str(to, 'city'),
);
debugPrint(
'[ADDRESS] booking $bookingId '
'→ ${res.ok ? 'ok' : 'FAILED ${res.status} ${res.message}'}',
);
return res.ok
? ApiConfig.toLegacyEnvelope(res.raw ?? {'success': true})
: {'status': false, 'code': res.status, 'message': res.message};
}
/// **Step 2b** — what this shipment costs, from the pricing table.
///
/// Returns null when the call itself failed, and a [ShipmentQuote] with
/// `found: false` when the table simply has no rule for this combination.
/// The two are different things to tell a rider — "try again" versus "call
/// the hub, this cannot be priced here" — so they stay distinguishable, and
/// neither one produces a number.
Future<ShipmentQuote?> fetchQuote({
required double weight,
required String serviceType,
String? pickupPincode,
String? deliveryPincode,
String? category,
}) async {
final res = await MilerApi.checkPrice(
weight: weight,
serviceType: serviceType,
pickupPincode: pickupPincode,
deliveryPincode: deliveryPincode,
category: category,
);
if (!res.ok) {
debugPrint('[PRICE] FAILED ${res.status} ${res.message}');
return null;
}
final quote = ShipmentQuote.fromData(
res.map,
preferredCategory: category ?? '',
);
debugPrint(
'[PRICE] ${quote.zone}/${quote.serviceType} ${quote.weight}kg → '
'${quote.found ? '${quote.currency} ${quote.payable}' : quote.message}',
);
return quote;
}
/// **Step 3** — the money, from the collect-payment screen's payload.
///
/// Returns a success envelope without calling anything when nothing was

View File

@@ -8,8 +8,7 @@ import 'package:flutter/foundation.dart';
class SummaryProvider {
final String baseUrl = ApiConstants.summaryApiLive;
Future<PickupStats?> fetchSummaryStats(int userId) =>
_fetchSummaryStatsNew();
Future<PickupStats?> fetchSummaryStats(int userId) => _fetchSummaryStatsNew();
/// NEW API: GET /miler/earnings?period=daily|weekly|monthly
/// -> data:{ completed_stops, total_kms, total_earnings, total_bonus }.