Files
doormile_milderapp/test/card_density_test.dart
2026-08-11 13:16:33 +05:30

991 lines
34 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.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/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.
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;
}
testWidgets('stays compact when collapsed', (tester) async {
final h = await briefHeight(tester);
expect(
h,
lessThan(90),
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('holds the journey figures back until it is opened', (
tester,
) async {
await pump(
tester,
TripCard(trip: Trip.fromStops(id: 't1', stops: [stop('A', cash: 940)])),
);
// The journey tiles are small-caps. ('Trip slot' was in this list until
// the row was removed — see the shift/slot test below.)
const briefLabels = [
'DURATION',
'DISTANCE',
'PARCELS',
'PAYMENT',
];
// Collapsed: progress and time, nothing else.
for (final label in briefLabels) {
expect(
find.text(label),
findsNothing,
reason: '"$label" is briefing material — it must not be on the face',
);
}
expect(
find.text('CURRENT SHIFT'),
findsNothing,
reason: 'the mislabelled shift caption is gone with the card',
);
final collapsed = tester.getSize(find.byType(TripBriefStrip)).height;
await tester.tap(find.byType(TripBriefStrip));
await tester.pumpAndSettle();
for (final label in briefLabels) {
expect(find.text(label), findsOneWidget, reason: '"$label" on open');
}
expect(
tester.getSize(find.byType(TripBriefStrip)).height,
greaterThan(collapsed),
reason: 'opening the brief must actually reveal something',
);
expect(tester.takeException(), isNull);
});
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);
await tester.tap(find.byType(TripBriefStrip));
await tester.pumpAndSettle();
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.
expect(find.bySemanticsLabel('Select stop 1'), 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('Select stop 1'), findsNothing);
// The trip says what happened to it instead of leaving a gap.
expect(
find.text('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.text('Every stop on this trip is taken — work them from Bookings'),
findsOneWidget,
);
});
testWidgets('a rejected stop can be put back to undecided', (tester) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [stop('A')]),
rejectedIds: const {'A'},
onToggleSelect: (_) {},
onAcceptSelected: (_) async {},
onUnrejectStop: (_) async {},
),
);
expect(find.widgetWithText(MilerButton, 'Undo reject'), findsOneWidget);
// Undoing returns it to the queue; it does not accept it outright.
expect(find.widgetWithText(MilerButton, 'Accept'), findsNothing);
});
testWidgets('a rejected stop is stated by its chip, not a button', (
tester,
) async {
await pumpStop(tester, rejected: {'A'});
expect(find.text('Rejected'), findsOneWidget);
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 the stops you want, then accept them below'),
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 the stops you picked from the bar 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 tester.tap(find.byType(TripBriefStrip));
await tester.pumpAndSettle();
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: () {},
),
);
await tester.tap(find.byType(TripBriefStrip));
await tester.pumpAndSettle();
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 chevron = tester.getRect(find.byIcon(Icons.chevron_right_rounded));
final icon = tester.getRect(find.byIcon(Icons.map_rounded));
// Every part of the control has real height and shares a centre line
// with the rest of it.
for (final part in [label, chevron, icon]) {
expect(part.height, greaterThan(0));
}
expect(chevron.center.dy, closeTo(label.center.dy, 1.5));
expect(icon.center.dy, closeTo(label.center.dy, 1.5));
});
// The alignment the band was introduced for in the first place.
testWidgets('and the hub node shares its centre line with START · HUB', (
tester,
) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [stop('A')]),
onViewRoute: () {},
),
);
final node = tester.getRect(find.byIcon(Icons.warehouse_rounded).first);
final label = tester.getRect(find.text('START · HUB'));
expect(node.center.dy, closeTo(label.center.dy, 1.5));
});
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 {
await pumpTrip(tester);
final selectAll = find.text('Select all 3 stops');
expect(selectAll, findsOneWidget);
expect(
tester.getRect(selectAll).top,
lessThan(tester.getRect(find.text('START · HUB')).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);
expect(find.text('ALL'), findsOneWidget);
});
testWidgets('offers a clear once everything is picked', (tester) async {
await pumpTrip(tester, picked: const {'A', 'B', 'C'});
expect(find.text('3 of 3 selected'), findsOneWidget);
expect(find.text('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 its ring and its "N of M done" 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.
expect(find.text('1 of 2 stops done'), findsOneWidget);
});
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 {},
),
);
expect(find.text('2'), findsOneWidget);
expect(find.text('1'), findsNothing);
});
});
group('page gutter', () {
const homeGutter = 28.0;
const stopRowInset = 32.0;
/// The rounded surface a piece of text sits on: walk up from the text to the
/// nearest `Container` that actually paints a card.
Rect cardBehind(WidgetTester tester, String anchor) {
final ancestors = find
.ancestor(of: find.text(anchor), matching: find.byType(Container))
.evaluate();
for (final e in ancestors) {
final d = (e.widget as Container).decoration;
if (d is BoxDecoration && d.borderRadius != null) {
final box = e.renderObject as RenderBox;
final pos = box.localToGlobal(Offset.zero);
return Rect.fromLTWH(pos.dx, pos.dy, box.size.width, box.size.height);
}
}
fail('no card found behind "$anchor"');
}
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;
// The summary is one surface now rather than four, so there is one card
// to measure instead of a list of captions — but it still has to land on
// the same gutter the footer bar uses. Found by its own decoration rather
// than by a caption, because every string on the face is data that moves
// with the trip.
final surface = find.descendant(
of: find.byType(TripBriefStrip),
matching: find.byWidgetPredicate(
(w) =>
w is Container &&
w.decoration is BoxDecoration &&
(w.decoration as BoxDecoration).borderRadius != null,
),
);
final right = screen - tester.getRect(surface.first).right;
expect(
right,
closeTo(homeGutter, 0.5),
reason:
"the brief ends $right from the edge, but Home's gutter is "
'$homeGutter — the summary must sit on it',
);
// The stop rows are inset further, on purpose: the rail takes the first
// 30 points on their left, so matching the gutter on the right left them
// visibly lopsided inside their own row. They are indented list items
// under the summary, not more cards in the same column.
final stopCard = cardBehind(tester, 'Sri Balaji Stores');
final stopRight = screen - stopCard.right;
expect(
stopRight,
closeTo(stopRowInset, 0.5),
reason:
'the stop card ends $stopRight from the edge; it should be '
'inset to $stopRowInset rather than share the summary gutter',
);
expect(
stopRight,
greaterThan(homeGutter),
reason: 'the point of the inset is that it clears the border more',
);
});
testWidgets('the rail is inset as far as the card edge', (tester) async {
await pump(
tester,
TripCard(
trip: Trip.fromStops(id: 't1', stops: [stop('A')]),
),
);
// The route block indents as a whole: whatever the card's right edge
// clears, the rail's left edge clears too. Insetting one side only left
// the rail closer to the border than anything else on screen, so the row
// still read as pushed against the edge — just from the other side.
final node = tester.getRect(find.byType(TripCard));
final railLeft =
tester.getRect(find.text('1')).center.dx -
11; // half the 22pt node, back to its left edge
expect(
railLeft - node.left,
closeTo(stopRowInset, 1.5),
reason:
'the rail starts ${railLeft - node.left} in; the card ends '
'$stopRowInset in — an indented block indents on both sides',
);
});
});
}