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

594
test/milk_run_test.dart Normal file
View File

@@ -0,0 +1,594 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miler/Models/stop_status.dart';
import 'package:miler/data/accepted_store.dart';
import 'package:miler/data/api_config.dart';
import 'package:miler/data/milk_run.dart';
import 'package:miler/data/service_profile.dart';
import 'package:miler/views/Dashboard/pickups/stop_type.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// THE MILK RUN, END TO END
///
/// ```
/// 6 orders ─ accept ─┬─ Kitchen 1 ×5 ─ arrived ─ picked ─▶ DELIVERIES
/// └─ Kitchen 2 ×5 ─ arrived ─ picked ─▶ DELIVERIES
/// │
/// arrived ─ delivered ─▶ ACTIVITY
/// ```
///
/// Three units, three different shapes, and the file exists because they were
/// being conflated:
///
/// • **Acceptance is per order** and crosses kitchens freely.
/// • **Pickup is per kitchen** — one ride, one counter, one handover — so a
/// selection must never span two of them.
/// • **Delivery is per order** again, each bag to its own door.
///
/// And the lifecycle rule underneath all of it: `picked` is the *middle* of a
/// milk-run morning. An order collected at a kitchen is a live delivery, not
/// history — it goes to DELIVERIES, and only `delivered` sends it to Activity.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
ServiceProfile.setActive(ServiceProfile.milkMan);
});
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
/// One assigned stop, in the shape the adapter produces.
Map<String, dynamic> stop({
required String id,
required String kitchen,
String status = 'assigned',
String? consignmentId,
}) => {
'orderid': id,
'pickupid': int.parse(id.replaceAll(RegExp(r'\D'), '')),
'orderstatus': status,
'sourceid': kitchen.hashCode.toString(),
'sourcename': kitchen,
'pickupcustomer': 'Customer $id',
'pickuplat': 11.01,
'pickuplon': 76.95,
if (consignmentId != null) 'consignmentid': consignmentId,
};
/// Two kitchens, five orders each — the brief's own example.
List<Map<String, dynamic>> tenOrders() => [
for (var i = 1; i <= 5; i++) stop(id: '100$i', kitchen: 'Kitchen 1'),
for (var i = 6; i <= 10; i++) stop(id: '10$i', kitchen: 'Kitchen 2'),
];
Set<String> idsOf(Iterable<Map<String, dynamic>> stops) =>
stops.map((s) => s['orderid'] as String).toSet();
group('pickup is bounded by the kitchen', () {
// ── The three units ──
//
// Acceptance is per order and crosses kitchens freely. Pickup does not: a
// selection spanning two counters would post "arrived" for a shop the rider
// is nowhere near and mark bags collected that are still in another
// kitchen's warmer. Delivery is per order again.
test('orders from one kitchen group together', () {
final stops = tenOrders();
final vidhya = MilkRun.sameSourceAs(stops.first, stops);
expect(vidhya.length, 5);
expect(vidhya.every((s) => s['sourcename'] == 'Kitchen 1'), isTrue);
});
test('and orders from another kitchen are not swept in', () {
final stops = tenOrders();
final vidhya = MilkRun.sameSourceAs(stops.first, stops);
expect(
vidhya.any((s) => s['sourcename'] == 'Kitchen 2'),
isFalse,
reason: 'Kitchen 2 is a different ride and a different counter',
);
expect(MilkRun.sameSource(stops.first, stops.last), isFalse);
});
test('the stable id decides, not the display name', () {
// Two kitchens can share a name across areas; the id is what the hub
// controls, so it is what the app compares.
final a = {'orderid': '1', 'sourceid': 'K1', 'sourcename': 'Vidhya'};
final b = {'orderid': '2', 'sourceid': 'K2', 'sourcename': 'Vidhya'};
expect(MilkRun.sameSource(a, b), isFalse);
});
test('a name-only payload still groups', () {
final a = {'orderid': '1', 'sourcename': 'Vidhya Kitchen'};
final b = {'orderid': '2', 'sourcename': 'vidhya kitchen'};
expect(MilkRun.sameSource(a, b), isTrue);
});
test(
'no source at all is one honest bucket, never a fabricated kitchen',
() {
final a = {'orderid': '1'};
final b = {'orderid': '2'};
expect(MilkRun.sourceKeyOf(a), 'none');
expect(MilkRun.sameSource(a, b), isTrue);
expect(MilkRun.sourceNameOf(a), '');
},
);
test('a zero pickuplocationid is not an id', () {
// The adapter emits '0' for an absent location; treating it as a real
// key would put every unsourced order in one bogus group.
expect(MilkRun.sourceKeyOf({'pickuplocationid': '0'}), 'none');
expect(MilkRun.sourceKeyOf({'pickuplocationid': '7'}), 'id:7');
});
test('collected work is NOT held back for the other kitchens', () {
// The rule §24 turns on: three Kitchen-1 bags collected at 11:40 are
// three live deliveries, even though Kitchen 2 has not been visited.
final stops = tenOrders();
final k1 = idsOf(stops.take(5));
for (final s in stops.take(5)) {
expect(
k1.contains(MilkRun.idOf(s)),
isTrue,
reason: 'on the deliveries tab the moment it is in his hands',
);
}
// And the rest are still his to collect.
final outstanding = MilkRun.outstandingPickups(
stops,
acceptedIds: idsOf(stops),
collectedIds: k1,
);
expect(outstanding.length, 5);
expect(outstanding.every((s) => s['sourcename'] == 'Kitchen 2'), isTrue);
});
});
group('where Navigate goes', () {
test('to the kitchen before collection', () {
final s = stop(id: '1001', kitchen: 'Kitchen 1');
expect(MilkRun.navigatesToCustomer(s), isFalse);
final target = MilkRun.navigationTarget(s);
expect(target?.lat, 11.01);
expect(MilkRun.navigationLabel(s), 'Kitchen 1');
});
test(
'a nameless pickup is titled by its neighbourhood, never "pickup"',
() {
// The live backend sends no sourcename today, and the literal 'pickup'
// this used to fall back to became the 24sp headline of the rider's
// first group — the exact regression the project rule forbids.
final s = {
'orderid': '1001',
'type': 'pickup',
'pickupaddress': '12/4, RS Puram, Coimbatore, Tamil Nadu 641002',
};
expect(
MilkRun.navigationLabel(s),
'RS Puram',
reason:
'the first non-numeric address component is a place; '
'"pickup" is a leg description',
);
// A component carrying its own house number is titled by the street.
expect(
MilkRun.navigationLabel({
'orderid': '1002',
'type': 'pickup',
'pickupaddress': '124 Gandhipuram Main Road, Coimbatore 641012',
}),
'Gandhipuram Main Road',
);
// No address either → a capitalised noun, still never the raw leg word.
expect(
MilkRun.navigationLabel({'orderid': '1', 'type': 'pickup'}),
'Pickup',
);
},
);
test('to the customer after collection', () {
final s = stop(id: '1001', kitchen: 'Kitchen 1')
..['droplat'] = 11.05
..['droplon'] = 76.99;
expect(MilkRun.navigatesToCustomer(s, collectedIds: {'1001'}), isTrue);
final target = MilkRun.navigationTarget(s, collectedIds: {'1001'});
expect(target?.lat, 11.05);
expect(target?.lng, 76.99);
expect(
MilkRun.navigationLabel(s, collectedIds: {'1001'}),
'Customer 1001',
);
});
test('the backend status alone flips it, without the local set', () {
final s = stop(id: '1001', kitchen: 'Kitchen 1', status: 'picked')
..['droplat'] = 11.05
..['droplon'] = 76.99;
expect(MilkRun.navigatesToCustomer(s), isTrue);
expect(MilkRun.navigationTarget(s)?.lat, 11.05);
});
test('null rather than the wrong end when coordinates are missing', () {
// A Navigate button that quietly opens the other end of the journey is
// worse than a disabled one: the rider only finds out on arrival.
final s = stop(id: '1001', kitchen: 'Kitchen 1', status: 'picked');
expect(MilkRun.navigatesToCustomer(s), isTrue);
expect(MilkRun.navigationTarget(s), isNull);
});
test('logistics never navigates to a customer', () {
ServiceProfile.setActive(ServiceProfile.parcel);
final s = stop(id: '2001', kitchen: '', status: 'picked')
..['droplat'] = 11.05
..['droplon'] = 76.99;
expect(
MilkRun.navigatesToCustomer(s),
isFalse,
reason: 'its collected parcel goes to a hub; somebody else delivers it',
);
expect(MilkRun.navigationTarget(s)?.lat, 11.01);
});
});
group('the ladder a stop climbs', () {
test('accepted → arrived → picked → out for delivery → delivered', () {
final s = stop(id: '1001', kitchen: 'Kitchen 1');
expect(
MilkRun.stageOf(s, acceptedIds: const {}, collectedIds: const {}),
StopStatus.assigned,
);
expect(
MilkRun.stageOf(s, acceptedIds: {'1001'}, collectedIds: const {}),
StopStatus.accepted,
);
s['orderstatus'] = 'arrived';
expect(
MilkRun.stageOf(s, acceptedIds: {'1001'}, collectedIds: const {}),
StopStatus.arrived,
);
expect(
MilkRun.stageOf(s, acceptedIds: {'1001'}, collectedIds: {'1001'}),
StopStatus.picked,
);
expect(
MilkRun.stageOf(
s,
acceptedIds: {'1001'},
collectedIds: {'1001'},
outForDeliveryIds: {'1001'},
),
StopStatus.outForDelivery,
);
expect(
MilkRun.stageOf(
s,
acceptedIds: {'1001'},
collectedIds: {'1001'},
outForDeliveryIds: {'1001'},
deliveredIds: {'1001'},
),
StopStatus.delivered,
);
});
test('picked up is never mistaken for delivered', () {
// The crate is in his hands at 11:50 and the last box goes through a door
// at 13:20. Reporting the first as the second bills the client for ninety
// minutes of work that has not happened.
expect(StopStatus.picked.isDelivered, isFalse);
expect(StopStatus.picked.isTerminal, isTrue);
expect(StopStatus.delivered.isDelivered, isTrue);
expect(StopStatus.outForDelivery.isDeliveryLeg, isTrue);
expect(StopStatus.picked.isDeliveryLeg, isFalse);
});
test(
'the pickup arrival and the delivery arrival are different states',
() {
expect(stopStatusFromRaw('arrived'), StopStatus.arrived);
expect(
stopStatusFromRaw('delivery_arrived'),
StopStatus.deliveryArrived,
);
expect(
StopStatus.arrived,
isNot(StopStatus.deliveryArrived),
reason: 'one is a kitchen counter, the other a customer door',
);
},
);
test(
'the backend spelling of out-for-delivery folds to the same state',
() {
for (final raw in [
'Out_for_Delivery',
'out for delivery',
'outfordelivery',
]) {
expect(
stopStatusFromRaw(raw),
StopStatus.outForDelivery,
reason: raw,
);
}
},
);
test('each rung offers the button that belongs to it', () {
expect(MilkRun.nextActionLabel(StopStatus.accepted), 'Arrived');
expect(MilkRun.nextActionLabel(StopStatus.arrived), 'Picked up');
expect(MilkRun.nextActionLabel(StopStatus.outForDelivery), 'Arrived');
expect(MilkRun.nextActionLabel(StopStatus.deliveryArrived), 'Delivered');
expect(MilkRun.nextActionLabel(StopStatus.delivered), isNull);
});
});
group('a collected order is on DELIVERIES, never Activity', () {
// ── The bug this group exists for ──
//
// `picked` at a kitchen was being filed as completed work, so the order
// left the deliveries list and appeared in the rider's history before he
// had left the counter. Activity's filter and the deliveries list read the
// same test from opposite sides, so it broke both at once.
test('picked is not finished work on a milk run', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
expect(StopStatus.picked.isWorkComplete, isFalse);
expect(StopStatus.delivered.isWorkComplete, isTrue);
expect(StopStatus.cancelled.isWorkComplete, isTrue);
});
test('picked IS finished work on logistics', () {
ServiceProfile.setActive(ServiceProfile.parcel);
expect(
StopStatus.picked.isWorkComplete,
isTrue,
reason: 'collecting the parcel is the job; the hub takes it from there',
);
});
test('a picked milk-run stop is still live work', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
final s = stop(id: '1001', kitchen: 'Kitchen 1', status: 'picked');
expect(stopStatusOf(s).isWorkComplete, isFalse);
expect(stopStatusOf(s).isPicked, isTrue);
});
});
group('the round, on the device', () {
test('releasing marks the load and finishing clears it', () async {
expect(await getOutForDeliveryOrderIds(), isEmpty);
await addOutForDeliveryOrderIds(['1001', '1002', '1003']);
expect(await getOutForDeliveryOrderIds(), {'1001', '1002', '1003'});
await removeOutForDeliveryOrderIds(['1002']);
expect(await getOutForDeliveryOrderIds(), {'1001', '1003'});
});
test('a finished day leaves nothing behind for tomorrow', () async {
await addCollectedOrderIds(['1001']);
await addOutForDeliveryOrderIds(['1001']);
await addNotLoadedOrderIds(['1002']);
await clearServiceRunState();
expect(await getCollectedOrderIds(), isEmpty);
expect(await getOutForDeliveryOrderIds(), isEmpty);
expect(await getNotLoadedOrderIds(), isEmpty);
});
test('a milk-run stop is filed as delivered, not picked', () async {
await addCompletedBookings([
{'orderid': '1001', 'pickupcustomer': 'A'},
]);
final done = await getCompletedBookings();
expect(done.single['orderstatus'], 'delivered');
expect(stopStatusOf(done.single), StopStatus.delivered);
});
test('a logistics stop is filed as picked', () async {
ServiceProfile.setActive(ServiceProfile.parcel);
await addCompletedBookings([
{'orderid': '2001', 'pickupcustomer': 'B'},
]);
final done = await getCompletedBookings();
expect(done.single['orderstatus'], 'picked');
});
});
group('the consignment id survives the trip from the API', () {
test('the adapter carries source, pincodes and consignment through', () {
final stop = ApiConfig.pickupFromBooking({
'bookingid': 42,
'bookingreference': 'DM-42',
'status': 'Converted_To_Consignment',
'pickupaddress': 'Kitchen 1, RS Puram',
'pickuppincode': '641002',
'deliveryaddress': '12 Cross Cut Road',
'deliverypincode': '641012',
'sourceid': '7',
'sourcename': 'Kitchen 1',
'consignmentid': 900,
});
// These four were the ones being dropped: without them every milk-run
// stop grouped into one nameless pile and no drop could be closed.
expect(stop['sourceid'], '7');
expect(stop['sourcename'], 'Kitchen 1');
expect(stop['consignmentid'], '900');
expect(stop['pickuppincode'], '641002');
expect(stop['droppincode'], '641012');
});
test('a booking with no source groups as no source, not as a guess', () {
final stop = ApiConfig.pickupFromBooking({
'bookingid': 43,
'bookingreference': 'DM-43',
'pickupaddress': '9 Mettupalayam Road',
});
expect(stop['sourcename'], '');
expect(stop['sourceid'], '');
expect(stop['consignmentid'], '');
});
test('MilkRun reads the consignment the adapter wrote', () {
expect(MilkRun.consignmentIdOf({'consignmentid': '900'}), '900');
expect(MilkRun.consignmentIdOf({}), '');
});
});
// ── The label follows the leg, not the payload ──
//
// `ApiConfig.pickupFromBooking` stamps `type: pickup` on every row it builds,
// because a v1 booking *is* a first-mile pickup and the backend has no
// per-stop type to send. On a milk run that made a bag already in the rider's
// box describe itself as a pickup: the delivery card offered **Start Pickup**,
// the confirmation sheet asked him to **Confirm pickup**, and the write path
// behind that button took the pickup branch — he was being asked to collect
// something he was carrying.
// ── Which screen a rung is worked on ──
//
// The defect: after accepting on Home, the Deliveries tab offered a live
// strip for the stop. Tapping it opened the single-stop map, then I'VE
// ARRIVED, then a confirmation sheet — a second, contradictory route up a
// ladder whose rungs are supposed to be one bulk slide on Home, at the
// counter, for the whole stack of bags at once. It also posted the booking
// ACTIVE from the tab that is meant to be the rider's load.
group('worksOnOwnScreen', () {
Map<String, dynamic> booking(String status) => {
'orderid': 'B1',
'type': 'pickup',
'orderstatus': status,
'kitchenname': 'Vidhya Kitchen',
};
test('a kitchen collection is worked on Home, not on its own screen', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
for (final status in ['assigned', 'accepted', 'arrived']) {
expect(
MilkRun.worksOnOwnScreen(booking(status)),
isFalse,
reason: '$status is a rung of the bulk ladder on Home',
);
}
});
test('the delivery leg is worked on its own screen', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
// Both ways a bag is known to be in his hands.
expect(MilkRun.worksOnOwnScreen(booking('picked')), isTrue);
expect(
MilkRun.worksOnOwnScreen(booking('accepted'), collectedIds: {'B1'}),
isTrue,
);
});
test('every logistics stop is worked on its own screen', () {
// Nothing is batched on that line: one customer, one parcel, one
// shipment raised at the door. The rule must not leak across.
ServiceProfile.setActive(ServiceProfile.parcel);
for (final status in ['assigned', 'accepted', 'arrived', 'picked']) {
expect(
MilkRun.worksOnOwnScreen(booking(status)),
isTrue,
reason: status,
);
}
});
});
group('workingKind', () {
Map<String, dynamic> booking(String status) => {
'orderid': 'B1',
'pickupcustomer': 'Joe',
// Exactly what the adapter writes.
'type': 'pickup',
'orderstatus': status,
'kitchenname': 'Vidhya Kitchen',
};
test(
'a collected milk-run order is a delivery, whatever the payload says',
() {
ServiceProfile.setActive(ServiceProfile.meals);
final stop = booking('picked');
expect(
stopKindOf(stop),
StopKind.pickup,
reason: 'what the payload says',
);
expect(
MilkRun.workingKind(stop),
StopKind.delivery,
reason: 'what the rider is actually doing',
);
expect(MilkRun.workingKind(stop).startCtaLabel, 'Start Delivery');
expect(MilkRun.workingKind(stop).completeLabel, 'Delivered');
},
);
test('the collected set is what the delivery screen actually has', () {
// ── The status is not the record on that screen ──
//
// A stop on the work tab comes out of the *accepted* store, which
// normalises every row it holds to `accepted`. What records the hand-over
// is the collected set — so asking the leg without it reads a bag already
// in the rider's box as still needing collection, which is exactly how
// the delivery card came to offer **Start Pickup**.
ServiceProfile.setActive(ServiceProfile.meals);
final onTheTab = booking('accepted');
expect(MilkRun.workingKind(onTheTab), StopKind.pickup);
expect(
MilkRun.workingKind(onTheTab, collectedIds: {'B1'}).startCtaLabel,
'Start Delivery',
);
expect(
MilkRun.workingKind(onTheTab, collectedIds: {'B1'}).completeLabel,
'Delivered',
);
});
test('the local collected set counts, before any status lands', () {
ServiceProfile.setActive(ServiceProfile.meals);
final stop = booking('accepted');
// Straight after the pickup slide the server may still say `accepted`;
// the rider is holding the bag either way.
expect(MilkRun.workingKind(stop), StopKind.pickup);
expect(
MilkRun.workingKind(stop, collectedIds: {'B1'}),
StopKind.delivery,
);
});
test('before collection it is still a pickup', () {
ServiceProfile.setActive(ServiceProfile.meals);
expect(MilkRun.workingKind(booking('accepted')), StopKind.pickup);
});
test('a parcel booking is untouched', () {
ServiceProfile.setActive(ServiceProfile.parcel);
// A collected parcel goes to a hub and is delivered by somebody else, so
// the rider never drives to its customer and the word never changes.
expect(MilkRun.workingKind(booking('picked')), StopKind.pickup);
expect(
MilkRun.workingKind(booking('picked')).startCtaLabel,
'Start Pickup',
);
});
});
}