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