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>
1815 lines
67 KiB
Dart
1815 lines
67 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 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',
|
||
);
|
||
});
|
||
});
|
||
}
|