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_timeline.dart'; import 'package:miler/views/Dashboard/home/trip.dart'; import 'package:miler/views/Dashboard/home/trip_card.dart'; /// A control a rider reaches for without looking has to be in the same place on /// every row of the route. /// /// ── What this used to measure, and why it moved ── /// /// The call button and the tick box on a Home stop *card* were conditional /// children of one meta row — `if (phone.isNotEmpty)` and `if (pending && /// onToggleSelect != null)` — so whenever one was missing the other slid across /// to take its place. A stop with no phone number pushed its tick a tap-target /// to the right; an already-decided stop pushed its call button the same way. /// Down a route of mixed states the two zig-zagged, and a rider reaching for /// "the tick" had to locate it again on every row. /// /// Both of those controls are gone with the cards. The tick *is* the row now, /// and the phone moved into the detail sheet with the rest of the progressive /// disclosure. So the principle is measured where it now lives: the timeline's /// spine. Every node — a place, an order, whatever state it is in — sits on one /// vertical axis, and every row's content starts on one left edge. That is a /// stronger version of the same contract, because it holds across two kinds of /// row rather than one, and it is what makes a run scannable in a glance. /// /// This is the kind of thing that is obvious in a screenshot and invisible in /// code review, so it is measured against a route deliberately built to mix /// every state Home still lists. void main() { tearDown(() => ServiceProfile.setActive(ServiceProfile.parcel)); Map stop( String id, { required String type, String? phone, String? kitchen, }) => { 'orderid': id, 'type': type, 'pickupcustomer': 'Sri Balaji Stores', 'pickupaddress': '19, Thadagam Road, Coimbatore', if (phone != null) 'pickupcontactno': phone, if (kitchen != null) 'kitchenid': kitchen, if (kitchen != null) 'kitchenname': kitchen, 'Quantity': '2', 'expected_pickup_time': DateTime.now().toIso8601String(), }; /// A route covering every trailing-slot combination. /// /// The states are the three Home still lists — pending, rejected, skipped: /// /// 1 pending + phone → tick, and a way into the sheet /// 2 pending, no phone → the same, with nothing to call /// 3 rejected + phone → no tick; it is already decided /// 4 skipped, no phone → not listed at all Future pumpRoute(WidgetTester tester, {String? kitchen}) async { tester.view.physicalSize = const Size(1170, 2532); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.resetPhysicalSize); addTearDown(tester.view.resetDevicePixelRatio); final trip = Trip.fromStops( id: 'mixed', stops: [ stop('A', type: 'pickup', phone: '9876543210', kitchen: kitchen), stop('B', type: 'delivery', kitchen: kitchen), stop('C', type: 'combined', phone: '9876500000', kitchen: kitchen), { ...stop('D', type: 'pickup', kitchen: kitchen), 'orderstatus': 'skipped', }, ], ); await tester.pumpWidget( ScreenUtilInit( designSize: const Size(390, 844), builder: (_, _) => MaterialApp( home: Scaffold( body: SingleChildScrollView( child: TripCard( trip: trip, rejectedIds: const {'C'}, onToggleSelect: (_) {}, onCallStop: (_) {}, onStopTap: (_) {}, onUnrejectStop: (_) async {}, ), ), ), ), ), ); await tester.pumpAndSettle(); } testWidgets('every node sits on one vertical axis', (tester) async { await pumpRoute(tester); final nodes = find.byType(PickupTimelineGroup); expect(nodes.evaluate().length, greaterThan(1), reason: 'a mixed route'); // The spine is a fixed-width rail with the node centred in it, so this // holds by construction — which is exactly why it is worth pinning. The // moment a state draws a wider node or a row grows a leading control, the // rail bends and the run stops being scannable. final centres = [ for (var i = 0; i < nodes.evaluate().length; i++) tester.getRect(nodes.at(i)).left, ]; for (final c in centres) { expect(c, closeTo(centres.first, 0.5)); } }); testWidgets('the rail does not move when a row changes state', ( tester, ) async { await pumpRoute(tester); // Two of the four are listed now. The skipped one has always been // Activity's row; the **rejected** one joined it — a declined stop leaves // Home the moment it is declined rather than sitting struck through with an // UNDO beside it. What is left on the route is the undecided work. final names = find.text('Sri Balaji Stores'); expect(names.evaluate().length, 2); final lefts = [ for (var i = 0; i < 2; i++) tester.getRect(names.at(i)).left, ]; for (final l in lefts) { expect( l, closeTo(lefts.first, 0.5), reason: 'a rejected row must not indent differently from a pending one', ); } }); testWidgets('every order in a place shares one axis', (tester) async { // ── What this used to say, and why it changed ── // // It pinned the *place* node and the *order* nodes to one axis, because a // spine ran from the header down through the steps and a stepped rail would // have bent it. // // Two things removed that line. The place and its orders are grouped by a // card now rather than by a thread, so the header draws no connector below // it; and the orders sit in an inset panel *inside* that card, which is what // makes them read as its contents. A panel that ignored its own container's // inset to keep a nonexistent line straight would just look broken. // // What still matters — and what actually makes a run scannable — is that // every step in a place shares one left edge, whatever state it is in. That // is what is pinned here, plus the panel's own inset, so the nesting cannot // drift a point at a time. ServiceProfile.setActive(ServiceProfile.meals); await pumpRoute(tester, kitchen: 'Vidhya Kitchen'); final group = tester.getRect(find.byType(PickupTimelineGroup).first); final steps = find.byType(PickupTimelineStep); expect(steps.evaluate(), isNotEmpty, reason: 'the group is open'); final lefts = [ for (var i = 0; i < steps.evaluate().length; i++) tester.getRect(steps.at(i)).left, ]; for (final l in lefts) { expect(l, closeTo(lefts.first, 0.5), reason: 'one axis for every order'); } expect( lefts.first - group.left, greaterThan(0), reason: 'the orders sit inside the card, not flush against its edge', ); expect( lefts.first - group.left, // ≤ 22: the panel inset plus the well's own padding. The well returned // when the route moved onto a white MilerPanel — open orders were // white-on-white — so the deliberate indent is two small steps now, and // this bound is what keeps it from becoming three. lessThanOrEqualTo(22), reason: 'measured ${lefts.first - group.left} — a nested well, not a second ' 'indent level growing a point at a time', ); }); testWidgets('the phone is not on an order row', (tester) async { ServiceProfile.setActive(ServiceProfile.meals); await pumpRoute(tester, kitchen: 'Vidhya Kitchen'); // ── Re-scoped, and the contract is unchanged ── // // This asserted no call control anywhere on the route. That was right while // there was nowhere sensible to put one — but ringing a kitchen is an action // on the *place*, and it now lives on the pickup card beside Navigate, where // the rider can reach it without opening a list of five customers he is not // looking for. // // What the test was really protecting is the thing it is still protecting: a // **step** carries who and which bag, and nothing else. Five call buttons // dialling one counter is the meta row coming back one control at a time. for ( var i = 0; i < find.byType(PickupTimelineStep).evaluate().length; i++ ) { expect( find.descendant( of: find.byType(PickupTimelineStep).at(i), matching: find.byIcon(LucideIcons.phone), ), findsNothing, reason: 'step $i grew a call control', ); } // And exactly one on the place that owns it. expect(find.byIcon(LucideIcons.phone), findsOneWidget); }); }