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 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 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 openBrief(WidgetTester tester) async { await tester.tap(find.byType(TripBriefStrip)); await tester.pumpAndSettle(); } group('Trip brief strip', () { Future 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 pumpStop( WidgetTester tester, { Set accepted = const {}, Set 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> 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 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 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 = []; 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> threeStops() => [ stop('A'), stop('B'), stop('C'), ]; Future pumpTrip( WidgetTester tester, { Set 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> 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 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 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 = []; 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 heightWith(Set 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(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(find.byType(AnimatedContainer)) .map((c) => c.decoration) .whereType() .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 = []; final ticked = []; 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( 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( 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(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', ); }); }); }