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