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 divider and one row of four figures, and // not enough for a fifth thing to be added without a decision. testWidgets('stays within the card budget', (tester) async { final h = await briefHeight(tester); expect( h, lessThan(190), reason: 'measured $h — the brief is the permanent furniture above the ' 'route, so every point of it is charged at every scroll position', ); }); testWidgets('cash does not make it taller', (tester) async { // The COD figure rides as a chip on the same row, not a fourth card and // not a second line. final without = await briefHeight(tester); final with_ = await briefHeight(tester, cash: 1250); expect( with_, without, reason: 'adding a COD figure must not grow the collapsed strip', ); }); testWidgets('states the journey figures without being asked', ( tester, ) async { await pump( tester, TripCard( trip: Trip.fromStops(id: 't1', stops: [stop('A', cash: 940)]), ), ); // ── Reversed, on an explicit product call ── // // This once required the four figures ABSENT until tapped; then it was // flipped to require them present, on the argument that a once-a-shift // read behind a chevron nobody presses is information the app has and // the rider does not. That argument is about *discoverability* and it // was answered by a measurement: on a 390×844 phone the four columns // plus the trip tabs plus the app bar reach the fold before the first // kitchen, so Home opened on a dashboard about the run instead of on // the run. // // The founder's call is that the figures are reference material and the // route is the screen. What stays out is the progress face — stops left, // and cash in hand — which are the two a rider acts on mid-shift. for (final label in const [ 'DURATION', 'DISTANCE', 'PARCELS', 'PAYMENT', ]) { expect( find.text(label), findsNothing, reason: '"$label" is reference; it is one tap away, not permanent', ); } // And the face itself is still out, unasked. expect(find.textContaining('stop'), findsWidgets); // One tap brings the whole set back. await tester.tap(find.byType(TripBriefStrip)); await tester.pumpAndSettle(); for (final label in const ['DURATION', 'DISTANCE', 'PARCELS']) { expect( find.text(label), findsOneWidget, reason: '$label after the tap', ); } expect( find.text('CURRENT SHIFT'), findsNothing, reason: 'the mislabelled shift caption is gone with the card', ); expect(tester.takeException(), isNull); }); // ── Nothing on this strip can be opened any more ── testWidgets('carries exactly one disclosure control', (tester) async { await pump( tester, TripCard( trip: Trip.fromStops(id: 't1', stops: [stop('A')]), ), ); // One chevron, and it is the only thing on the strip that can be // pressed. Small and grey: what is behind it is reference, and a loud // control would advertise it as something to check. expect( find.descendant( of: find.byType(TripBriefStrip), matching: find.byIcon(LucideIcons.chevronDown), ), findsOneWidget, ); // The rule that read as a loading bar. expect( find.descendant( of: find.byType(TripBriefStrip), matching: find.byType(LinearProgressIndicator), ), findsNothing, reason: 'a thin bar under the app bar reads as "still loading"', ); }); testWidgets('lays out inside a scroll view, open or shut', (tester) async { // The row this replaced used `CrossAxisAlignment.stretch`, which needs an // `IntrinsicHeight` above it or the cards are asked to be infinitely tall // in an unbounded scroll extent — see `card_stripe_layout_test.dart`. It // cost a build once. The replacement has no stretch row at all, and this // holds that it stays that way. await pump( tester, TripCard( trip: Trip.fromStops(id: 't1', stops: [stop('A', cash: 1310)]), ), ); expect(tester.takeException(), isNull); expect(tester.takeException(), isNull); }); }); group('Trip tabs', () { testWidgets('fit in one compact row', (tester) async { await pump( tester, TripTabs( trips: [ Trip.fromStops(id: 't1', stops: [stop('A')]), Trip.fromStops(id: 't2', stops: [stop('B')]), ], selectedIndex: 0, onSelect: (_) {}, ), ); final h = tester.getSize(find.byType(TripTabs)).height; // ── This bound was raised deliberately, and it cost something ── // // The strip went from one line (~41pt) to a segmented control carrying a // label, a status line and a running-indicator (~70pt). That is ~30pt of // *permanent* height, and it is worse than it sounds because the bar is // now a pinned sliver: it is on screen at every scroll position, not just // at the top. // // What it buys is the separation of "the trip I am looking at" from "the // trip that is running", which the one-line version could not express at // all, plus per-trip progress in words rather than an ambiguous bar. That // is a real trade, taken on purpose — but the guard stays, because the // failure mode here is a strip that keeps growing a line at a time until // it crowds out the route it is supposed to be navigating. expect( h, lessThan(80), reason: 'the tab strip is pinned furniture — it is on screen at ' 'every scroll position, so its height is charged continuously', ); expect(tester.takeException(), isNull); }); testWidgets('still show three slots before the hub assigns them', ( tester, ) async { await pump( tester, TripTabs( trips: [ Trip.fromStops(id: 't1', stops: [stop('A')]), ], selectedIndex: 0, onSelect: (_) {}, ), ); expect(find.text('Trip 1'), findsOneWidget); expect(find.text('Trip 2'), findsOneWidget); expect(find.text('Trip 3'), findsOneWidget); }); }); // ── A stop row carries no controls at all ── // // Every row used to have its own Accept and Reject, and for a while an // accepted row kept a live Reject beside its own green confirmation — so a // stop could be accepted and then rejected a second later, with the two wire // calls racing. Both buttons are gone from the row entirely now: ticking // chooses, and the floating bar is the only thing that commits. group('decision row', () { Future 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 · HUB, never on RETURN · HUB', (tester) async { await pump( tester, TripCard( trip: Trip.fromStops(id: 't1', stops: [stop('A')]), onViewRoute: () {}, ), ); expect( find.text('Map'), findsOneWidget, reason: 'one map action per trip, on the row the route starts from', ); expect( tester.getRect(find.text('Map')).top, lessThan(tester.getRect(find.text('RETURN · HUB')).top), ); }); }); group('Trip tab states', () { Future pumpTabs(WidgetTester tester, {required int selected}) async { await pump( tester, TripTabs( trips: [ Trip.fromStops(id: 't1', stops: [stop('A')]), Trip.fromStops(id: 't2', stops: [stop('B')]), ], selectedIndex: selected, onSelect: (_) {}, ), ); } Color colourOf(WidgetTester tester, String label) => tester.widget(find.text(label)).style!.color!; testWidgets('the selected tab takes the brand accent', (tester) async { // The bug: the pill moved but the label stayed slate, so standing on // Trip 2 looked no different from Trip 2 sitting idle. await pumpTabs(tester, selected: 1); expect( colourOf(tester, 'Trip 2'), ColorConstants.primary, reason: 'the tab the rider is on must read as picked', ); expect(colourOf(tester, 'Trip 1'), isNot(ColorConstants.primary)); await pumpTabs(tester, selected: 0); expect(colourOf(tester, 'Trip 1'), ColorConstants.primary); expect( colourOf(tester, 'Trip 3'), isNot(ColorConstants.primary), reason: 'an unassigned slot stays quiet', ); }); testWidgets('the strip stays compact after the state work', (tester) async { await pumpTabs(tester, selected: 0); final h = tester.getSize(find.byType(TripTabs)).height; expect( h, lessThan(66), reason: 'measured $h — the segmented strip is pinned furniture, so ' 'every point of it is charged at every scroll position', ); }); }); group('Trip tab selection marker', () { Future pumpTabs(WidgetTester tester, {required int selected}) async { await pump( tester, TripTabs( trips: [ Trip.fromStops(id: 't1', stops: [stop('A')]), Trip.fromStops(id: 't2', stops: [stop('B')]), ], selectedIndex: selected, onSelect: (_) {}, ), ); } /// Same tree, but leaves the pill mid-flight so it can be sampled. Future pumpTabsNoSettle( WidgetTester tester, { required int selected, }) async { await tester.pumpWidget( ScreenUtilInit( designSize: const Size(390, 844), builder: (_, _) => MaterialApp( home: Scaffold( body: SingleChildScrollView( child: TripTabs( trips: [ Trip.fromStops(id: 't1', stops: [stop('A')]), Trip.fromStops(id: 't2', stops: [stop('B')]), ], selectedIndex: selected, onSelect: (_) {}, ), ), ), ), ), ); await tester.pump(); } /// The travelling selection marker. /// /// Found by key, not by colour. It moved out of the segments and into the /// pill when it was made to travel, and colour cannot identify it any more: /// crossing an unassigned trip it is deliberately grey rather than the brand /// red, so a colour finder loses it exactly during the journey these tests /// exist to measure. Rect underline(WidgetTester tester) { final marks = find.byKey(TripTabs.markerKey); expect( marks, findsOneWidget, reason: 'exactly one tab may be marked at a time', ); return tester.getRect(marks); } // The marker followed a separate "this trip is running" signal for a while, // which left Trip 1 permanently underlined while the rider stood on Trip 2 — // so a tap appeared to do nothing. The marker belongs to selection. testWidgets('the underline sits under the selected tab', (tester) async { await pumpTabs(tester, selected: 0); final first = underline(tester); final trip1 = tester.getRect(find.text('Trip 1')); expect( first.center.dx, closeTo(trip1.center.dx, 2), reason: 'the marker should be under Trip 1', ); }); testWidgets('and travels when a different tab is picked', (tester) async { await pumpTabs(tester, selected: 0); final before = underline(tester).center.dx; await pumpTabs(tester, selected: 1); await tester.pumpAndSettle(); final after = underline(tester).center.dx; final trip2 = tester.getRect(find.text('Trip 2')); expect( after, greaterThan(before), reason: 'the marker moved from $before to $after — it must follow ' 'the selection rightward to Trip 2', ); expect(after, closeTo(trip2.center.dx, 2)); }); testWidgets('Trip 3 → Trip 1 slides across Trip 2 rather than jumping', ( tester, ) async { // The pill used to belong to each segment: one faded out on the right // while a *different* one faded in on the left, so nothing ever occupied // the space between and there was no object to cross Trip 2 with. await pumpTabs(tester, selected: 2); await tester.pumpAndSettle(); final trip2 = tester.getRect(find.text('Trip 2')); final start = underline(tester).center.dx; expect( start, greaterThan(trip2.center.dx), reason: 'sanity: the marker starts to the right of Trip 2', ); // `pumpTabs` pumps and settles; drive the frames by hand instead so the // journey can be sampled. await pumpTabsNoSettle(tester, selected: 0); final samples = []; for (var elapsed = 0; elapsed < 420; elapsed += 20) { await tester.pump(const Duration(milliseconds: 20)); samples.add(underline(tester).center.dx); } final half = trip2.width / 2; expect( samples.any((x) => (x - trip2.center.dx).abs() < half), isTrue, reason: 'the marker never passed over Trip 2; samples: $samples', ); for (var i = 1; i < samples.length; i++) { expect( samples[i], lessThanOrEqualTo(samples[i - 1] + 0.5), reason: 'the marker moved backwards at sample $i: $samples', ); } await tester.pumpAndSettle(); expect( underline(tester).center.dx, closeTo(tester.getRect(find.text('Trip 1')).center.dx, 2), ); }); testWidgets('the pill is the brand tint, not a white card', (tester) async { // It was a white pill with a drop shadow, which made this the one // selected-state in the app that was not the brand colour — the two // controls a rider uses most looked like they came from different apps. await pumpTabs(tester, selected: 0); final fills = tester .widgetList(find.byType(Container)) .map((c) => c.decoration) .whereType() .toList(); final glass = ColorConstants.primary.withValues(alpha: 0.12); expect( fills.map((d) => d.color), contains(glass), reason: 'the selection pill should be the light glass red the bottom ' 'nav bar uses', ); expect( fills.where( (d) => d.color == Colors.white && (d.boxShadow?.isNotEmpty ?? false), ), isEmpty, reason: 'no raised white pill should remain', ); }); }); // ── Home has exactly two horizontal insets ── // // Bookings cards sit at 16 (`PickupCard` carries `margin: horizontal 16.w` // and its list adds no horizontal padding). Home is wider on purpose: it is a // denser screen — shift card, three stat cards, a pinned tab strip and a rail // above the route — and at 16 the whole column read as pressed against the // border. // // Pinned because it is the kind of value that drifts a couple of points at a // time. What matters is that every element on the screen agrees on it: the // cards, the tab strip and the accept bar were at 16, 20 and 18 respectively // at one point, which is what a gutter looks like when nobody is checking. // ── Select-all leads the list it selects ── // // It used to sit under the last hub row, a full screen of scrolling below the // first stop on a six-stop trip — and it is the control the rider reaches for // *before* reading the list, since "take the lot" is the common case. group('select all', () { List> 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* // keeps them, because the brief's remaining count and its parcel figure are // counted from it. group('completed stops', () { testWidgets('are not listed, but still count towards the trip', ( tester, ) async { final trip = Trip.fromStops( id: 't1', stops: [ {...stop('A'), 'orderstatus': 'picked'}, stop('B'), ], ); await pump( tester, TripCard( trip: trip, acceptedIds: const {'A'}, onToggleSelect: (_) {}, onAcceptSelected: (_) async {}, ), ); // Two stops in the trip, one row on screen. expect(trip.stopCount, 2); expect(find.text('Sri Balaji Stores'), findsOneWidget); // And the brief still counts the finished one. // // Asserted on the PARCEL figure rather than on the head: the head states // what is LEFT, and "1 stop left" is what a trip of two with one done and // a trip of one with none done both say. The parcel count can only reach // 2 if the finished stop is still part of the trip, which is the whole // contract here. // The hero sets the figure and its caption as two texts — numeral at // headline scale, words as its caption — so the claim is asserted on // the pair rather than on one sentence. expect( find.descendant( of: find.byType(TripBriefStrip), matching: find.text('1'), ), findsOneWidget, ); expect(find.text('stop left'), findsOneWidget); final singleStop = Trip.fromStops(id: 't2', stops: [stop('B')]); expect( trip.totalParcels, greaterThan(singleStop.totalParcels), reason: 'the collected stop still contributes its load', ); // The parcel figure is reference now, so it is behind the fold — but it // must still be the *trip's* total rather than the visible one, which is // the contract this test exists for. await openBrief(tester); expect( find.descendant( of: find.byType(TripBriefStrip), matching: find.text('${trip.totalParcels}'), ), findsOneWidget, reason: 'and the card states that total, not the visible one', ); }); testWidgets('the numbering does not renumber as stops finish', ( tester, ) async { // Stop 1 is done, so the list starts at 2 — it does not slide up and // call the second stop "1", which would rename a stop mid-shift. await pump( tester, TripCard( trip: Trip.fromStops( id: 't1', stops: [ {...stop('A'), 'orderstatus': 'picked'}, stop('B'), ], ), acceptedIds: const {'A'}, onToggleSelect: (_) {}, onAcceptSelected: (_) async {}, ), ); // The position sits on the timeline node's own meta line now, rather // than on the card's facts line — but it is still the trip's own index // and not the visible list's, which is the whole contract. expect(find.textContaining('Stop 2'), findsOneWidget); expect(find.textContaining('Stop 1'), findsNothing); }); }); group('page gutter', () { // ── Both numbers moved when Home's sections got a surface ── // // These were 28 and 32 — the brief's own 12 and the timeline's own 16, // each plus [TripCard]'s outer 16 — and they disagreed with each other by // four points, which is what the old comment below was written without // measuring. // // The brief and the route now stand on [MilerPanel], layer 1 of the // surface ladder, because Home's ground is the canvas and nothing white // was standing on it. The panel owns the inset: `panelGutter` 12 outside // the white plus `panelPad` 14 inside it, and `TripCard` no longer adds a // gutter of its own. So both land on 26, and — the part that matters — // they land on the *same* number. `home_gutter_test` holds them together. const homeGutter = 26.0; const stopRowInset = 26.0; testWidgets('the summary cards and the stop rows each keep their inset', ( tester, ) async { await pump( tester, TripCard( trip: Trip.fromStops(id: 't1', stops: [stop('A', cash: 340)]), ), ); final screen = tester.getSize(find.byType(TripCard)).width; // ── Measured on the content, because the card is gone again ── // // This has now measured both, because the brief has been both. It was a // bare line on the page, then a bordered surface, and it is a bare // composition again: a `pureSurface` fill inside a `borderSubtle` // outline is a four-percent step on a `daylightSurface` page, so the box // separated nothing and cost the screen its one remaining container. // // Whichever it is, the contract is the same and is the reason the test // exists: **the summary begins and ends on Home's gutter.** With no box // to measure, the rule between the headline and the four figures is the // one element that spans the brief's full content width, so it is what // the gutter is read off. // The rule lives inside the folded figures, so it is opened first — the // gutter it measures is the same either way. await openBrief(tester); final rule = find.descendant( of: find.byType(TripBriefStrip), matching: find.byType(Divider), ); final r = tester.getRect(rule.first); expect( r.left, closeTo(homeGutter, 0.5), reason: 'the brief starts ${r.left} from the edge, but Home\'s gutter is ' '$homeGutter — the summary must sit on it', ); expect( screen - r.right, closeTo(homeGutter, 0.5), reason: 'the brief ends ${screen - r.right} from the edge, but Home\'s ' 'gutter is $homeGutter — the summary must sit on it', ); // ── Ported to the timeline ── // // There is no card behind a stop name any more, so there is no second // surface to measure. What survives is the half that was always the point: // the route block is inset one step further than the summary above it, // because a rail plus its rows is an indented list item under the brief // rather than another card in the same column. final page = tester.getRect(find.byType(TripCard)); final name = tester.getRect(find.text('Sri Balaji Stores').first); expect( name.left - page.left, greaterThan(homeGutter), reason: 'the stop name starts ${name.left - page.left} in, which must clear ' "Home's $homeGutter gutter — the rail takes the space before it", ); }); testWidgets('the stop card is inset the same on both sides', ( tester, ) async { await pump( tester, TripCard( trip: Trip.fromStops(id: 't1', stops: [stop('A')]), ), ); // The route block indents as a whole. The journey thread is a rail again // — outside the rows rather than inside a card — so what is measured is // the block's own edges: the rail starts on the route inset, and the // block ends on it. final page = tester.getRect(find.byType(TripCard)); final timeline = tester.getRect(find.byType(RouteTimeline)); expect( timeline.left - page.left, closeTo(stopRowInset, 1.5), reason: 'the run starts ${timeline.left - page.left} in, not $stopRowInset', ); expect( timeline.left - page.left, closeTo(page.right - timeline.right, 0.5), reason: 'an indented block indents by the same amount on both sides', ); }); }); // ═══════════════════════════════════════════════════════════════════════ // THE PICKUP NODE // // The node carries what the old screen said across a glass "NEXT ACTION" // card, a heading, three meta rows and two buttons. These hold the three // things that made that possible, because each of them is one careless // patch away from coming back. // ═══════════════════════════════════════════════════════════════════════ group('the pickup node', () { List> 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 bags'), findsOneWidget); }); // ── The destination never truncates first ── // // The journey column was a `Flexible` beside the identity column's // `Expanded`, so the two split the row evenly and "Vidhya Kitchen" at 19sp // rendered as "Vid…" — the one element on the node that must survive, // ellipsised by a metric that had width to spare. testWidgets('gives the name the room the journey does not need', ( tester, ) async { await pumpKitchen(tester); final name = tester.getRect(find.text('Vidhya Kitchen')); final journey = tester.getRect(find.textContaining(' km')); expect( name.width, greaterThan(journey.width), reason: 'the destination gets ${name.width} and the journey ' '${journey.width} — the name is the dominant element', ); }); // ── One place open at a time ── // // Groups used to hold independent open/shut state, so every card the rider // opened stayed open — three kitchens expanded is ~600pt of orders, and the // question the screen exists to answer is buried under two counters he is // not standing at. The underlying fact is an accordion: he is at one place. testWidgets('opening a place closes the one that was open', (tester) async { ServiceProfile.setActive(ServiceProfile.milkMan); addTearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); Map 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 disclosure has to LOOK like one ── // // The chevron was removed once when the header became the toggle, and its // signal went with it: a collapsed kitchen showed nothing that said "there // is a list under me". It is back as a passive indicator inside the // header's own gesture — one arena, one owner — and this holds the three // facts that make it honest: it exists, it turns with the state, and a // flat stop (whose tap navigates instead) wears the pointing variant. testWidgets('the header wears a chevron that turns with the state', ( tester, ) async { ServiceProfile.setActive(ServiceProfile.milkMan); addTearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); final stops = [ for (var i = 0; i < 2; i++) { 'orderid': 'K1-$i', 'type': 'delivery', 'pickupcustomer': 'Customer $i', 'pickupaddress': '12, SNS Colony', 'kitchenid': 'K1', 'kitchenname': 'Vidhya Kitchen', 'Quantity': 1, 'orderstatus': 'new', }, ]; await pump( tester, TripCard( trip: Trip.fromStops(id: 't', stops: stops), onToggleSelect: (_) {}, onStopTap: (_) {}, ), ); // Scoped to the route. The trip brief above it also folds, and since // Home moved to one icon family both disclosure marks are the same // glyph — correctly, they mean the same thing — so a bare `byIcon` here // matches the brief's chevron as well and stops being a statement about // the group header at all. final chevron = find.descendant( of: find.byType(RouteTimeline), matching: find.byIcon(LucideIcons.chevronDown), ); expect(chevron, findsOneWidget); double turns() => tester .widget( find.ancestor(of: chevron, matching: find.byType(AnimatedRotation)), ) .turns; final openTurns = turns(); await tester.tap(find.text('Vidhya Kitchen')); await tester.pumpAndSettle(); expect( turns(), isNot(openTurns), reason: 'the chevron must turn when the list opens or closes — a ' 'static disclosure mark is a printed one', ); }); testWidgets('a flat stop points instead of folding', (tester) async { // One order, no counter: its header pushes the stop detail, so the mark // is the go-somewhere chevron, not the fold-open one. await pump( tester, TripCard( trip: Trip.fromStops(id: 't', stops: [stop('A')]), onStopTap: (_) {}, ), ); // Scoped for the same reason as above: the trip brief's own fold mark // is now the same glyph, so an unscoped `findsNothing` here is a claim // about the whole card rather than about this stop's header. expect(find.byIcon(LucideIcons.chevronRight), findsWidgets); expect( find.descendant( of: find.byType(RouteTimeline), matching: find.byIcon(LucideIcons.chevronDown), ), findsNothing, ); }); // ── A card must not change height when its state changes ── // // The state word had a line of its own between the name and the load, and // that line only existed when there *was* a state — so an accepted card // stood ~18pt taller than a pending one and a three-kitchen run stepped up // and down as the rider worked it. Controls that move under the thumb // between one glance and the next are the thing this guards. // // The word sits on the action row now, in space that row already spent on a // `Spacer`, so accepting a counter changes what the card *says* and nothing // about where anything on it is. testWidgets('accepting a place does not resize its card', (tester) async { ServiceProfile.setActive(ServiceProfile.milkMan); addTearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); Future 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', ); }); }); }