Files
doormile_milderapp/test/card_density_test.dart
2026-08-28 15:07:30 +05:30

1792 lines
68 KiB
Dart

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 bar, and the four facts two-by-two, and
// not enough for a fifth thing to be added without a decision.
//
// ── 190 → 245 ──
//
// Raised deliberately, once, when the four journey figures came out of the
// fold and onto the face. It buys the thing the fold was costing: a rider
// could not see the day's duration, distance, parcel count or cash without
// pressing something, and the press was rare enough that the figures were
// effectively shipped and unreachable.
//
// The 55 points are paid for elsewhere in the same change — the header
// collapses to 52 on scroll where it used to be a permanent 160, and the
// run's ratio came off this card because the header states it. The guard
// stays because the failure mode is unchanged: a card that grows a line at
// a time until the route is below the fold again.
testWidgets('stays within the card budget', (tester) async {
final h = await briefHeight(tester);
expect(
h,
lessThan(245),
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 twice, and this is the third position ──
//
// The four figures were once required ABSENT until tapped, on a
// measurement: four columns plus the trip tabs plus the app bar reached
// the fold before the first kitchen, so Home opened on a dashboard about
// the run instead of on the run.
//
// What changed is the space, not the argument. The header collapses now,
// the tabs are a row of pills rather than a strip, and the figures are
// two-by-two rather than four across — so the same information costs
// about half the vertical it did when it was banished, and the fold it
// was pushing the route past is no longer where it was.
//
// So they are on the face, in sentence case, with the run's ratio taken
// OFF the card entirely — the header states that now.
for (final label in const [
'Duration',
'Distance',
'Parcels',
'Payment',
]) {
expect(
find.text(label),
findsOneWidget,
reason: '"$label" is on the face, not one tap away',
);
}
// The face's own headline is still the thing a rider acts on.
expect(find.textContaining('stop'), findsWidgets);
// The fold keeps what it is actually for: the shift and slot windows,
// read once in the morning.
await tester.tap(find.byType(TripBriefStrip));
await tester.pumpAndSettle();
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);
});
});
// ── The trip selector moved into the header ──
//
// Its height and its three-slot rule are pinned in `trip_tabs_pinned_test`,
// against the widget's own declared height rather than against a sliver
// extent it no longer has. What is left here is the density guard: it is
// chrome the rider sees at every scroll position, so its height is charged
// continuously and must not creep.
group('Trip selector', () {
testWidgets('stays inside its own height budget', (tester) async {
await pump(
tester,
TripSelector(
trips: [
Trip.fromStops(id: 't1', stops: [stop('A')]),
Trip.fromStops(id: 't2', stops: [stop('B')]),
],
selectedIndex: 0,
onSelect: (_) {},
),
);
final h = tester.getSize(find.byType(TripSelector)).height;
// The old segmented strip carried a numbered disc, the word "Trip" and a
// status line, and came to ~70pt. This carries a numeral and a bar, and
// is bigger *per target* at the same overall height — the budget did not
// move, the content did.
expect(
h,
lessThan(80),
reason:
'the selector is header furniture — it is on screen at every '
'scroll position, so its height is charged continuously',
);
expect(tester.takeException(), isNull);
});
testWidgets('names three trips, and says only that', (tester) async {
await pump(
tester,
TripSelector(
trips: [
Trip.fromStops(id: 't1', stops: [stop('A')]),
],
selectedIndex: 0,
onSelect: (_) {},
),
);
for (final n in ['Trip 1', 'Trip 2', 'Trip 3']) {
expect(find.text(n), findsOneWidget);
}
// The run's count belongs to the card below, in type four times the
// size. A filter says what it filters to. See [TripSelector].
expect(find.text('0 / 1'), findsNothing);
expect(find.text('Not set'), findsNothing);
});
});
// ── 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 · BASE, never on RETURN · BASE', (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 · BASE')).top),
);
});
});
// ── The per-segment colour test is gone with the word it probed ──
//
// It found each segment by the shared text "Trip" and read that Text's
// colour. There is no shared word any more — a segment is a numeral and a
// bar — and what the test was really guarding, that the selected trip reads
// as picked, is now carried by the travelling marker rather than by ink on a
// noun. `trip_tabs_pinned_test` measures the marker against the numeral it
// belongs to, which is the same guarantee stated in the terms the control
// now uses.
group('Trip selector marker', () {
Future<void> pumpTabs(WidgetTester tester, {required int selected}) async {
await pump(
tester,
TripSelector(
trips: [
Trip.fromStops(id: 't1', stops: [stop('A')]),
Trip.fromStops(id: 't2', stops: [stop('B')]),
],
selectedIndex: selected,
onSelect: (_) {},
),
);
}
/// Same tree, but left mid-flight so the marker 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: TripSelector(
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 a
/// single travelling object when it was made to travel, and colour cannot
/// identify it: the segments themselves fade between two inks as it passes,
/// so a colour finder loses it exactly during the journey these tests exist
/// to measure.
Rect marker(WidgetTester tester) {
final marks = find.byKey(TripSelector.markerKey);
expect(
marks,
findsOneWidget,
reason: 'exactly one trip may be marked at a time',
);
return tester.getRect(marks);
}
/// One tab's label.
Rect numeral(WidgetTester tester, int slot) =>
tester.getRect(find.text('Trip ${slot + 1}'));
// The marker followed a separate "this trip is running" signal for a while,
// which left trip 1 permanently marked while the rider stood on trip 2 — so
// a tap appeared to do nothing. The marker belongs to selection.
testWidgets('the marker sits under the selected pill', (tester) async {
await pumpTabs(tester, selected: 1);
final m = marker(tester);
final second = numeral(tester, 1);
// A short centred rule, not a full-width underline — so it is centred on
// its pill rather than spanning it.
expect(m.center.dx, closeTo(second.center.dx, 6));
expect(m.top, greaterThan(second.bottom));
});
testWidgets('and travels when a different segment is picked', (
tester,
) async {
await pumpTabs(tester, selected: 0);
final first = marker(tester).center.dx;
await pumpTabs(tester, selected: 2);
final third = marker(tester).center.dx;
expect(
third,
greaterThan(first),
reason: 'the marker did not move with the selection',
);
});
testWidgets('3 → 1 slides across 2 rather than jumping', (tester) async {
// The regression this whole mechanism exists for: the marker used to be
// each segment's own fill, so crossing two slots faded one out on the
// right and a different one in on the left. Nothing ever occupied the
// space between.
await pumpTabs(tester, selected: 2);
final middle = numeral(tester, 1);
await pumpTabsNoSettle(tester, selected: 0);
final samples = <double>[];
for (var i = 0; i < 20; i++) {
await tester.pump(const Duration(milliseconds: 20));
samples.add(marker(tester).center.dx);
}
final half = middle.width;
expect(
samples.any((x) => (x - middle.center.dx).abs() < half * 3),
isTrue,
reason: 'the marker never passed over tab 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',
);
}
});
testWidgets('exactly one marker, whatever is selected', (tester) async {
// ── This assertion has been on both sides, and the principle held ──
//
// It began as "no raised white pill" — the bar was bare words with a
// travelling tint behind them, and a white card would have been the
// loudest object on a page whose brief was that containers recede. Then
// the bar became a segmented control on the canvas, and the pill was the
// design rather than the bug. It is now a white marker on the brand
// header, where white is simply the only mark that reads.
//
// What has to hold through all three is the part that always did: **one**
// mark of selection, never three.
for (final selected in const [0, 1, 2]) {
await pumpTabs(tester, selected: selected);
expect(find.byKey(TripSelector.markerKey), findsOneWidget);
}
});
});
// ── 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*
// object keeps them, because the numbering and the progress head are
// measured against a total that must not move.
//
// ── What the brief's four figures do NOT do ──
//
// They used to be counted from that same total, so duration, distance and
// the parcel count described the slot as assigned and never moved while the
// rider worked it. `20 stops left` counted down above four figures that
// still described the whole morning, including everything already collected
// and handed to Deliveries.
//
// They are counted from [Trip.outstanding] now — the collections still owed
// — so the two halves of the card finally answer the same question. The
// trip's own totals are untouched, which is what the first two assertions
// below are for.
group('completed stops', () {
testWidgets('leave the list and the figures, but not 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 headline is one string now — `1 stop left` — rather than a numeral
// at headline scale beside its caption at body scale. Two sizes and a
// manual baseline nudge to make a figure and its unit look like one
// thing, when they are one thing.
expect(
find.descendant(
of: find.byType(TripBriefStrip),
matching: find.text('1 stop left'),
),
findsOneWidget,
);
final singleStop = Trip.fromStops(id: 't2', stops: [stop('B')]);
expect(
trip.totalParcels,
greaterThan(singleStop.totalParcels),
reason: 'the TRIP still carries the collected stop and its load',
);
// ── But the brief states what is left, not what the slot held ──
//
// Stop A is `picked`, so it is across the boundary and belongs to
// Deliveries. One collection is still owed, and the parcel figure says
// one — the trip's own total of two is deliberately NOT what is drawn.
final outstanding = trip.outstanding();
expect(
outstanding.totalParcels,
lessThan(trip.totalParcels),
reason: 'a collected stop is no longer work in front of the rider',
);
await openBrief(tester);
expect(
find.descendant(
of: find.byType(TripBriefStrip),
matching: find.text('${outstanding.totalParcels}'),
),
findsOneWidget,
reason: 'the card states the outstanding count, not the slot total',
);
});
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, and `TripCard` no longer
// adds a gutter of its own — so both land on the *same* number, which is
// the part that matters. `home_gutter_test` holds them together.
//
// 26 → 29: `panelGutter` 12 outside the white, `panelPad` 16 inside it,
// and one point for the hairline the card carries now (see [Narrative] —
// the border is half of what buys a card its separation on the lighter
// ground). A pixel of border is a pixel of inset.
const homeGutter = 29.0;
const stopRowInset = 29.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 orders'), 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 header's right edge belongs to the journey ──
//
// A chevron has been on and off this header three times, and the argument
// each way was about what the right edge was *for*. It came back once as a
// passive indicator, because a collapsed kitchen showed nothing that said
// "there is a list under me".
//
// The corner is not empty any more: the distance and the ride time live
// there, and they are the reason a rider looks at it — down a multi-kitchen
// run they form the column that answers *which of these is nearest*. A
// glyph between them and the card's edge pushed that column ~24 points off
// the margin so it lined up with nothing.
//
// What is asserted now is the trade: no fold mark on a group header, and
// the journey column sitting on the card's own right edge. The fold state
// is still legible — an open group has its orders on screen under it.
testWidgets('the group header ends on its journey, not a chevron', (
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',
'pickuplat': 11.04,
'pickuplon': 76.98,
},
];
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't', stops: stops),
riderLat: 11.0168,
riderLng: 76.9558,
onToggleSelect: (_) {},
onStopTap: (_) {},
),
);
// Scoped to the route: the trip brief above it still folds and wears the
// same glyph, so an unscoped finder would be a claim about the whole card.
expect(
find.descendant(
of: find.byType(RouteTimeline),
matching: find.byIcon(LucideIcons.chevronDown),
),
findsNothing,
reason: 'the corner is the journey column, not a fold mark',
);
// And the group still folds — the gesture was always the header's, and
// removing the glyph must not have taken the behaviour with it.
// Measured by height rather than by the rows' presence: the body
// collapses through an `AnimatedSize`, which keeps its child mounted at
// zero height, so a `findsNothing` here would be asserting an
// implementation detail of the animation rather than the fold.
final open = tester.getSize(find.byType(RouteTimeline)).height;
await tester.tap(find.text('Vidhya Kitchen'));
await tester.pumpAndSettle();
expect(
tester.getSize(find.byType(RouteTimeline)).height,
lessThan(open),
reason: 'tapping the header still closes its orders',
);
});
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',
);
});
});
}