Files
doormile_milderapp/test/delivery_details_test.dart
Thiru-tenext d7348e253f 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>
2026-08-22 05:40:35 +05:30

616 lines
25 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
});
}