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,615 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:miler/data/order_events.dart';
import 'package:miler/data/service_profile.dart';
import 'package:miler/views/Dashboard/activity/delivery_details_page.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
/// ─────────────────────────────────────────────────────────────────────────
/// DELIVERY DETAILS — one stop, the whole story
///
/// The record used to open inside the Activity list. It is a page now, for two
/// reasons: an open record is several screens tall, so the list stopped being
/// scannable the moment it was used — and the two jobs want opposite things.
/// The list answers "did that go through?" in one word per row; this answers
/// "what happened on that one?", which is a timeline, a route, four
/// measurements and a reference.
///
/// The page takes the record it is given and renders it — no fetch, no store
/// write — so these pump it directly.
///
/// What they hold is the part that regresses silently: **a rung, a row or a
/// whole section is drawn only when the data behind it exists.** A record with
/// a hole in it can be investigated; one carrying a fabricated `0 km` cannot be
/// caught.
/// ─────────────────────────────────────────────────────────────────────────
void main() {
final now = DateTime(2026, 8, 20, 11, 42);
/// A finished stop. Every stamp is optional, exactly as it is in the store.
Map<String, dynamic> record({
String type = 'delivery',
String status = 'delivered',
bool started = true,
bool arrived = true,
Map<String, dynamic>? compliance,
double cash = 0,
double? collected,
String? skipReason,
}) => {
'orderid': 'DM101',
'type': type,
'pickupcustomer': 'Joe Mathew',
'sourcename': 'Vidhya Kitchen',
'pickupaddress': '78, Cross Cut Road, Gandhipuram',
'orderstatus': status,
'completedat': now.toIso8601String(),
if (started)
'startedat': now.subtract(const Duration(minutes: 87)).toIso8601String(),
if (arrived)
'arrivedat': now.subtract(const Duration(minutes: 5)).toIso8601String(),
if (compliance != null) 'compliance': compliance,
if (cash > 0) 'collectionamt': '$cash',
if (skipReason != null) 'skipreason': skipReason,
'proof': {
'collected': 2,
'weight': '2.4',
if (collected != null) 'amountcollected': collected,
},
};
Future<void> pump(WidgetTester tester, Map<String, dynamic> stop) async {
// The record is a tall page and the `ListView` under it is lazy, so a
// default 800×600 harness never builds the sections below the timeline —
// and every assertion about them would pass or fail on viewport height
// rather than on the widget. Give it a phone's width and room to render.
tester.view.physicalSize = const Size(390 * 3, 2600 * 3);
tester.view.devicePixelRatio = 3;
addTearDown(tester.view.reset);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(home: DeliveryDetailsPage(stop: stop)),
),
);
await tester.pumpAndSettle();
}
/// Opens every folded group.
///
/// ── Why the tests now tap first ──
///
/// The ledger — distance, ETA, payment, the booking reference — used to be
/// drawn flat, at the same weight as the outcome, which is what made a
/// finished delivery read as a form to work through. It is folded now: three
/// rows the rider taps only when somebody in an office asks him something.
///
/// The facts are unchanged and every assertion about them still holds. What
/// changed is that reaching them costs a tap, so the tests take it — the
/// alternative is asserting the *absence* of progressive disclosure, which
/// is the thing being introduced.
Future<void> unfold(WidgetTester tester) async {
for (final title in const [
'Delivery details',
'Payment',
'Order details',
]) {
// Matched inside its own control, not by text alone: the app bar's
// title is *also* "Delivery details", and a finder that catches both
// makes `tap` throw rather than fail an expectation — which is how one
// ambiguous line reads as nine unrelated broken tests.
final row = find.descendant(
of: find.byType(InkWell),
matching: find.text(title),
);
if (row.evaluate().isEmpty) continue;
await tester.tap(row.first);
await tester.pumpAndSettle();
}
}
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
group('the summary', () {
testWidgets('leads with the outcome, in the outcome\'s colour', (
tester,
) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
await pump(tester, record());
// The word comes from `StopStatusX.label` — the canonical list — so a
// stop cannot be called one thing on the list and another on the page
// that list opens. It appears twice: the summary and the timeline's head
// rung, which is the newest event.
expect(find.text('Delivered'), findsNWidgets(2));
final headline = tester.widget<Text>(find.text('Delivered').first);
expect(headline.style!.color, ColorConstants.acceptGreen);
// Bigger than the route under it, which is bigger than the note under
// that: the hierarchy is the answer, then which stop, then the detail.
// The route is two `Text`s with a glyph between them — Poppins carries no
// U+2192 and the arrow-as-character rendered as a tofu box — so `.first`
// is the summary's copy and `.last` the one in ROUTE below it.
final route = tester.widget<Text>(find.text('Vidhya Kitchen').first);
expect(headline.style!.fontSize, greaterThan(route.style!.fontSize!));
expect(find.byIcon(LucideIcons.arrowRight), findsOneWidget);
});
testWidgets('a cancelled stop never heads itself "Delivered"', (
tester,
) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
await pump(tester, record(status: 'cancelled'));
expect(find.text('Cancelled'), findsWidgets);
// The *summary* is the outcome and it is Cancelled. `Delivered` appears
// once and only once, as the open-ring rung for the step this record
// never reached — see the group below.
final summary = tester.widget<Text>(find.text('Cancelled').first);
expect(summary.style!.fontSize, greaterThan(20));
expect(find.text('Delivered'), findsOneWidget);
expect(find.text('Did not happen'), findsOneWidget);
});
testWidgets('a skipped stop leads with the reason it was put down', (
tester,
) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
await pump(
tester,
record(status: 'skipped', skipReason: 'Customer unreachable'),
);
expect(find.text('Skipped'), findsWidgets);
// The one thing on the record he cannot reconstruct for himself: he knows
// who and where, and what he has forgotten by the afternoon is *why he
// walked away*.
expect(find.textContaining('Customer unreachable'), findsWidgets);
});
});
group('the timeline', () {
testWidgets('runs oldest first, named for the leg', (tester) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
await pump(tester, record());
// The door arrival is not a rung: it folds into the outcome as a
// duration, because "he got there 5m before he handed it over" is a
// subtraction, not an event worth its own line.
expect(find.text('Arrived at customer'), findsNothing);
expect(find.text('5m at the door'), findsOneWidget);
expect(find.text('Out for delivery'), findsOneWidget);
// ── Downwards, like every order-tracking screen ──
//
// Placed at the top, the outcome at the bottom, so the eye finishes on
// what the record is about. The order is the whole reason this is a rail
// and not a list — the rider takes the sequence off the geometry, not off
// the clocks. `.last` is the timeline's rung; `.first` is the summary.
final off = tester.getRect(find.text('Out for delivery'));
final head = tester.getRect(find.text('Delivered').last);
expect(off.top, lessThan(head.top));
// One rail, so every title starts at the same gutter.
expect(head.left, closeTo(off.left, 0.5));
});
testWidgets('a collection is named for its own leg', (tester) async {
await pump(tester, record(type: 'pickup', status: 'picked'));
expect(find.text('Picked up'), findsWidgets);
// On a collection `arrivedat` IS the counter, so it fills the same rung
// the kitchen event fills on a round.
expect(find.text('Arrived'), findsOneWidget);
expect(find.text('Set off'), findsOneWidget);
// Words from the other leg must not leak across.
expect(find.text('Out for delivery'), findsNothing);
});
testWidgets('an unstamped rung is absent, not a placeholder', (
tester,
) async {
// A stop closed in bulk from Home never opened the map, so nothing
// recorded a departure or an arrival. One event happened; one rung draws.
await pump(
tester,
record(
type: 'pickup',
status: 'picked',
started: false,
arrived: false,
),
);
expect(find.text('Set off'), findsNothing);
expect(find.text('Arrived'), findsNothing);
// None of the shapes a placeholder would take. A record that invents a
// plausible step is worse than one with a hole in it, because the hole
// can be investigated and the invention cannot be caught.
for (final invented in const [
'Pending',
'Not recorded',
'--:--',
'12:00 AM',
]) {
expect(
find.text(invented),
findsNothing,
reason: '"$invented" is a lie',
);
}
});
testWidgets('the middle rung drops on its own', (tester) async {
await pump(
tester,
record(type: 'pickup', status: 'picked', arrived: false),
);
expect(find.text('Set off'), findsOneWidget);
expect(find.text('Arrived'), findsNothing);
});
});
group('the full history', () {
testWidgets('every moment the app recorded gets a rung', (tester) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
final base = DateTime(2026, 8, 20, 9, 10);
await pump(tester, {
...record(),
// What the booking carries: when the order was created.
'starttime': base
.subtract(const Duration(minutes: 25))
.toIso8601String(),
// What the rider's own screens stamped as they happened.
OrderEvent.assigned: base
.subtract(const Duration(minutes: 9))
.toIso8601String(),
OrderEvent.accepted: base.toIso8601String(),
OrderEvent.arrivedAtPickup: base
.add(const Duration(minutes: 18))
.toIso8601String(),
OrderEvent.pickedUp: base
.add(const Duration(minutes: 25))
.toIso8601String(),
OrderEvent.outForDelivery: base
.add(const Duration(minutes: 65))
.toIso8601String(),
});
for (final rung in const [
'Assigned',
'Accepted',
'Arrived',
'Picked up',
'Out for delivery',
'Delivered',
]) {
expect(find.text(rung), findsWidgets, reason: '$rung is missing');
}
// ── A glyph per event, not one tick eight times ──
//
// A rail of identical marks says only "these all happened", which the
// rail says by being solid. A rider scanning for the moment he took the
// bag is looking for a shape.
for (final glyph in const [
LucideIcons.mapPinned,
LucideIcons.squareCheckBig,
LucideIcons.store,
LucideIcons.package,
LucideIcons.bike,
]) {
expect(find.byIcon(glyph), findsWidgets, reason: '$glyph is missing');
}
// Oldest at the top, whatever order the stamps were assembled in.
final tops = [
for (final rung in const [
'Assigned',
'Accepted',
'Arrived',
'Picked up',
'Out for delivery',
'Delivered',
])
tester.getRect(find.text(rung).last).top,
];
for (var i = 1; i < tops.length; i++) {
expect(
tops[i],
greaterThan(tops[i - 1]),
reason: 'rung $i is out of order',
);
}
});
testWidgets('a record from before the ledger keeps its three rungs', (
tester,
) async {
// Everything already on a rider's device was written before these stamps
// existed. It must degrade to what it has, not to a page of placeholders.
ServiceProfile.setActive(ServiceProfile.milkMan);
await pump(tester, record());
expect(find.text('Delivered'), findsNWidgets(2));
// The door arrival is not a rung: it folds into the outcome as a
// duration, because "he got there 5m before he handed it over" is a
// subtraction, not an event worth its own line.
expect(find.text('Arrived at customer'), findsNothing);
expect(find.text('5m at the door'), findsOneWidget);
expect(find.text('Out for delivery'), findsOneWidget);
for (final absent in const [
'Order placed',
'Order accepted',
'Arrived at pickup',
'Picked up',
]) {
expect(find.text(absent), findsNothing, reason: '$absent was invented');
}
});
});
group('the step that never happened', () {
testWidgets('a skipped stop shows the delivery it did not make', (
tester,
) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
await pump(
tester,
record(status: 'skipped', skipReason: 'Customer unreachable'),
);
// The one rung on this page allowed to exist without a timestamp: a
// skipped stop has a delivery in its story that did not occur, and saying
// so is the point of the record. It is an open ring, not a filled disc,
// so it cannot read as a step that is merely pending.
expect(find.text('Delivered'), findsOneWidget);
expect(find.text('Not completed — return visit owed'), findsOneWidget);
});
testWidgets('a completed stop has nothing left unreached', (tester) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
await pump(tester, record());
// Twice — the summary and the timeline's last rung — and never a third
// time as a step still to come.
expect(find.text('Delivered'), findsNWidgets(2));
expect(find.text('Not completed — return visit owed'), findsNothing);
expect(find.text('Did not happen'), findsNothing);
});
});
group('route', () {
testWidgets('a round draws both ends; a parcel draws one', (tester) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
await pump(tester, record());
// ── ROUTE is gone; its two addresses moved onto the journey ──
//
// Tracking said *when*, Route said *where*, and the rider joined them
// himself. Each place is now written onto the rung that happened there.
expect(find.text('ROUTE'), findsNothing);
expect(find.text('JOURNEY'), findsOneWidget);
// Once now, not twice. The hero's quiet `from → to` line names both
// ends; the journey names the place on the rung that happened there,
// with its address under it. A second ROUTE section printing the same
// two names again was the duplication this merge removed.
expect(find.text('Vidhya Kitchen'), findsOneWidget);
expect(
find.text('78, Cross Cut Road, Gandhipuram'),
findsOneWidget,
reason:
'the address hangs on the rung that happened at it. With no '
'drop address on the booking the one address it has belongs to '
'the door — the fallback ROUTE encoded and this inherited.',
);
// A parcel booking is a first-mile collection *from* the customer, and
// where it goes next is the hub's problem — there is no second name to
// print, so there is no route to draw.
ServiceProfile.setActive(ServiceProfile.parcel);
await pump(tester, record(type: 'pickup', status: 'picked'));
expect(find.text('ROUTE'), findsNothing);
expect(find.text('JOURNEY'), findsOneWidget);
});
});
group('the measurements', () {
testWidgets('only what was measured is shown', (tester) async {
await pump(
tester,
record(
type: 'pickup',
status: 'picked',
compliance: const {'ontime': true, 'actualkm': 4.6, 'plannedkm': 4.1},
),
);
await unfold(tester);
// ── The labelled stats strip is gone ──
//
// DISTANCE / TOTAL TIME / ORDERS was three captioned cells in a tinted
// box — a dashboard widget, and a dashboard is for comparing many things
// across records. There is one stop on this page and it is finished. The
// figures still earn a place; they earn a line.
expect(find.text('DISTANCE'), findsNothing);
expect(find.text('TOTAL TIME'), findsNothing);
// The distance now reads once in that line, and once in the folded row
// that names it.
expect(find.text('4.6 km'), findsOneWidget);
expect(find.textContaining('4.6 km'), findsWidgets);
expect(find.text('4.1 km'), findsOneWidget);
final verdict = tester.widget<Text>(find.text('Yes'));
expect(verdict.style!.color, ColorConstants.acceptGreen);
// A record that invents a plausible number cannot be caught.
for (final invented in const ['0 km', '0.0 km', '0m', '₹0']) {
expect(
find.text(invented),
findsNothing,
reason: '"$invented" is a lie',
);
}
});
testWidgets('an unmeasured stop says so out loud', (tester) async {
await pump(tester, record(type: 'pickup', status: 'picked'));
await unfold(tester);
// Said out loud, not implied by a missing row: a dropped row would be
// indistinguishable from one that simply had not been scrolled to.
expect(find.text('Not measured'), findsOneWidget);
expect(find.text('Distance'), findsNothing);
});
});
group('order and payment', () {
testWidgets('the reference is there and copyable', (tester) async {
await pump(tester, record(type: 'pickup', status: 'picked'));
await unfold(tester);
expect(find.text('#DM101'), findsOneWidget);
expect(find.byIcon(LucideIcons.copy), findsOneWidget);
});
testWidgets('a round shows no money and no shipment desk', (tester) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
await pump(tester, record(cash: 340));
await unfold(tester);
// Subscribers pay the client monthly; `collectsCash` is false, so not
// one collection row is drawn rather than printing "nothing to collect".
//
// The **group** still exists, because the stop payout lives in it —
// earnings folded into Payment rather than standing as a second group
// about money. What must be absent is any figure.
expect(find.text('Due at this stop'), findsNothing);
expect(find.text('Collected'), findsNothing);
expect(find.textContaining('₹'), findsNothing);
// ── No fabricated payout ──
//
// `deliver` computes `ridercharges` server-side and returns nothing, and
// `/miler/earnings` answers for a period rather than a stop. A number
// here would be pay derived from a rate the app does not hold — the one
// invention on this screen a rider would act on and be wrong about.
expect(find.text('Stop payout'), findsOneWidget);
expect(find.text('See Account'), findsOneWidget);
// Weight and parcel counts belong to the shipment desk, which is a
// capability this line does not run.
expect(find.text('Weight'), findsNothing);
expect(find.text('Parcels handed over'), findsNothing);
});
testWidgets('a parcel stop that collected shows what it took', (
tester,
) async {
await pump(
tester,
record(type: 'pickup', status: 'picked', cash: 340, collected: 340),
);
await unfold(tester);
expect(find.text('Payment'), findsWidgets);
// Due, collected, and again under EARNINGS — the cash that changed hands
// is the one earnings figure this stop really has.
expect(find.text('₹340'), findsNWidgets(3));
// Earnings folded into Payment — one group about money, not two.
expect(find.text('Payment'), findsWidgets);
expect(find.text('Cash collected'), findsOneWidget);
expect(find.text('Weight'), findsOneWidget);
expect(find.text('2.4 kg'), findsOneWidget);
});
testWidgets('a prepaid parcel stop draws no payment section', (
tester,
) async {
await pump(tester, record(type: 'pickup', status: 'picked'));
await unfold(tester);
// Nothing was due and nothing was taken, so not one collection row is
// drawn — a section spent saying a section does not apply is a section
// too many. The group itself survives on the payout row alone.
expect(find.text('Due at this stop'), findsNothing);
expect(find.text('Collected'), findsNothing);
});
});
// ══════════════════════════════════════════════════════════════════════
// ROUTE — each end owns its own address
// ══════════════════════════════════════════════════════════════════════
//
// `_route` read `pickupaddress` **only** and hung it under the drop. On a
// parcel booking that is right by construction — one address, and the
// collection point IS the customer. On a milk run the payload carries both,
// so the record printed the kitchen's street under the customer's name: the
// most checkable fact on the one screen that exists to be checked.
group('route', () {
tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
testWidgets('a round hangs each address on its own end', (tester) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
// Both ends need a rung to hang on: with no collection stamp the
// journey has nothing that happened at the counter, and the kitchen's
// address would have nowhere to go. That is the correct behaviour — a
// place is only drawn on a moment the record actually holds — so the
// fixture supplies the moment.
// The fixture's own clock, not the wall clock: a rung stamped after the
// record closed is dropped on purpose (a timeline that contradicts
// itself is worse than a short one), and `record()` completes at a fixed
// past moment.
final at = now.subtract(const Duration(minutes: 40));
await pump(tester, {
...record(),
'pickupaddress': '4 Cross, RS Puram, Coimbatore 641002',
'dropaddress': '12, SNS Colony, Peelamedu, Coimbatore 641004',
'arrived_at_pickup': at.toIso8601String(),
'picked_up': at.add(const Duration(minutes: 4)).toIso8601String(),
});
// Both addresses now sit on the journey, each on the rung that happened
// at it — the counter on the collection, the door on the outcome — so
// this reads the ladder rather than a separate ROUTE section.
final kitchen = tester.getRect(
find.text('4 Cross, RS Puram, Coimbatore 641002'),
);
final door = tester.getRect(
find.text('12, SNS Colony, Peelamedu, Coimbatore 641004'),
);
expect(
kitchen.top,
lessThan(door.top),
reason: 'the kitchen is the first end of the journey',
);
expect(
tester.getRect(find.text('Joe Mathew').last).top,
lessThan(door.top),
reason: "the customer's door hangs under the customer, not the kitchen",
);
});
testWidgets('one address still belongs to the door', (tester) async {
// The fallback, unchanged. A payload with no separate drop must keep
// hanging its one address exactly where it always hung.
ServiceProfile.setActive(ServiceProfile.milkMan);
await pump(tester, record());
await unfold(tester);
expect(find.text('78, Cross Cut Road, Gandhipuram'), findsOneWidget);
});
testWidgets('the same street is never printed twice', (tester) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
await pump(tester, record());
await unfold(tester);
expect(find.text('78, Cross Cut Road, Gandhipuram'), findsOneWidget);
});
});
}