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,418 @@
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 BRIEF'S OWN SCENARIO, WALKED THROUGH
///
/// Six orders, three kitchens:
///
/// ```
/// Vidhya ×3 ┐
/// Priyanka ×2 ├─ accept all six (per order, across kitchens)
/// ABC ×1 ┘
/// │
/// ├─ Vidhya: arrived → picked ×3 ─▶ DELIVERIES (Priyanka/ABC stay)
/// ├─ Priyanka: arrived → picked ×2 ─▶ DELIVERIES
/// └─ ABC: arrived → picked ×1 ─▶ DELIVERIES
/// │
/// arrived → delivered ─▶ ACTIVITY
/// ```
///
/// The three rules this file holds, all of which were got wrong at some point:
///
/// 1. **Acceptance is per order.** Six accepted orders stay six visible cards;
/// they are not replaced by three kitchen cards.
/// 2. **Pickup is per kitchen.** One counter at a time — and finishing one
/// kitchen must NOT wait for the others.
/// 3. **`picked` is the middle of the morning.** It goes to DELIVERIES, and
/// only `delivered` reaches Activity.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
SharedPreferences.setMockInitialValues({});
ServiceProfile.setActive(ServiceProfile.milkMan);
});
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
Map<String, dynamic> order({
required String id,
required String kitchen,
required String kitchenId,
String status = 'assigned',
bool withDrop = true,
}) => {
'orderid': id,
'pickupid': int.parse(id),
'orderstatus': status,
'sourceid': kitchenId,
'sourcename': kitchen,
'pickupaddress': '$kitchen, Trichy Road',
'pickupcustomer': 'Receiver $id',
'pickupcontactno': '98765432$id',
'quantity': 1,
'pickuplat': 11.01,
'pickuplon': 76.95,
if (withDrop) 'droplat': 11.05,
if (withDrop) 'droplon': 76.99,
if (withDrop) 'dropaddress': 'Flat $id, Anna Nagar',
};
/// The brief's six.
List<Map<String, dynamic>> sixOrders() => [
order(id: '101', kitchen: 'Vidhya Kitchen', kitchenId: 'K1'),
order(id: '102', kitchen: 'Vidhya Kitchen', kitchenId: 'K1'),
order(id: '103', kitchen: 'Vidhya Kitchen', kitchenId: 'K1'),
order(id: '104', kitchen: 'Priyanka Kitchen', kitchenId: 'K2'),
order(id: '105', kitchen: 'Priyanka Kitchen', kitchenId: 'K2'),
order(id: '106', kitchen: 'ABC Kitchen', kitchenId: 'K3'),
];
Set<String> idsOf(Iterable<Map<String, dynamic>> s) =>
s.map(MilkRun.idOf).toSet();
group('1–7 · six orders arrive, all six are accepted, all six stay', () {
test('acceptance is per order and crosses kitchens', () {
final stops = sixOrders();
final accepted = idsOf(stops);
expect(
accepted.length,
6,
reason:
'he takes on the whole morning in one press, whichever '
'counters it comes from',
);
});
test('six accepted orders remain six — not three kitchen cards', () {
final stops = sixOrders();
final accepted = idsOf(stops);
// Every accepted order is still individually addressable and still
// outstanding work. Collapsing them into one card per kitchen would lose
// the receiver, the drop address and the order id — the things the rider
// reads on the card.
final outstanding = MilkRun.outstandingPickups(
stops,
acceptedIds: accepted,
collectedIds: const {},
);
expect(outstanding.length, 6);
expect(idsOf(outstanding), accepted);
for (final s in outstanding) {
expect(MilkRun.sourceNameOf(s), isNotEmpty);
expect(s['pickupcustomer'], isNotEmpty);
expect(s['pickupcontactno'], isNotEmpty);
}
});
test('but they are still knowable as three kitchens', () {
final stops = sixOrders();
final keys = stops.map(MilkRun.sourceKeyOf).toSet();
expect(keys.length, 3);
expect(MilkRun.sameSourceAs(stops[0], stops).length, 3); // Vidhya
expect(MilkRun.sameSourceAs(stops[3], stops).length, 2); // Priyanka
expect(MilkRun.sameSourceAs(stops[5], stops).length, 1); // ABC
});
});
group('9–11 · navigate and the details sheet', () {
test('any Vidhya order navigates to Vidhya Kitchen', () {
final stops = sixOrders();
// He does not need a special kitchen card — any of the three will do.
for (final s in MilkRun.sameSourceAs(stops[0], stops)) {
expect(MilkRun.navigatesToCustomer(s), isFalse);
expect(MilkRun.navigationLabel(s), 'Vidhya Kitchen');
expect(MilkRun.navigationTarget(s)?.lat, 11.01);
}
});
test('the sheet has everything it needs, from the payload', () {
// Nothing here is invented; each is a field the adapter carries.
final s = sixOrders().first;
expect(MilkRun.sourceNameOf(s), 'Vidhya Kitchen'); // pickup kitchen
expect(s['pickupaddress'], isNotEmpty); // pickup address
expect(s['pickupcustomer'], 'Receiver 101'); // receiver
expect(s['pickupcontactno'], isNotEmpty); // phone → Call
expect(s['dropaddress'], isNotEmpty); // drop address
expect(MilkRun.idOf(s), '101'); // order id
expect(s['quantity'], 1); // quantity
expect(stopStatusOf(s), StopStatus.assigned); // status
});
test('a missing phone is absent, never fabricated', () {
final s = sixOrders().first..remove('pickupcontactno');
expect(
s['pickupcontactno'],
isNull,
reason: 'the Call action hides rather than dialling a made-up number',
);
});
});
group('12–17 · Vidhya: arrive, pick, and straight to DELIVERIES', () {
test('the pickup set is bounded to one kitchen', () {
final stops = sixOrders();
final vidhya = MilkRun.sameSourceAs(stops[0], stops);
expect(idsOf(vidhya), {'101', '102', '103'});
expect(
idsOf(vidhya).intersection({'104', '105', '106'}),
isEmpty,
reason: 'Priyanka and ABC are different rides and different counters',
);
});
test('arrived is not picked', () {
// §14: arrival and collection are separate events.
final s = sixOrders().first..['orderstatus'] = 'arrived';
expect(stopStatusOf(s), StopStatus.arrived);
expect(stopStatusOf(s).isPicked, isFalse);
expect(MilkRun.nextActionLabel(StopStatus.arrived), 'Picked up');
});
test('picking Vidhya leaves Priyanka and ABC on Home', () {
final stops = sixOrders();
final accepted = idsOf(stops);
final collected = {'101', '102', '103'};
final stillToCollect = MilkRun.outstandingPickups(
stops,
acceptedIds: accepted,
collectedIds: collected,
);
expect(idsOf(stillToCollect), {'104', '105', '106'});
expect(stillToCollect.map(MilkRun.sourceNameOf).toSet(), {
'Priyanka Kitchen',
'ABC Kitchen',
});
});
test('and the three picked ones are live deliveries immediately', () {
// §24 — the rule that must NOT wait for the other kitchens.
final collected = {'101', '102', '103'};
for (final id in collected) {
expect(collected.contains(id), isTrue);
}
// They navigate to the customer now, not back to the kitchen.
final s = sixOrders().first;
expect(MilkRun.navigatesToCustomer(s, collectedIds: collected), isTrue);
expect(MilkRun.navigationTarget(s, collectedIds: collected)?.lat, 11.05);
});
});
group('18 · the picked card lands on DELIVERIES, not Activity', () {
// ── Both filters, from opposite sides, on one fixture ──
//
// The deliveries list keeps what is NOT complete; Activity keeps what IS.
// They read the same test, so a stop can only ever be on one of them — and
// the bug was that `picked` answered "complete" on a line where it means
// "in the rider's box".
test('the deliveries list keeps a picked milk-run stop', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
final s = sixOrders().first..['orderstatus'] = 'picked';
// `pickups.dart` builds its list with `!st.isFinishedPickup`.
expect(stopStatusOf(s).isFinishedPickup, isFalse);
});
test('and Activity drops it', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
final s = sixOrders().first..['orderstatus'] = 'picked';
// `activity_page.dart` builds its list with `.isFinishedPickup`.
expect(stopStatusOf(s).isFinishedPickup, isFalse);
});
test('the backend\'s own post-pickup status behaves the same', () {
// `pickup-complete` leaves the booking `Converted_To_Consignment`, which
// the adapter maps to `picked`. The live payload must not put the order
// in history either.
ServiceProfile.setActive(ServiceProfile.milkMan);
final mapped = ApiConfig.legacyStatusFromNew('Converted_To_Consignment');
expect(mapped, 'picked');
expect(stopStatusFromRaw(mapped).isWorkComplete, isFalse);
});
test('once delivered the two swap', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
final s = sixOrders().first..['orderstatus'] = 'delivered';
expect(stopStatusOf(s).isFinishedPickup, isTrue);
});
test('on logistics picked goes to Activity, as it always did', () {
ServiceProfile.setActive(ServiceProfile.parcel);
final s = sixOrders().first..['orderstatus'] = 'picked';
expect(stopStatusOf(s).isFinishedPickup, isTrue);
});
});
group('18b · the status vocabulary underneath', () {
test('picked is live work on this line', () {
final s = sixOrders().first..['orderstatus'] = 'picked';
expect(
stopStatusOf(s).isWorkComplete,
isFalse,
reason:
'the bug: a bag collected at 11:40 was filed as history before '
'the rider had left the counter',
);
});
test('delivered is what completes it', () {
final s = sixOrders().first..['orderstatus'] = 'delivered';
expect(stopStatusOf(s).isWorkComplete, isTrue);
});
});
group('20–22 · the rest of the kitchens, then all six on the round', () {
test('each kitchen adds its orders as it is worked', () {
final stops = sixOrders();
final accepted = idsOf(stops);
final collected = <String>{};
for (final batch in [
{'101', '102', '103'},
{'104', '105'},
{'106'},
]) {
collected.addAll(batch);
final left = MilkRun.outstandingPickups(
stops,
acceptedIds: accepted,
collectedIds: collected,
);
expect(left.length, 6 - collected.length);
}
expect(collected.length, 6);
expect(
MilkRun.outstandingPickups(
stops,
acceptedIds: accepted,
collectedIds: collected,
),
isEmpty,
);
});
});
group('23–25 · the delivery leg', () {
test('navigation targets the customer', () {
final s = sixOrders().first..['orderstatus'] = 'picked';
expect(MilkRun.navigationLabel(s), 'Receiver 101');
expect(MilkRun.navigationTarget(s)?.lng, 76.99);
});
test('customer arrival is a different state from kitchen arrival', () {
expect(stopStatusFromRaw('arrived'), StopStatus.arrived);
expect(stopStatusFromRaw('delivery_arrived'), StopStatus.deliveryArrived);
expect(StopStatus.arrived, isNot(StopStatus.deliveryArrived));
expect(MilkRun.nextActionLabel(StopStatus.deliveryArrived), 'Delivered');
});
test('the delivery arrival is local, and says so', () {
// §20 — documented, not faked. No call is made and no success invented.
expect(MilkRun.deliveryArrivalIsLocalOnly, isTrue);
});
test('delivered files it in Activity with the right verb', () async {
await addCompletedBookings([
{'orderid': '101', 'pickupcustomer': 'Receiver 101'},
]);
final done = await getCompletedBookings();
expect(done.single['orderstatus'], 'delivered');
expect(stopStatusOf(done.single).isWorkComplete, isTrue);
});
test('a delivered order keys on the consignment, not the booking', () {
// The same trap `bookingassignmentid` sets on the accept side.
final s = sixOrders().first..['consignmentid'] = '900';
expect(MilkRun.consignmentIdOf(s), '900');
expect(MilkRun.consignmentIdOf(sixOrders().first), '');
});
});
group('26 · a milk run never sees the logistics desk', () {
test('no payment, pricing, address capture or verification', () {
const p = ServiceProfile.milkMan;
expect(p.collectsCash, isFalse);
expect(p.pricesShipment, isFalse);
expect(p.capturesShipmentAddresses, isFalse);
expect(p.initiatesShipment, isFalse);
expect(p.needsVerification, isFalse);
});
test('and nothing is ever owed at a milk-run door', () {
ServiceProfile.setActive(ServiceProfile.milkMan);
expect(stopCollectionAmount({'collectionamt': 450}), 0);
});
});
group('27 · logistics is untouched', () {
test('its whole desk is still on', () {
const p = ServiceProfile.parcel;
expect(p.capturesShipmentAddresses, isTrue);
expect(p.pricesShipment, isTrue);
expect(p.initiatesShipment, isTrue);
expect(p.collectsCash, isTrue);
expect(p.needsVerification, isTrue);
});
test('accepted logistics work still moves off Home', () {
expect(ServiceProfile.parcel.handoffAt, HandoffPoint.accepted);
expect(ServiceProfile.milkMan.handoffAt, HandoffPoint.collected);
});
test('it has no kitchen and never drives to its customer', () {
const p = ServiceProfile.parcel;
expect(p.sourceIsKitchen, isFalse);
expect(p.deliversToCustomer, isFalse);
});
test('and picked still finishes it', () {
ServiceProfile.setActive(ServiceProfile.parcel);
expect(StopStatus.picked.isWorkComplete, isTrue);
});
});
group('the wire carries what the grouping needs', () {
test('the adapter passes source, drop and consignment through', () {
final s = ApiConfig.pickupFromBooking({
'bookingid': 42,
'bookingreference': 'DM-42',
'status': 'Miler_Assigned',
'pickupaddress': 'Vidhya Kitchen, Trichy Road',
'deliveryaddress': 'Flat 3, Anna Nagar',
'sourceid': 'K1',
'sourcename': 'Vidhya Kitchen',
'consignmentid': 900,
'customerphone': '9876543210',
});
expect(MilkRun.sourceNameOf(s), 'Vidhya Kitchen');
expect(MilkRun.sourceKeyOf(s), 'id:K1');
expect(MilkRun.consignmentIdOf(s), '900');
expect(s['pickupcontactno'], '9876543210');
expect(s['dropaddress'], 'Flat 3, Anna Nagar');
});
test('a booking without source fields does not invent one', () {
// BACKEND DEPENDENCY: sourceid/sourcename are not deployed. Until they
// are, every order groups under one unnamed bucket — visibly wrong, and
// deliberately not papered over with a guessed kitchen name.
final s = ApiConfig.pickupFromBooking({
'bookingid': 43,
'bookingreference': 'DM-43',
'pickupaddress': '9 Mettupalayam Road',
});
expect(MilkRun.sourceNameOf(s), '');
expect(MilkRun.sourceKeyOf(s), 'none');
});
});
}