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

@@ -0,0 +1,424 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/data/api_config.dart';
import 'package:miler/data/api_status.dart';
import 'package:miler/data/consignment_state.dart';
import 'package:miler/data/miler_api.dart';
import 'package:miler/data/mock_backend.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE RUNG BETWEEN THE COUNTER AND THE ROAD
///
/// Backend contract change, 21 Aug 2026. Until then `pickup-complete` pushed a
/// hyperlocal booking straight to `Out_for_Delivery`, so the hub's board said
/// *actively delivering* for food still on the kitchen counter, and the app
/// had nowhere honest to put the one fact only the rider knows — that he has
/// set off. **Start round** could therefore only ever be a note to self.
///
/// The pivot now stops at `Collected_By_Miler` and
/// `POST /miler/consignments/:id/start-delivery` makes the release, so the
/// press is a real write again. These pin the four pieces that has to hold:
/// the state exists and is not mistaken for a hub hold, the gate routes it to
/// the rider rather than blaming the warehouse, the release is asked of the
/// server before anything local is written, and a list row's consignment
/// status outranks the booking status that stopped being informative at
/// conversion.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late List<http.Request> sent;
/// Answers every request with [body], and records what was asked.
void stub({
Map<String, dynamic> body = const {'success': true},
int code = 200,
}) {
sent = <http.Request>[];
MilerApi.client = MockClient((req) async {
sent.add(req);
return http.Response(
json.encode(body),
code,
headers: const {'content-type': 'application/json'},
);
});
}
/// Answers each path from [routes], falling back to a bare success.
void stubRoutes(Map<String, http.Response> routes) {
sent = <http.Request>[];
MilerApi.client = MockClient((req) async {
sent.add(req);
for (final entry in routes.entries) {
if (req.url.path.endsWith(entry.key)) return entry.value;
}
return http.Response(
json.encode({'success': true}),
200,
headers: const {'content-type': 'application/json'},
);
});
}
http.Response jsonRes(Object body, [int code = 200]) => http.Response(
json.encode(body),
code,
headers: const {'content-type': 'application/json'},
);
setUp(() async {
MockBackend.enabled = false;
SharedPreferences.setMockInitialValues({'authtoken': 'test-token'});
await ApiConfig.setToken('test-token');
stub();
});
tearDown(() {
MilerApi.client = http.Client();
MockBackend.enabled = kMockBackend;
});
group('the state itself', () {
test('Collected_By_Miler parses, however it is spelled', () {
for (final raw in [
'Collected_By_Miler',
'collected_by_miler',
'COLLECTED BY MILER',
]) {
expect(
consignmentStateFromRaw(raw),
ConsignmentState.collectedByMiler,
reason:
'$raw must not fall through to unknown — an unrecognised '
'state blocks the door',
);
}
});
test('it is the rider holding the parcel, not the hub', () {
const state = ConsignmentState.collectedByMiler;
expect(
state.awaitsHub,
isFalse,
reason:
'telling a rider the hub has the parcel in his own box is the '
'exact error this state exists to prevent',
);
expect(state.needsRelease, isTrue);
expect(
state.isDeliverable,
isFalse,
reason: 'deliver is refused until start-delivery has run',
);
expect(state.isClosed, isFalse);
});
test('a skip is reportable the moment the parcel is collected', () {
// Widened server-side on 21 Aug 2026: a customer who is not home is not
// home whether or not the rider remembered to press Start round.
expect(ConsignmentState.collectedByMiler.canSkip, isTrue);
expect(ConsignmentState.outForDelivery.canSkip, isTrue);
expect(ConsignmentState.delivered.canSkip, isFalse);
expect(ConsignmentState.inwardedAtHub.canSkip, isFalse);
});
test('the gate sends the rider to Start round, not to the hub', () {
expect(
ConsignmentGate.gateFor(ConsignmentState.collectedByMiler),
DeliverGate.needsRelease,
);
expect(
ConsignmentGate.gateFor(ConsignmentState.inwardedAtHub),
DeliverGate.awaitingHub,
reason: 'the genuine hub guard must survive this change',
);
expect(
ConsignmentGate.gateFor(ConsignmentState.delivered),
DeliverGate.alreadyDelivered,
);
expect(
ConsignmentGate.gateFor(ConsignmentState.outForDelivery),
DeliverGate.deliverable,
);
});
});
group('reading the state', () {
test('one call answers it, where it used to take a history', () async {
stubRoutes({
'/miler/consignments/34': jsonRes({
'success': true,
'data': {'consignmentid': 34, 'status': 'Collected_By_Miler'},
}),
});
expect(
await ConsignmentGate.stateOf(34),
ConsignmentState.collectedByMiler,
);
expect(sent.single.method, 'GET');
expect(
sent.single.url.path.endsWith('/miler/consignments/34'),
isTrue,
reason: 'the logs walk was one round trip and a sort per stop',
);
});
test(
'the flags answer it when the word is one this build cannot read',
() async {
stubRoutes({
'/miler/consignments/34': jsonRes({
'success': true,
'data': {
'status': 'Handed_To_Locker_Bank',
'collected': false,
'out_for_delivery': false,
'delivered': true,
},
}),
});
expect(
await ConsignmentGate.stateOf(34),
ConsignmentState.delivered,
reason:
'delivered is read first: mistaking a finished delivery for an '
'open one makes a rider post work he has already done',
);
},
);
test('an old deployment still answers, through the history', () async {
// A rider mid-round on this build meeting a server that has not shipped
// the route yet must not be stranded at a door.
stubRoutes({
'/miler/consignments/logs/34': jsonRes([
{'historyid': 2, 'eventstatus': 'Out_for_Delivery'},
{'historyid': 1, 'eventstatus': 'Created'},
]),
'/miler/consignments/34': jsonRes({
'success': false,
'message': 'not found',
}, 404),
});
expect(
await ConsignmentGate.stateOf(34),
ConsignmentState.outForDelivery,
);
expect(
sent.map((r) => r.url.path.endsWith('/miler/consignments/logs/34')),
contains(true),
);
});
test('a read that fails is not evidence of anything', () async {
stub(body: const {'success': false, 'message': 'gateway'}, code: 502);
expect(await ConsignmentGate.stateOf(34), ConsignmentState.unknown);
expect(
ConsignmentGate.gateFor(ConsignmentState.unknown),
DeliverGate.unknown,
reason: 'blocking on a timed-out GET strands a rider at a door',
);
});
test('nothing is asked about a consignment that does not exist', () async {
expect(await ConsignmentGate.stateOf(''), ConsignmentState.unknown);
expect(await ConsignmentGate.stateOf('0'), ConsignmentState.unknown);
expect(sent, isEmpty);
});
});
group('the release', () {
test('start-delivery posts against the consignment', () async {
await MilerApi.startDelivery(34);
expect(sent.single.method, 'POST');
expect(
sent.single.url.path.endsWith('/miler/consignments/34/start-delivery'),
isTrue,
);
});
test('it carries an idempotency key, stable across a retry', () async {
await MilerApi.startDelivery(34);
final first = sent.single.headers['Idempotency-Key'];
stub();
await MilerApi.startDelivery(34);
expect(first, isNotNull);
expect(
sent.single.headers['Idempotency-Key'],
first,
reason:
'a key that changes per press is not idempotency — a dropped '
'response would release the round twice',
);
});
test('a different consignment gets a different key', () async {
await MilerApi.startDelivery(34);
final first = sent.single.headers['Idempotency-Key'];
stub();
await MilerApi.startDelivery(35);
expect(sent.single.headers['Idempotency-Key'], isNot(first));
});
test(
'the write that is already running is waited out, not reported',
() async {
var calls = 0;
sent = <http.Request>[];
MilerApi.client = MockClient((req) async {
sent.add(req);
calls++;
// The first attempt's response was lost; the server is running it.
if (calls == 1) {
return jsonRes({
'success': false,
'code': 'IDEMPOTENCY_IN_PROGRESS',
'message': 'in progress',
}, 409);
}
return jsonRes({
'success': true,
'data': {'status': 'Out_for_Delivery'},
});
});
final res = await MilerApi.startDelivery(34);
expect(
res.ok,
isTrue,
reason:
'telling a rider his round failed while it is in the act of '
'succeeding sends him to press the same refusal again',
);
expect(calls, 2);
},
);
test('a failure names the code, not just the prose', () async {
stub(
body: const {
'success': false,
'code': 'INVALID_STATE',
'message': 'consignment is not collected',
},
code: 400,
);
final res = await MilerApi.startDelivery(34);
expect(res.ok, isFalse);
expect(
res.isInvalidState,
isTrue,
reason: 'branching on message text breaks the day it is reworded',
);
});
});
group('the list row carries the delivery half', () {
Map<String, dynamic> booking(String status, {String? consignment}) => {
'bookingid': 59,
'bookingreference': 'DM-BK-501CB551-45130',
'status': status,
if (consignment != null) 'consignmentstatus': consignment,
'consignmentid': 34,
};
test('the consignment id arrives on the row, so nothing has to hunt', () {
// The 17-of-29 blocker: bookings carried no consignment id and
// /miler/assignments listed only 12 of them, so for 17 collected stops
// the delivery reference was unobtainable by any route.
final stop = ApiConfig.pickupFromBooking(
booking('Converted_To_Consignment', consignment: 'Collected_By_Miler'),
);
expect(stop['consignmentid'], '34');
});
test('a delivered consignment is not an open delivery stop', () {
final stop = ApiConfig.pickupFromBooking(
booking('Converted_To_Consignment', consignment: 'Delivered'),
);
expect(
stop['orderstatus'],
'delivered',
reason:
'the booking word is terminal at conversion and says the same '
'thing for a parcel in the box and one handed over an hour ago',
);
});
test('collected and on-the-road are told apart on the list', () {
expect(
ApiConfig.pickupFromBooking(
booking(
'Converted_To_Consignment',
consignment: 'Collected_By_Miler',
),
)['orderstatus'],
'picked',
);
expect(
ApiConfig.pickupFromBooking(
booking('Converted_To_Consignment', consignment: 'Out_for_Delivery'),
)['orderstatus'],
'outfordelivery',
);
});
test('a hub-side consignment keeps the booking’s own word', () {
// Not this rider's parcel to act on, and it has no rung on his card —
// inventing one would draw somebody else's warehouse on his screen.
final stop = ApiConfig.pickupFromBooking(
booking('Converted_To_Consignment', consignment: 'Inwarded_at_Hub'),
);
expect(stop['orderstatus'], 'picked');
});
test('a row with no consignment status behaves exactly as before', () {
expect(
ApiConfig.pickupFromBooking(booking('Pickup_Scheduled'))['orderstatus'],
'accepted',
);
expect(
ApiConfig.pickupFromBooking(
booking('Converted_To_Consignment'),
)['orderstatus'],
'picked',
);
});
});
group('arrival is a rung the hub can see', () {
test('Arrived_At_Pickup is the status reached writes', () {
expect(
BookingStatus.parse('Arrived_At_Pickup'),
BookingStatus.arrivedAtPickup,
);
expect(ApiConfig.legacyStatusFromNew('Arrived_At_Pickup'), 'arrived');
});
test('the older At_Customer spelling means the same thing', () {
expect(BookingStatus.parse('At_Customer'), BookingStatus.arrivedAtPickup);
expect(ApiConfig.legacyStatusFromNew('At_Customer'), 'arrived');
});
test('arriving is not collecting', () {
expect(
BookingStatus.arrivedAtPickup.isCollected,
isFalse,
reason:
'the work boundary must not move a bag the rider has not '
'picked up yet onto the delivery tab',
);
expect(BookingStatus.arrivedAtPickup.isOpen, isTrue);
expect(BookingStatus.arrivedAtPickup.canCancel, isTrue);
});
});
}