Files
doormile_milderapp/test/card_density_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

1815 lines
67 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:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:miler/data/service_profile.dart';
import 'package:miler/views/Dashboard/home/route_brief.dart';
import 'package:miler/views/Dashboard/home/trip.dart';
import 'package:miler/views/helpers/constants/Colorconstants.dart';
import 'package:miler/views/Dashboard/home/trip_brief_strip.dart';
import 'package:miler/views/Dashboard/home/route_timeline.dart';
import 'package:miler/views/Dashboard/home/trip_card.dart';
import 'package:miler/views/Dashboard/home/trip_tabs.dart';
import 'package:miler/views/helpers/widgets/app_widgets.dart';
/// Density is the whole point of these two redesigns, so it is asserted rather
/// than claimed. A phone screen is roughly 844pt tall; how much of it the
/// permanent furniture eats decides how many stops the rider can see.
void main() {
Future<void> pump(WidgetTester tester, Widget child) async {
tester.view.physicalSize = const Size(1170, 2532);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
home: Scaffold(body: SingleChildScrollView(child: child)),
),
),
);
await tester.pumpAndSettle();
}
Map<String, dynamic> stop(String id, {double cash = 0}) => {
'orderid': id,
'type': 'pickup',
'pickupcustomer': 'Sri Balaji Stores',
'pickupaddress': '19, Thadagam Road, Venkitapuram, Coimbatore',
'pickupqty': '3',
'Quantity': '3',
if (cash > 0) 'collectionamt': '$cash',
'expected_pickup_time': DateTime.now().toIso8601String(),
};
// ── The trip brief is collapsed by default, and that is the whole point ──
//
// The four surfaces this replaced — a shift strip, a "CURRENT SHIFT" card, a
// 66dp ring and three stat cards — measured ~351dp at this design size, on a
// scrollable region of roughly 650. Over half the screen was spent before the
// rider saw a stop. These tests hold the new budget, because the failure mode
// is exactly the one that produced the old header: a line at a time, each
// individually defensible.
/// Opens the trip strip's folded figures.
///
/// They are reference material and sit behind one chevron — see
/// `states the journey figures`. Anything asserting a figure opens it first.
Future<void> openBrief(WidgetTester tester) async {
await tester.tap(find.byType(TripBriefStrip));
await tester.pumpAndSettle();
}
group('Trip brief strip', () {
Future<double> briefHeight(WidgetTester tester, {double cash = 0}) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(
id: 't1',
stops: [stop('A', cash: cash)],
),
),
);
return tester.getSize(find.byType(TripBriefStrip)).height;
}
// ── The brief is one open card now ──
//
// It was a collapsed line with the four journey figures behind a chevron,
// and a 3pt progress rule under it. The rule sat directly below the app bar
// in the exact position and shape every other app uses for a loading
// indicator, so a completion signal was read as a spinner; and the chevron
// was opened so rarely that the figures behind it were shipped and
// unreachable.
//
// So: no rule, no disclosure, one card that is always in the state the
// rider previously had to discover. That costs height, and the budget says
// how much — enough for a head, a divider and one row of four figures, and
// not enough for a fifth thing to be added without a decision.
testWidgets('stays within the card budget', (tester) async {
final h = await briefHeight(tester);
expect(
h,
lessThan(190),
reason:
'measured $h — the brief is the permanent furniture above the '
'route, so every point of it is charged at every scroll position',
);
});
testWidgets('cash does not make it taller', (tester) async {
// The COD figure rides as a chip on the same row, not a fourth card and
// not a second line.
final without = await briefHeight(tester);
final with_ = await briefHeight(tester, cash: 1250);
expect(
with_,
without,
reason: 'adding a COD figure must not grow the collapsed strip',
);
});
testWidgets('states the journey figures without being asked', (
tester,
) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [stop('A', cash: 940)]),
),
);
// ── Reversed, on an explicit product call ──
//
// This once required the four figures ABSENT until tapped; then it was
// flipped to require them present, on the argument that a once-a-shift
// read behind a chevron nobody presses is information the app has and
// the rider does not. That argument is about *discoverability* and it
// was answered by a measurement: on a 390×844 phone the four columns
// plus the trip tabs plus the app bar reach the fold before the first
// kitchen, so Home opened on a dashboard about the run instead of on
// the run.
//
// The founder's call is that the figures are reference material and the
// route is the screen. What stays out is the progress face — stops left,
// and cash in hand — which are the two a rider acts on mid-shift.
for (final label in const [
'DURATION',
'DISTANCE',
'PARCELS',
'PAYMENT',
]) {
expect(
find.text(label),
findsNothing,
reason: '"$label" is reference; it is one tap away, not permanent',
);
}
// And the face itself is still out, unasked.
expect(find.textContaining('stop'), findsWidgets);
// One tap brings the whole set back.
await tester.tap(find.byType(TripBriefStrip));
await tester.pumpAndSettle();
for (final label in const ['DURATION', 'DISTANCE', 'PARCELS']) {
expect(
find.text(label),
findsOneWidget,
reason: '$label after the tap',
);
}
expect(
find.text('CURRENT SHIFT'),
findsNothing,
reason: 'the mislabelled shift caption is gone with the card',
);
expect(tester.takeException(), isNull);
});
// ── Nothing on this strip can be opened any more ──
testWidgets('carries exactly one disclosure control', (tester) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [stop('A')]),
),
);
// One chevron, and it is the only thing on the strip that can be
// pressed. Small and grey: what is behind it is reference, and a loud
// control would advertise it as something to check.
expect(
find.descendant(
of: find.byType(TripBriefStrip),
matching: find.byIcon(LucideIcons.chevronDown),
),
findsOneWidget,
);
// The rule that read as a loading bar.
expect(
find.descendant(
of: find.byType(TripBriefStrip),
matching: find.byType(LinearProgressIndicator),
),
findsNothing,
reason: 'a thin bar under the app bar reads as "still loading"',
);
});
testWidgets('lays out inside a scroll view, open or shut', (tester) async {
// The row this replaced used `CrossAxisAlignment.stretch`, which needs an
// `IntrinsicHeight` above it or the cards are asked to be infinitely tall
// in an unbounded scroll extent — see `card_stripe_layout_test.dart`. It
// cost a build once. The replacement has no stretch row at all, and this
// holds that it stays that way.
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [stop('A', cash: 1310)]),
),
);
expect(tester.takeException(), isNull);
expect(tester.takeException(), isNull);
});
});
group('Trip tabs', () {
testWidgets('fit in one compact row', (tester) async {
await pump(
tester,
TripTabs(
trips: [
Trip.fromStops(id: 't1', stops: [stop('A')]),
Trip.fromStops(id: 't2', stops: [stop('B')]),
],
selectedIndex: 0,
onSelect: (_) {},
),
);
final h = tester.getSize(find.byType(TripTabs)).height;
// ── This bound was raised deliberately, and it cost something ──
//
// The strip went from one line (~41pt) to a segmented control carrying a
// label, a status line and a running-indicator (~70pt). That is ~30pt of
// *permanent* height, and it is worse than it sounds because the bar is
// now a pinned sliver: it is on screen at every scroll position, not just
// at the top.
//
// What it buys is the separation of "the trip I am looking at" from "the
// trip that is running", which the one-line version could not express at
// all, plus per-trip progress in words rather than an ambiguous bar. That
// is a real trade, taken on purpose — but the guard stays, because the
// failure mode here is a strip that keeps growing a line at a time until
// it crowds out the route it is supposed to be navigating.
expect(
h,
lessThan(80),
reason:
'the tab strip is pinned furniture — it is on screen at '
'every scroll position, so its height is charged continuously',
);
expect(tester.takeException(), isNull);
});
testWidgets('still show three slots before the hub assigns them', (
tester,
) async {
await pump(
tester,
TripTabs(
trips: [
Trip.fromStops(id: 't1', stops: [stop('A')]),
],
selectedIndex: 0,
onSelect: (_) {},
),
);
expect(find.text('Trip 1'), findsOneWidget);
expect(find.text('Trip 2'), findsOneWidget);
expect(find.text('Trip 3'), findsOneWidget);
});
});
// ── A stop row carries no controls at all ──
//
// Every row used to have its own Accept and Reject, and for a while an
// accepted row kept a live Reject beside its own green confirmation — so a
// stop could be accepted and then rejected a second later, with the two wire
// calls racing. Both buttons are gone from the row entirely now: ticking
// chooses, and the floating bar is the only thing that commits.
group('decision row', () {
Future<void> pumpStop(
WidgetTester tester, {
Set<String> accepted = const {},
Set<String> rejected = const {},
}) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [stop('A')]),
acceptedIds: accepted,
rejectedIds: rejected,
onToggleSelect: (_) {},
onAcceptSelected: (_) async {},
),
);
}
testWidgets('an undecided stop offers a tick, not buttons', (tester) async {
await pumpStop(tester);
for (final label in ['Reject', 'Accept', 'Accept instead']) {
expect(
find.widgetWithText(MilerButton, label),
findsNothing,
reason: '"$label" is not a per-stop control any more',
);
}
// The tick is what is left, and it is how a stop is chosen. Ported: the
// card's tick box became the timeline row itself, which announces as a
// selection control while one is meaningful.
expect(find.bySemanticsLabel(RegExp('^Select ')), findsOneWidget);
});
testWidgets('an accepted stop leaves the screen entirely', (tester) async {
await pumpStop(tester, accepted: {'A'});
// Home lists what still needs deciding here. Once a stop is taken it is
// work in hand, and work in hand lives on Bookings — the row used to stay
// behind saying "Accepted", which is a row the rider can do nothing with.
expect(find.text('Sri Balaji Stores'), findsNothing);
expect(find.bySemanticsLabel(RegExp('^Select ')), findsNothing);
// The trip says what happened to it instead of leaving a gap — as an
// illustration now, whose semantic label still carries the sentence.
expect(
find.bySemanticsLabel(
'Every stop on this trip is taken — work them from Bookings',
),
findsOneWidget,
);
});
testWidgets('a skipped stop leaves the screen, like a finished one', (
tester,
) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(
id: 't1',
stops: [
{...stop('A'), 'orderstatus': 'skipped'},
],
),
onToggleSelect: (_) {},
onAcceptSelected: (_) async {},
),
);
// A skip is a record of something that happened at a door, not an offer
// waiting on a decision — so it is Activity's row now, with the Resume
// control on it. Home is only ever "what am I taking?".
expect(find.text('Sri Balaji Stores'), findsNothing);
expect(find.widgetWithText(MilerButton, 'Resume'), findsNothing);
expect(
find.bySemanticsLabel(
'Every stop on this trip is taken — work them from Bookings',
),
findsOneWidget,
);
});
// ── A declined stop leaves Home, and the UNDO leaves with it ──
//
// It used to stay on the route struck through, with an UNDO beside it, for
// the rest of the shift — a row the rider had finished with, occupying the
// screen he uses to decide what is next. Declining is a decision, and
// decisions are recorded on Activity: it files there the moment he makes
// it, stamped `rejected` so that page can say **Declined**.
testWidgets('a declined stop leaves Home entirely', (tester) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [stop('A')]),
rejectedIds: const {'A'},
onToggleSelect: (_) {},
onAcceptSelected: (_) async {},
onUnrejectStop: (_) async {},
),
);
expect(
find.text('UNDO'),
findsNothing,
reason:
'an action that reverses itself from the page it was performed '
'on is what kept the row here',
);
expect(find.text('Sri Balaji Stores'), findsNothing);
expect(find.widgetWithText(MilerButton, 'Accept'), findsNothing);
});
// The chip that used to state this is gone with the row: a declined stop is
// no longer drawn on Home at all. What must never come back is the thing
// this test was really guarding — a dead button standing in for a status.
testWidgets('a declined stop is not drawn as a pressable anything', (
tester,
) async {
await pumpStop(tester, rejected: {'A'});
expect(find.text('Rejected'), findsNothing);
expect(
find.widgetWithText(MilerButton, 'Rejected'),
findsNothing,
reason: 'a button that cannot be pressed is not a status',
);
});
});
// ── One accept in the whole screen ──
//
// The footer used to carry "Accept all N stops", a second bulk action over a
// different set of stops from the floating bar's — and the difference between
// the two sets was exactly the stops the rider had just decided against.
group('bulk action', () {
List<Map<String, dynamic>> threeStops() => [
stop('A'),
stop('B'),
stop('C'),
];
testWidgets('the footer has no accept button of its own', (tester) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: threeStops()),
onAcceptSelected: (_) async {},
onToggleSelect: (_) {},
),
);
expect(find.textContaining('Accept all'), findsNothing);
expect(find.textContaining('Accept remaining'), findsNothing);
expect(find.textContaining('Continue trip'), findsNothing);
// It points at the one accept there is instead.
expect(find.text('Tick stops to accept them'), findsOneWidget);
});
testWidgets('and says where the action is once stops are ticked', (
tester,
) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: threeStops()),
selectedIds: const {'A', 'B'},
onAcceptSelected: (_) async {},
onToggleSelect: (_) {},
),
);
expect(find.text('Accept your picked stops below'), findsOneWidget);
});
});
group('Trip brief contents', () {
testWidgets('the shift is labelled, and the trip slot is not repeated', (
tester,
) async {
// Two bugs, closed one after the other.
//
// First: the old card captioned `trip.slotLabel` — the TRIP's window — as
// "CURRENT SHIFT", while the rider's actual shift sat in a separate strip
// directly above it. Two ranges, stacked, one wearing the other's name.
// The fix was a labelled row each.
//
// Then the slot row turned out to be printing a range the rider could
// already see: the collapsed face falls back to `slotLabel`, and the trip
// TAB above the strip is labelled with it too. So the slot keeps its
// places and the brief keeps only the window that is genuinely its own.
final trip = Trip.fromStops(id: 't1', stops: [stop('A')]);
final brief = RouteBrief.from(
orderIds: const [],
ordersMap: const {},
shiftStart: '09:00:00',
shiftEnd: '18:00:00',
);
await pump(tester, TripCard(trip: trip, brief: brief));
await openBrief(tester);
expect(find.text('Shift'), findsOneWidget);
expect(
find.text('Trip slot'),
findsNothing,
reason: 'the slot is on the face and on the tab; a third copy is noise',
);
expect(
find.text(brief.shiftWindowLabel),
findsOneWidget,
reason: "the shift row states the rider's window, not the trip's",
);
});
testWidgets('the brief holds no actions, only figures', (tester) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [stop('A')]),
onViewRoute: () {},
),
);
expect(
find.descendant(
of: find.byType(TripBriefStrip),
matching: find.text('Map'),
),
findsNothing,
reason:
'the map is an action; it belongs on the route, not in a list of '
'read-only figures',
);
});
});
// ── The map opens from where the route begins ──
//
// It used to be buried at the foot of the trip brief: an action among nine
// read-only figures, two taps deep behind a strip the rider has no other
// reason to open. It now sits on the START · HUB row, which is both where the
// route starts and where his eye already is when he starts it.
group('MAP on the hub row', () {
testWidgets('is reachable without opening anything', (tester) async {
var opened = false;
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [stop('A')]),
onViewRoute: () => opened = true,
),
);
expect(find.text('Map'), findsOneWidget);
expect(
tester.getRect(find.text('Map')).top,
lessThan(tester.getRect(find.text('Sri Balaji Stores')).top),
reason: 'it sits on the hub row, above the first stop',
);
await tester.tap(find.text('Map'));
await tester.pumpAndSettle();
expect(opened, isTrue);
});
// ── It has to fit in the row it sits on ──
//
// Aligning the hub node with its label once boxed the whole row to the
// node's 22dp height, which cropped this control — a tap target with its
// own padding and a chevron — through the middle. The band is sized for the
// tallest thing on the row now, and this is what says so.
testWidgets('is drawn whole, not cropped by the row it sits in', (
tester,
) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [stop('A')]),
onViewRoute: () {},
),
);
final label = tester.getRect(find.text('Map'));
final icon = tester.getRect(find.byIcon(LucideIcons.map));
// Every part of the control has real height and shares a centre line
// with the rest of it.
// Ported: the chevron went with the hub row's band. What is left is the
// same contract on the run head — every part of the control has real
// height and shares one centre line, so none of it is cropped.
for (final part in [label, icon]) {
expect(part.height, greaterThan(0));
}
expect(icon.center.dy, closeTo(label.center.dy, 1.5));
});
// ── The band this held is gone ──
//
// `START · HUB` was a node and a label sharing a 36pt band, and this pinned
// their centre lines. The run opens on a one-line head now — an eyebrow, a
// rule and the map action — so there is no node to align and nothing left
// to crop. What the band was protecting is held by the test above, which
// asserts the map control is drawn whole.
testWidgets('and stays hidden when there is nothing to open', (
tester,
) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [stop('A')]),
),
);
expect(
find.text('Map'),
findsNothing,
reason: 'the action is hidden until a callback is supplied',
);
});
testWidgets('only on START · HUB, never on RETURN · HUB', (tester) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [stop('A')]),
onViewRoute: () {},
),
);
expect(
find.text('Map'),
findsOneWidget,
reason: 'one map action per trip, on the row the route starts from',
);
expect(
tester.getRect(find.text('Map')).top,
lessThan(tester.getRect(find.text('RETURN · HUB')).top),
);
});
});
group('Trip tab states', () {
Future<void> pumpTabs(WidgetTester tester, {required int selected}) async {
await pump(
tester,
TripTabs(
trips: [
Trip.fromStops(id: 't1', stops: [stop('A')]),
Trip.fromStops(id: 't2', stops: [stop('B')]),
],
selectedIndex: selected,
onSelect: (_) {},
),
);
}
Color colourOf(WidgetTester tester, String label) =>
tester.widget<Text>(find.text(label)).style!.color!;
testWidgets('the selected tab takes the brand accent', (tester) async {
// The bug: the pill moved but the label stayed slate, so standing on
// Trip 2 looked no different from Trip 2 sitting idle.
await pumpTabs(tester, selected: 1);
expect(
colourOf(tester, 'Trip 2'),
ColorConstants.primary,
reason: 'the tab the rider is on must read as picked',
);
expect(colourOf(tester, 'Trip 1'), isNot(ColorConstants.primary));
await pumpTabs(tester, selected: 0);
expect(colourOf(tester, 'Trip 1'), ColorConstants.primary);
expect(
colourOf(tester, 'Trip 3'),
isNot(ColorConstants.primary),
reason: 'an unassigned slot stays quiet',
);
});
testWidgets('the strip stays compact after the state work', (tester) async {
await pumpTabs(tester, selected: 0);
final h = tester.getSize(find.byType(TripTabs)).height;
expect(
h,
lessThan(66),
reason:
'measured $h — the segmented strip is pinned furniture, so '
'every point of it is charged at every scroll position',
);
});
});
group('Trip tab selection marker', () {
Future<void> pumpTabs(WidgetTester tester, {required int selected}) async {
await pump(
tester,
TripTabs(
trips: [
Trip.fromStops(id: 't1', stops: [stop('A')]),
Trip.fromStops(id: 't2', stops: [stop('B')]),
],
selectedIndex: selected,
onSelect: (_) {},
),
);
}
/// Same tree, but leaves the pill mid-flight so it can be sampled.
Future<void> pumpTabsNoSettle(
WidgetTester tester, {
required int selected,
}) async {
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(390, 844),
builder: (_, _) => MaterialApp(
home: Scaffold(
body: SingleChildScrollView(
child: TripTabs(
trips: [
Trip.fromStops(id: 't1', stops: [stop('A')]),
Trip.fromStops(id: 't2', stops: [stop('B')]),
],
selectedIndex: selected,
onSelect: (_) {},
),
),
),
),
),
);
await tester.pump();
}
/// The travelling selection marker.
///
/// Found by key, not by colour. It moved out of the segments and into the
/// pill when it was made to travel, and colour cannot identify it any more:
/// crossing an unassigned trip it is deliberately grey rather than the brand
/// red, so a colour finder loses it exactly during the journey these tests
/// exist to measure.
Rect underline(WidgetTester tester) {
final marks = find.byKey(TripTabs.markerKey);
expect(
marks,
findsOneWidget,
reason: 'exactly one tab may be marked at a time',
);
return tester.getRect(marks);
}
// The marker followed a separate "this trip is running" signal for a while,
// which left Trip 1 permanently underlined while the rider stood on Trip 2 —
// so a tap appeared to do nothing. The marker belongs to selection.
testWidgets('the underline sits under the selected tab', (tester) async {
await pumpTabs(tester, selected: 0);
final first = underline(tester);
final trip1 = tester.getRect(find.text('Trip 1'));
expect(
first.center.dx,
closeTo(trip1.center.dx, 2),
reason: 'the marker should be under Trip 1',
);
});
testWidgets('and travels when a different tab is picked', (tester) async {
await pumpTabs(tester, selected: 0);
final before = underline(tester).center.dx;
await pumpTabs(tester, selected: 1);
await tester.pumpAndSettle();
final after = underline(tester).center.dx;
final trip2 = tester.getRect(find.text('Trip 2'));
expect(
after,
greaterThan(before),
reason:
'the marker moved from $before to $after — it must follow '
'the selection rightward to Trip 2',
);
expect(after, closeTo(trip2.center.dx, 2));
});
testWidgets('Trip 3 → Trip 1 slides across Trip 2 rather than jumping', (
tester,
) async {
// The pill used to belong to each segment: one faded out on the right
// while a *different* one faded in on the left, so nothing ever occupied
// the space between and there was no object to cross Trip 2 with.
await pumpTabs(tester, selected: 2);
await tester.pumpAndSettle();
final trip2 = tester.getRect(find.text('Trip 2'));
final start = underline(tester).center.dx;
expect(
start,
greaterThan(trip2.center.dx),
reason: 'sanity: the marker starts to the right of Trip 2',
);
// `pumpTabs` pumps and settles; drive the frames by hand instead so the
// journey can be sampled.
await pumpTabsNoSettle(tester, selected: 0);
final samples = <double>[];
for (var elapsed = 0; elapsed < 420; elapsed += 20) {
await tester.pump(const Duration(milliseconds: 20));
samples.add(underline(tester).center.dx);
}
final half = trip2.width / 2;
expect(
samples.any((x) => (x - trip2.center.dx).abs() < half),
isTrue,
reason: 'the marker never passed over Trip 2; samples: $samples',
);
for (var i = 1; i < samples.length; i++) {
expect(
samples[i],
lessThanOrEqualTo(samples[i - 1] + 0.5),
reason: 'the marker moved backwards at sample $i: $samples',
);
}
await tester.pumpAndSettle();
expect(
underline(tester).center.dx,
closeTo(tester.getRect(find.text('Trip 1')).center.dx, 2),
);
});
testWidgets('the pill is the brand tint, not a white card', (tester) async {
// It was a white pill with a drop shadow, which made this the one
// selected-state in the app that was not the brand colour — the two
// controls a rider uses most looked like they came from different apps.
await pumpTabs(tester, selected: 0);
final fills = tester
.widgetList<Container>(find.byType(Container))
.map((c) => c.decoration)
.whereType<BoxDecoration>()
.toList();
final glass = ColorConstants.primary.withValues(alpha: 0.12);
expect(
fills.map((d) => d.color),
contains(glass),
reason:
'the selection pill should be the light glass red the bottom '
'nav bar uses',
);
expect(
fills.where(
(d) => d.color == Colors.white && (d.boxShadow?.isNotEmpty ?? false),
),
isEmpty,
reason: 'no raised white pill should remain',
);
});
});
// ── Home has exactly two horizontal insets ──
//
// Bookings cards sit at 16 (`PickupCard` carries `margin: horizontal 16.w`
// and its list adds no horizontal padding). Home is wider on purpose: it is a
// denser screen — shift card, three stat cards, a pinned tab strip and a rail
// above the route — and at 16 the whole column read as pressed against the
// border.
//
// Pinned because it is the kind of value that drifts a couple of points at a
// time. What matters is that every element on the screen agrees on it: the
// cards, the tab strip and the accept bar were at 16, 20 and 18 respectively
// at one point, which is what a gutter looks like when nobody is checking.
// ── Select-all leads the list it selects ──
//
// It used to sit under the last hub row, a full screen of scrolling below the
// first stop on a six-stop trip — and it is the control the rider reaches for
// *before* reading the list, since "take the lot" is the common case.
group('select all', () {
List<Map<String, dynamic>> threeStops() => [
stop('A'),
stop('B'),
stop('C'),
];
Future<void> pumpTrip(
WidgetTester tester, {
Set<String> picked = const {},
}) {
return pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: threeStops()),
selectedIds: picked,
onToggleSelect: (_) {},
onSelectAll: (_) {},
onAcceptSelected: (_) async {},
),
);
}
testWidgets('sits above the route, not below it', (tester) async {
// In selection mode: the control is contextual now — it arrives with the
// first tick and leaves with the last, rather than occupying a row above
// every route all day. What is pinned here is unchanged and is the reason
// the test exists: *where* it appears when it does.
await pumpTrip(tester, picked: const {'A'});
final selectAll = find.byType(SelectAllAction);
expect(selectAll, findsOneWidget);
expect(find.text('1 of 3 selected'), findsOneWidget);
// Ported: measured against the first stop rather than the START · HUB
// row, which went with the cards. The contract is the position — it heads
// the list it selects, so "take the lot" is reached before reading it
// rather than after scrolling past everything.
expect(
tester.getRect(selectAll).top,
lessThan(tester.getRect(find.text('Sri Balaji Stores').first).top),
reason: 'it heads the list; the route starts under it',
);
});
testWidgets('counts the picked stops as they are ticked', (tester) async {
await pumpTrip(tester, picked: const {'A', 'B'});
expect(find.text('2 of 3 selected'), findsOneWidget);
});
// The wording carries the state now, rather than a second `ALL`/`CLEAR`
// affordance sitting beside it. The control is one 44pt row and the row
// itself performs both gestures, so a separate word to aim at was one more
// target for a gesture already available on the whole line.
testWidgets('offers a clear once everything is picked', (tester) async {
await pumpTrip(tester, picked: const {'A', 'B', 'C'});
expect(find.text('All 3 selected · Clear'), findsOneWidget);
});
testWidgets('stays away when there is only one stop to pick', (
tester,
) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [stop('A')]),
onToggleSelect: (_) {},
onSelectAll: (_) {},
onAcceptSelected: (_) async {},
),
);
expect(find.textContaining('Select all'), findsNothing);
});
});
// ── Finished work leaves the decision screen ──
//
// A completed stop used to stay in the list, greyed out with a "Completed"
// chip, and there is no bound on how many of those a shift produces. Six
// stops in, the rider was scrolling past six rows he could do nothing with to
// reach the one he could. They move to the Activity tab — but the *trip*
// keeps them, because the brief's remaining count and its parcel figure are
// counted from it.
group('completed stops', () {
testWidgets('are not listed, but still count towards the trip', (
tester,
) async {
final trip = Trip.fromStops(
id: 't1',
stops: [
{...stop('A'), 'orderstatus': 'picked'},
stop('B'),
],
);
await pump(
tester,
TripCard(
trip: trip,
acceptedIds: const {'A'},
onToggleSelect: (_) {},
onAcceptSelected: (_) async {},
),
);
// Two stops in the trip, one row on screen.
expect(trip.stopCount, 2);
expect(find.text('Sri Balaji Stores'), findsOneWidget);
// And the brief still counts the finished one.
//
// Asserted on the PARCEL figure rather than on the head: the head states
// what is LEFT, and "1 stop left" is what a trip of two with one done and
// a trip of one with none done both say. The parcel count can only reach
// 2 if the finished stop is still part of the trip, which is the whole
// contract here.
// The hero sets the figure and its caption as two texts — numeral at
// headline scale, words as its caption — so the claim is asserted on
// the pair rather than on one sentence.
expect(
find.descendant(
of: find.byType(TripBriefStrip),
matching: find.text('1'),
),
findsOneWidget,
);
expect(find.text('stop left'), findsOneWidget);
final singleStop = Trip.fromStops(id: 't2', stops: [stop('B')]);
expect(
trip.totalParcels,
greaterThan(singleStop.totalParcels),
reason: 'the collected stop still contributes its load',
);
// The parcel figure is reference now, so it is behind the fold — but it
// must still be the *trip's* total rather than the visible one, which is
// the contract this test exists for.
await openBrief(tester);
expect(
find.descendant(
of: find.byType(TripBriefStrip),
matching: find.text('${trip.totalParcels}'),
),
findsOneWidget,
reason: 'and the card states that total, not the visible one',
);
});
testWidgets('the numbering does not renumber as stops finish', (
tester,
) async {
// Stop 1 is done, so the list starts at 2 — it does not slide up and
// call the second stop "1", which would rename a stop mid-shift.
await pump(
tester,
TripCard(
trip: Trip.fromStops(
id: 't1',
stops: [
{...stop('A'), 'orderstatus': 'picked'},
stop('B'),
],
),
acceptedIds: const {'A'},
onToggleSelect: (_) {},
onAcceptSelected: (_) async {},
),
);
// The position sits on the timeline node's own meta line now, rather
// than on the card's facts line — but it is still the trip's own index
// and not the visible list's, which is the whole contract.
expect(find.textContaining('Stop 2'), findsOneWidget);
expect(find.textContaining('Stop 1'), findsNothing);
});
});
group('page gutter', () {
// ── Both numbers moved when Home's sections got a surface ──
//
// These were 28 and 32 — the brief's own 12 and the timeline's own 16,
// each plus [TripCard]'s outer 16 — and they disagreed with each other by
// four points, which is what the old comment below was written without
// measuring.
//
// The brief and the route now stand on [MilerPanel], layer 1 of the
// surface ladder, because Home's ground is the canvas and nothing white
// was standing on it. The panel owns the inset: `panelGutter` 12 outside
// the white plus `panelPad` 14 inside it, and `TripCard` no longer adds a
// gutter of its own. So both land on 26, and — the part that matters —
// they land on the *same* number. `home_gutter_test` holds them together.
const homeGutter = 26.0;
const stopRowInset = 26.0;
testWidgets('the summary cards and the stop rows each keep their inset', (
tester,
) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [stop('A', cash: 340)]),
),
);
final screen = tester.getSize(find.byType(TripCard)).width;
// ── Measured on the content, because the card is gone again ──
//
// This has now measured both, because the brief has been both. It was a
// bare line on the page, then a bordered surface, and it is a bare
// composition again: a `pureSurface` fill inside a `borderSubtle`
// outline is a four-percent step on a `daylightSurface` page, so the box
// separated nothing and cost the screen its one remaining container.
//
// Whichever it is, the contract is the same and is the reason the test
// exists: **the summary begins and ends on Home's gutter.** With no box
// to measure, the rule between the headline and the four figures is the
// one element that spans the brief's full content width, so it is what
// the gutter is read off.
// The rule lives inside the folded figures, so it is opened first — the
// gutter it measures is the same either way.
await openBrief(tester);
final rule = find.descendant(
of: find.byType(TripBriefStrip),
matching: find.byType(Divider),
);
final r = tester.getRect(rule.first);
expect(
r.left,
closeTo(homeGutter, 0.5),
reason:
'the brief starts ${r.left} from the edge, but Home\'s gutter is '
'$homeGutter — the summary must sit on it',
);
expect(
screen - r.right,
closeTo(homeGutter, 0.5),
reason:
'the brief ends ${screen - r.right} from the edge, but Home\'s '
'gutter is $homeGutter — the summary must sit on it',
);
// ── Ported to the timeline ──
//
// There is no card behind a stop name any more, so there is no second
// surface to measure. What survives is the half that was always the point:
// the route block is inset one step further than the summary above it,
// because a rail plus its rows is an indented list item under the brief
// rather than another card in the same column.
final page = tester.getRect(find.byType(TripCard));
final name = tester.getRect(find.text('Sri Balaji Stores').first);
expect(
name.left - page.left,
greaterThan(homeGutter),
reason:
'the stop name starts ${name.left - page.left} in, which must clear '
"Home's $homeGutter gutter — the rail takes the space before it",
);
});
testWidgets('the stop card is inset the same on both sides', (
tester,
) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [stop('A')]),
),
);
// The route block indents as a whole. The journey thread is a rail again
// — outside the rows rather than inside a card — so what is measured is
// the block's own edges: the rail starts on the route inset, and the
// block ends on it.
final page = tester.getRect(find.byType(TripCard));
final timeline = tester.getRect(find.byType(RouteTimeline));
expect(
timeline.left - page.left,
closeTo(stopRowInset, 1.5),
reason:
'the run starts ${timeline.left - page.left} in, not $stopRowInset',
);
expect(
timeline.left - page.left,
closeTo(page.right - timeline.right, 0.5),
reason: 'an indented block indents by the same amount on both sides',
);
});
});
// ═══════════════════════════════════════════════════════════════════════
// THE PICKUP NODE
//
// The node carries what the old screen said across a glass "NEXT ACTION"
// card, a heading, three meta rows and two buttons. These hold the three
// things that made that possible, because each of them is one careless
// patch away from coming back.
// ═══════════════════════════════════════════════════════════════════════
group('the pickup node', () {
List<Map<String, dynamic>> kitchen({bool coords = true}) => [
for (var i = 0; i < 5; i++)
{
'orderid': 'K1-$i',
'type': 'delivery',
'pickupcustomer': 'Customer $i',
'pickupaddress': '12, SNS Colony, Peelamedu, Coimbatore',
'kitchenid': 'K1',
'kitchenname': 'Vidhya Kitchen',
'Quantity': 1,
'orderstatus': 'new',
if (coords) ...{'pickuplat': 11.04, 'pickuplon': 76.98},
},
];
Future<void> pumpKitchen(
WidgetTester tester, {
bool coords = true,
bool rider = true,
}) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
addTearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
final stops = kitchen(coords: coords);
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't', stops: stops),
acceptedIds: {for (final s in stops) s['orderid'] as String},
riderLat: rider ? 11.0168 : null,
riderLng: rider ? 76.9558 : null,
onToggleSelect: (_) {},
onSelectAll: (_) {},
onNavigateStop: (_) {},
onStopTap: (_) {},
),
);
}
// ── One journey action on the whole screen ──
//
// There were two: a full-width red button on the "NEXT ACTION" glass card
// above the route, and an identical one inside the group for the same
// counter ~200pt below it. The card is gone and the node that owns the
// journey owns the button — so this counts, rather than trusting it.
testWidgets('offers exactly one Navigate, for the place that owns it', (
tester,
) async {
await pumpKitchen(tester);
// The journey is an icon in the card's action cluster now — the three
// things a rider does about a place, as three equal circles. Asserted by
// its accessible name, which is the contract that actually matters and
// does not move when the glyph does.
expect(find.bySemanticsLabel('Navigate to this pickup'), findsOneWidget);
expect(find.text('Vidhya Kitchen'), findsOneWidget);
});
// ── Distance and ETA are measured, or absent ──
//
// The pair is the reason the node has a second column at all, and the one
// way it could do real harm is by being confident when it does not know:
// a rider plans the next fifteen minutes around it. `metersToStop` returns
// null rather than zero when there is no fix and no `kms`, and the column
// must not invent a `0 m` or a `~0 min` out of that.
testWidgets('states the journey when it is measured', (tester) async {
await pumpKitchen(tester);
expect(find.textContaining(' km'), findsOneWidget);
expect(find.textContaining('~'), findsOneWidget);
});
testWidgets('and says nothing at all when it is not', (tester) async {
await pumpKitchen(tester, coords: false, rider: false);
expect(
find.textContaining(' km'),
findsNothing,
reason: 'no coordinates and no rider fix means no distance to state',
);
expect(find.textContaining('0 m'), findsNothing);
// The load still reads, so the node degrades rather than emptying.
expect(find.text('5 bags'), findsOneWidget);
});
// ── The destination never truncates first ──
//
// The journey column was a `Flexible` beside the identity column's
// `Expanded`, so the two split the row evenly and "Vidhya Kitchen" at 19sp
// rendered as "Vid…" — the one element on the node that must survive,
// ellipsised by a metric that had width to spare.
testWidgets('gives the name the room the journey does not need', (
tester,
) async {
await pumpKitchen(tester);
final name = tester.getRect(find.text('Vidhya Kitchen'));
final journey = tester.getRect(find.textContaining(' km'));
expect(
name.width,
greaterThan(journey.width),
reason:
'the destination gets ${name.width} and the journey '
'${journey.width} — the name is the dominant element',
);
});
// ── One place open at a time ──
//
// Groups used to hold independent open/shut state, so every card the rider
// opened stayed open — three kitchens expanded is ~600pt of orders, and the
// question the screen exists to answer is buried under two counters he is
// not standing at. The underlying fact is an accordion: he is at one place.
testWidgets('opening a place closes the one that was open', (tester) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
addTearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
Map<String, dynamic> order(String id, String kitchen, String customer) =>
{
'orderid': id,
'type': 'delivery',
'pickupcustomer': customer,
'pickupaddress': '12, SNS Colony, Peelamedu',
'kitchenid': kitchen,
'kitchenname': kitchen,
'Quantity': 1,
'orderstatus': 'new',
};
await pump(
tester,
TripCard(
trip: Trip.fromStops(
id: 't',
stops: [
order('1', 'Vidhya Kitchen', 'Joe'),
order('2', 'Vidhya Kitchen', 'Arun'),
order('3', 'Anna Kitchen', 'Meena'),
order('4', 'Anna Kitchen', 'Sai'),
],
),
onToggleSelect: (_) {},
onStopTap: (_) {},
),
);
// ── Measured, not found ──
//
// `AnimatedCrossFade` builds *both* branches, so `find.text('Meena')`
// resolves inside the collapsed card's unpainted child and cannot tell
// open from shut. Height can: an open place is its header plus a panel of
// orders, a shut one is just the header.
double heightOf(int i) =>
tester.getSize(find.byType(PickupTimelineGroup).at(i)).height;
// The first place opens itself — the rider has not chosen anything yet.
final firstOpen = heightOf(0);
final secondShut = heightOf(1);
expect(
firstOpen,
greaterThan(secondShut),
reason: 'the group being worked opens without being asked',
);
await tester.tap(find.text('Anna Kitchen'));
await tester.pumpAndSettle();
expect(heightOf(1), greaterThan(secondShut), reason: 'the second opened');
expect(
heightOf(0),
lessThan(firstOpen),
reason: 'and the first closed — two open places is not a route',
);
// Tapping the open one shuts it, leaving the run fully collapsed.
await tester.tap(find.text('Anna Kitchen'));
await tester.pumpAndSettle();
expect(heightOf(0), lessThan(firstOpen));
expect(heightOf(1), closeTo(secondShut, 0.5));
});
// ── Three targets, three meanings ──
//
// The card carries three gestures and they must not bleed into each other:
// the body previews the place, the chevron discloses the orders, and the
// rail ticks one. This is the kind of thing that regresses silently — a
// handler moved up one widget and suddenly expanding a kitchen also selects
// it — so each is asserted by what it *calls*.
testWidgets('the card body discloses, the "i" previews', (tester) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
addTearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
var previews = 0;
final selected = <String>[];
final stops = [
for (var i = 0; i < 3; i++)
{
'orderid': 'K1-$i',
'type': 'delivery',
'pickupcustomer': 'Customer $i',
'pickupaddress': '12, SNS Colony',
'kitchenid': 'K1',
'kitchenname': 'Vidhya Kitchen',
'Quantity': 1,
'orderstatus': 'new',
},
];
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't', stops: stops),
onToggleSelect: selected.add,
onStopTap: (_) {},
onPreviewGroup: (_) => previews++,
),
);
final card = find.byType(PickupTimelineGroup).first;
final open = tester.getSize(card).height;
// The body: discloses, and never ticks. A rider must be able to look at a
// counter's orders without taking one of them on.
await tester.tap(find.text('Vidhya Kitchen'));
await tester.pumpAndSettle();
expect(
tester.getSize(card).height,
lessThan(open),
reason: 'tapping the card closed the orders',
);
expect(
selected,
isEmpty,
reason: 'disclosing a place must never tick one of its orders',
);
expect(previews, 0, reason: 'and it does not open a sheet either');
// The place circle: previews, and leaves the disclosure alone. (The
// glyph is the storefront now — the app's mark for "this place" — where
// it was a generic "i".)
final shut = tester.getSize(card).height;
await tester.tap(find.byIcon(LucideIcons.store));
await tester.pumpAndSettle();
expect(previews, 1, reason: 'the place circle opens the pickup preview');
expect(
tester.getSize(card).height,
closeTo(shut, 0.5),
reason: 'and it must not toggle the list',
);
});
// ── The disclosure has to LOOK like one ──
//
// The chevron was removed once when the header became the toggle, and its
// signal went with it: a collapsed kitchen showed nothing that said "there
// is a list under me". It is back as a passive indicator inside the
// header's own gesture — one arena, one owner — and this holds the three
// facts that make it honest: it exists, it turns with the state, and a
// flat stop (whose tap navigates instead) wears the pointing variant.
testWidgets('the header wears a chevron that turns with the state', (
tester,
) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
addTearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
final stops = [
for (var i = 0; i < 2; i++)
{
'orderid': 'K1-$i',
'type': 'delivery',
'pickupcustomer': 'Customer $i',
'pickupaddress': '12, SNS Colony',
'kitchenid': 'K1',
'kitchenname': 'Vidhya Kitchen',
'Quantity': 1,
'orderstatus': 'new',
},
];
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't', stops: stops),
onToggleSelect: (_) {},
onStopTap: (_) {},
),
);
// Scoped to the route. The trip brief above it also folds, and since
// Home moved to one icon family both disclosure marks are the same
// glyph — correctly, they mean the same thing — so a bare `byIcon` here
// matches the brief's chevron as well and stops being a statement about
// the group header at all.
final chevron = find.descendant(
of: find.byType(RouteTimeline),
matching: find.byIcon(LucideIcons.chevronDown),
);
expect(chevron, findsOneWidget);
double turns() => tester
.widget<AnimatedRotation>(
find.ancestor(of: chevron, matching: find.byType(AnimatedRotation)),
)
.turns;
final openTurns = turns();
await tester.tap(find.text('Vidhya Kitchen'));
await tester.pumpAndSettle();
expect(
turns(),
isNot(openTurns),
reason:
'the chevron must turn when the list opens or closes — a '
'static disclosure mark is a printed one',
);
});
testWidgets('a flat stop points instead of folding', (tester) async {
// One order, no counter: its header pushes the stop detail, so the mark
// is the go-somewhere chevron, not the fold-open one.
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't', stops: [stop('A')]),
onStopTap: (_) {},
),
);
// Scoped for the same reason as above: the trip brief's own fold mark
// is now the same glyph, so an unscoped `findsNothing` here is a claim
// about the whole card rather than about this stop's header.
expect(find.byIcon(LucideIcons.chevronRight), findsWidgets);
expect(
find.descendant(
of: find.byType(RouteTimeline),
matching: find.byIcon(LucideIcons.chevronDown),
),
findsNothing,
);
});
// ── A card must not change height when its state changes ──
//
// The state word had a line of its own between the name and the load, and
// that line only existed when there *was* a state — so an accepted card
// stood ~18pt taller than a pending one and a three-kitchen run stepped up
// and down as the rider worked it. Controls that move under the thumb
// between one glance and the next are the thing this guards.
//
// The word sits on the action row now, in space that row already spent on a
// `Spacer`, so accepting a counter changes what the card *says* and nothing
// about where anything on it is.
testWidgets('accepting a place does not resize its card', (tester) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
addTearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
Future<double> heightWith(Set<String> accepted) async {
final stops = kitchen();
await pump(
tester,
TripCard(
// A fresh subtree per pump. Without it the second `pumpWidget`
// reuses `_RouteTimelineState` — including the accordion key the
// first one's tap left behind — so the second tap *opened* the card
// instead of closing it and the measurement compared open to shut.
key: ValueKey('accepted-${accepted.length}'),
trip: Trip.fromStops(id: 't', stops: stops),
acceptedIds: accepted,
riderLat: 11.0168,
riderLng: 76.9558,
onToggleSelect: (_) {},
onSelectAll: (_) {},
onCallStop: (_) {},
onNavigateStop: (_) {},
onStopTap: (_) {},
onPreviewGroup: (_) {},
),
);
// Collapsed, so what is measured is the *card* rather than its
// contents: an accepted place also gains a `Navigate to pickup` inside
// its dropdown, and that is a control appearing, which is allowed to
// cost height. What is not allowed is the header changing shape.
await tester.tap(find.text('Vidhya Kitchen'));
await tester.pumpAndSettle();
return tester.getSize(find.byType(PickupTimelineGroup).first).height;
}
final pending = await heightWith(const {});
final accepted = await heightWith({for (var i = 0; i < 5; i++) 'K1-$i'});
expect(
accepted,
closeTo(pending, 0.5),
reason:
'pending $pending vs accepted $accepted — the state word must not '
'be worth a line of its own',
);
// And it is genuinely said, not simply dropped — with how much of the
// counter he has taken, which "Accepted" alone never told him. A place
// with four orders and one accepted used to render the same word as a
// place with four accepted.
expect(find.text('Accepted 5/5'), findsOneWidget);
});
// ── Two of four accepted has to be readable in under a second ──
//
// A counter with four orders where the rider had taken two drew four
// identical hollow numbered rings: the one question an open group has to
// answer — *which of these are already mine* — had no answer on the screen,
// and he had to remember.
//
// Three channels now carry it, so losing any one still leaves two: the
// node is **filled** rather than hollow (shape — survives grayscale and
// sunlight), the tile takes a settled wash, and the name goes to w700 in
// full slate. The bag numeral survives all of it, because that is what the
// rider matches against the shelf.
testWidgets('an accepted order is unmistakable beside a pending one', (
tester,
) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
addTearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: kitchen()),
// Two of the five taken; three still to decide.
acceptedIds: const {'K1-0', 'K1-1'},
riderLat: 11.0168,
riderLng: 76.9558,
onToggleSelect: (_) {},
onSelectAll: (_) {},
onCallStop: (_) {},
onNavigateStop: (_) {},
onStopTap: (_) {},
onPreviewGroup: (_) {},
),
);
await tester.tap(find.text('Vidhya Kitchen'));
await tester.pumpAndSettle();
final steps = tester
.widgetList<PickupTimelineStep>(find.byType(PickupTimelineStep))
.toList();
expect(steps, hasLength(5));
expect(steps[0].state, StopState.accepted);
expect(steps[4].state, StopState.pending);
// ── Channel 1: the node is filled, not hollow ──
final nodes = tester
.widgetList<AnimatedContainer>(find.byType(AnimatedContainer))
.map((c) => c.decoration)
.whereType<BoxDecoration>()
.where((d) => d.shape == BoxShape.circle)
.toList();
final filled = nodes.where((d) => d.color != ColorConstants.pureSurface);
expect(
filled.length,
greaterThanOrEqualTo(2),
reason: 'the two accepted orders draw a filled mark',
);
// ── Channel 2: the bag numeral survives ──
//
// One order = one bag, and the numeral is matched against a printed
// label. Accepting must never renumber it or replace it with a tick.
for (var i = 1; i <= 5; i++) {
expect(
find.text('$i'),
findsWidgets,
reason: 'bag $i lost its number when its state changed',
);
}
// ── Channel 3: the group says how far through the counter he is ──
expect(find.text('Accepted 2/5'), findsOneWidget);
});
// ── Two intents, two targets ──
//
// The row body ticked and a small block in the corner opened the sheet, so
// the *content* of a row — a customer's name — did the one thing that has
// nothing to do with reading about that customer, and a rider who wanted
// the address had to find the corner. Tapping content opens content;
// selection keeps its own dedicated control, the rail node, which is the
// thing that visibly changes when ticked.
// ── The body selects; the corner opens ──
//
// Selecting is what a rider does to an order five times per counter;
// reading its address is what he does to one of them occasionally. The
// frequent action gets the large target — the whole row — and the
// occasional one gets the block that already looks like a way in: the bag,
// the order id and a chevron in the right corner.
//
// The two must stay **siblings**. Nested, the two recognisers resolve to
// neither, which is how the card's chevron came to be dead for a build.
testWidgets('the row body selects; the order-id block opens it', (
tester,
) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
addTearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
final opened = <String>[];
final ticked = <String>[];
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: kitchen()),
acceptedIds: const {'K1-0'},
riderLat: 11.0168,
riderLng: 76.9558,
onToggleSelect: ticked.add,
onSelectAll: (_) {},
onCallStop: (_) {},
onNavigateStop: (_) {},
onStopTap: (s) => opened.add((s['orderid'] ?? '').toString()),
onPreviewGroup: (_) {},
),
);
await tester.tap(find.text('Vidhya Kitchen'));
await tester.pumpAndSettle();
final step = tester.widget<PickupTimelineStep>(
find.byType(PickupTimelineStep).first,
);
expect(step.selectable, isTrue, reason: 'a meal row stays tickable');
// ── The row body ticks ──
//
// Invoked rather than tapped: the panel lives inside an
// `AnimatedCrossFade`, whose Stack makes a synthetic tap land on the
// card's ink host rather than the row. What is under test is the wiring,
// and the wiring is what a real pointer resolves against.
final rowInk = tester.widget<InkWell>(
find
.descendant(
of: find.byType(PickupTimelineStep).first,
matching: find.byType(InkWell),
)
.first,
);
rowInk.onTap!();
await tester.pumpAndSettle();
expect(ticked, ['K1-0'], reason: 'the row is the selection target');
expect(opened, isEmpty, reason: 'selecting must not open a sheet');
// ── The corner block opens ──
expect(
find.byIcon(LucideIcons.chevronRight),
findsWidgets,
reason: 'the way in still looks like one',
);
step.onOpen!();
await tester.pumpAndSettle();
expect(opened, ['K1-0']);
expect(ticked, ['K1-0'], reason: 'opening must not change the selection');
});
// ── The spine starts at the first order and ends at the last ──
//
// `lineAbove` was hard-coded true, so order 1 drew a connector upward out
// of its own node into the select-all row above it — a length of route
// joining a stop to a control. A connector means "there is another stop
// that way"; above the first one there is not.
testWidgets('the rail opens at order 1 and closes at the last', (
tester,
) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
addTearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: kitchen()),
acceptedIds: {for (var i = 0; i < 5; i++) 'K1-$i'},
riderLat: 11.0168,
riderLng: 76.9558,
onToggleSelect: (_) {},
onSelectAll: (_) {},
onCallStop: (_) {},
onNavigateStop: (_) {},
onStopTap: (_) {},
onPreviewGroup: (_) {},
),
);
await tester.tap(find.text('Vidhya Kitchen'));
await tester.pumpAndSettle();
final steps = tester
.widgetList<PickupTimelineStep>(find.byType(PickupTimelineStep))
.toList();
expect(steps, hasLength(5));
expect(steps.first.first, isTrue, reason: 'nothing is above order 1');
expect(steps.first.last, isFalse);
expect(steps.last.last, isTrue, reason: 'nothing is below the last');
expect(steps.last.first, isFalse);
for (final mid in steps.sublist(1, steps.length - 1)) {
expect(mid.first, isFalse);
expect(mid.last, isFalse);
}
});
// ── One accepted out of four is not the same as four ──
//
// The chip said "Accepted" either way, so a rider who had taken a quarter
// of a counter was told only that he had taken *something* — and the three
// still to decide were a scroll and a count away, which is the work this
// word exists to remove.
testWidgets('the state word counts how much of the place he took', (
tester,
) async {
ServiceProfile.setActive(ServiceProfile.milkMan);
addTearDown(() => ServiceProfile.setActive(ServiceProfile.parcel));
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: kitchen()),
acceptedIds: const {'K1-0'},
riderLat: 11.0168,
riderLng: 76.9558,
onToggleSelect: (_) {},
onSelectAll: (_) {},
onCallStop: (_) {},
onNavigateStop: (_) {},
onStopTap: (_) {},
onPreviewGroup: (_) {},
),
);
expect(find.text('Accepted 1/5'), findsOneWidget);
});
// ── Density, as a number, and the budget moved once ──
//
// This was ≤48: a step was one line of text plus its air. A step now carries
// two — the bag and the order's own id, which is the block the rider taps to
// open a stop — inside a tile with its own inset. That is ~58, and it was
// bought deliberately: the id is how the rider and the hub name an order on
// the phone when something has gone wrong, and it had no home before.
//
// The guard is what matters and it still guards. A step that grew back into
// a *card* — address lines, a facts row, a button — was 138pt, which is what
// made a five-order counter not fit a viewport. 64 leaves no room for that
// and every point of headroom has to be argued for here.
testWidgets('keeps a step to its two lines plus its air', (tester) async {
await pumpKitchen(tester);
final h = tester.getSize(find.byType(PickupTimelineStep).first).height;
expect(
h,
lessThanOrEqualTo(64.0),
reason: 'a step measured $h — it is a milestone, not a card',
);
expect(
h,
greaterThanOrEqualTo(44.0),
reason: 'and it still has to be hittable with a thumb',
);
});
});
}