@Tags(['shots']) library; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_test/flutter_test.dart'; import 'support/golden_fonts.dart'; import 'package:get/get.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:miler/data/service_profile.dart'; import 'package:miler/views/helpers/constants/Font_constant.dart'; import 'package:miler/views/Dashboard/home/trip.dart'; import 'package:miler/views/Dashboard/home/trip_card.dart'; import 'package:miler/views/helpers/constants/Colorconstants.dart'; import 'package:miler/views/helpers/constants/miler_surface.dart'; import 'package:miler/views/offline/offline_banner.dart'; /// Throwaway visual harness for Home's route block. /// /// `_HomepageState` cannot be pumped — Get, SharedPreferences, Geolocator, live /// fetches — but [TripCard] is a pure `StatelessWidget` over a [Trip] plus the /// accepted / rejected / selected / collected id sets, which is exactly the /// surface being redesigned. Run with `--update-goldens`. void main() { setUpAll(() async { // ── The harness must load the family the app asks for ── // // `FontConstants.fontFamily` is what every style names, and a golden // rendered without that family loaded is a picture of the fallback — which // is how a font regression gets baked into a reference image and then // asserted against forever. Poppins ships as separate weight files, so each // one is registered under the same family with its own `FontWeight`; // without that, `w800` headings render as smeared SemiBold in the goldens // and nowhere else. await loadGoldenFonts(); }); tearDown(() { Get.reset(); ServiceProfile.setActive(ServiceProfile.parcel); }); final now = DateTime.now(); Map order( String id, String customer, String kitchenId, String kitchenName, { String address = '12, SNS Colony, Peelamedu, Coimbatore 641004', String status = 'new', String? bagLabel, int minsOut = 20, double lat = 11.0168, double lng = 76.9558, }) => { 'orderid': id, 'tripid': 'T1', 'type': 'delivery', 'pickupcustomer': customer, 'pickupaddress': address, 'dropaddress': address, 'orderstatus': status, 'kitchenid': kitchenId, 'kitchenname': kitchenName, // The kitchen's number. Without it the card's Call control is correctly // suppressed — a dead button on a card is worse than no button — so the // fixture has to carry one for the shots to show the state at all. 'pickupcontactno': '9787698259', if (bagLabel != null) 'baglabel': bagLabel, 'Quantity': 1, 'expected_pickup_time': now .add(Duration(minutes: minsOut)) .toIso8601String(), // ── `pickuplon`, not `pickuplng` ── // // The fixture used `pickuplng`, which no reader in the app looks for: // `Trip._coordsOf` and `RouteMetricsHelper.metersToStop` both read // `pickuplon` / `PickupLon`. So every stop in this harness was silently // coordinate-less, every leg estimate came out zero, and the shots have // been showing the no-distance degradation while claiming to show a route. 'pickuplat': lat, 'pickuplon': lng, 'droplat': lat + 0.004, 'droplon': lng + 0.004, }; /// One kitchen's orders. [away] nudges the whole group off the rider's own /// position so each kitchen sits at a different, plausible distance — which /// is the thing the header's journey column exists to show. List> kitchen( String id, String name, List customers, { String status = 'new', int startAt = 1, double away = 0.02, }) => [ for (var i = 0; i < customers.length; i++) order( '$id-${startAt + i}', customers[i], id, name, status: status, minsOut: 15 + i * 4, lat: 11.0168 + away, lng: 76.9558 + away, ), ]; Future shoot( WidgetTester tester, String name, { required List> stops, Set accepted = const {}, Set rejected = const {}, Set selected = const {}, Set collected = const {}, ServiceProfile profile = ServiceProfile.milkMan, double height = 1400, double width = 390, double textScale = 1.0, bool offline = false, Future Function(WidgetTester)? settle, }) async { ServiceProfile.setActive(profile); SharedPreferences.setMockInitialValues({'userid': 1}); tester.view.physicalSize = Size(width * 3, height * 3); tester.view.devicePixelRatio = 3.0; addTearDown(tester.view.reset); final trip = Trip.groupIntoTrips(stops).first; await tester.pumpWidget( // ── Phone-scale layout on a taller canvas ── // // ScreenUtil derives `.h` from the *view* height, so rendering a 1800pt // surface against a 844pt design size multiplies every vertical token by // 2.13 and the screenshot lies about the spacing. Overriding MediaQuery // to the real phone keeps the scale honest; the surface stays tall so the // whole page fits in one frame. // ── Phone-scale layout on a taller canvas ── // // ScreenUtil derives `.w`/`.h` from the *view*, so a 1800pt surface // against a 844pt design size multiplies every vertical token by 2.13 and // the screenshot lies about the spacing. Declaring the design size as the // surface makes both factors exactly 1 — which is what a real 390×844 // phone produces against the app's own design size. ScreenUtilInit( designSize: Size(width, height), builder: (_, _) => MaterialApp( debugShowCheckedModeBanner: false, home: MediaQuery( data: MediaQueryData(textScaler: TextScaler.linear(textScale)), child: Scaffold( // The ground `MilerSheet` actually paints on Home — the // **canvas**, not white. This said working, and it was wrong: // `homepage.dart` builds `MilerSheet(color: _surfaceColor)` with // `_surfaceColor = daylightSurface`. So every shot in here was // taken on a white page while the device drew a grey one, and // the flatness the screen actually had could not appear in its // own reference images. See [MilerPanel]. backgroundColor: MilerSurface.canvas, body: OfflineScope( offline: offline, child: SingleChildScrollView( child: TripCard( trip: trip, // Where the rider is standing. Home passes the live // geocoded fix; the shots pin one so the distance column // renders the same numbers on every run. riderLat: 11.0168, riderLng: 76.9558, acceptedIds: accepted, rejectedIds: rejected, selectedIds: selected, collectedIds: collected, onToggleSelect: (_) {}, onSelectAll: (_) {}, onStopTap: (_) {}, onCallStop: (_) {}, onNavigateStop: (_) {}, onAdvanceStop: (_) {}, onViewRoute: () {}, // Supplied so the card's "i" renders — without a preview // callback the control is correctly suppressed, and the // shots would show a state the app never has. onPreviewGroup: (_) {}, onContinueStop: (_) {}, ), ), ), ), ), ), ), ); await tester.pump(); for (var i = 0; i < 25; i++) { await tester.pump(const Duration(milliseconds: 80)); } // Drive the screen into the state being photographed — a tap, a scroll — // after the first settle and before the shot. if (settle != null) await settle(tester); // ── A build failure must not become a golden ── // // This was `while (tester.takeException() != null) {}` — every exception // swallowed, silently. A negative `Container.margin` (which asserts, via // `Padding`) therefore replaced a whole pickup group with a `RenderErrorBox` // and the harness cheerfully wrote it to disk as the new reference image. // A screenshot suite that cannot fail is not a test. final error = tester.takeException(); expect( error, isNull, reason: 'shots/home_$name.png would have captured a build failure', ); await expectLater( find.byType(MaterialApp), matchesGoldenFile('shots/home_$name.png'), ); } final vidhya = kitchen('K1', 'Vidhya Kitchen', [ 'Joe Mathew', 'Arun Prakash', 'Ravi Kumar', 'Priya Nair', 'Kumar S', ]); final joe = kitchen( 'K2', 'Joe Kitchen', ['Lakshmi R', 'Anitha Ramesh', 'Deepa Menon'], startAt: 10, away: 0.031, ); final third = kitchen( 'K3', 'Anna Kitchen', ['Suresh B', 'Meena T', 'Vijay K', 'Nandini P'], startAt: 20, away: 0.047, ); testWidgets('one kitchen, five orders', (t) async { await shoot(t, 'one_kitchen', stops: vidhya, height: 1800); }); // ── The brief opened ── // // It defaults closed, so every reference shot on this screen was taken with // the day's figures hidden — and the figures are most of what a rider looks // at when he opens Home, which is the state a device screenshot caught the // moment anyone tapped it. A state with no reference image is a state // nobody reviews. testWidgets('the trip brief opened', (t) async { await shoot( t, 'brief_open', stops: [...vidhya, ...joe], height: 2400, settle: (tester) async { await tester.tap(find.textContaining('stops left').first); for (var i = 0; i < 25; i++) { await tester.pump(const Duration(milliseconds: 80)); } }, ); }); testWidgets('two kitchens', (t) async { await shoot(t, 'two_kitchens', stops: [...vidhya, ...joe], height: 2400); }); testWidgets('three kitchens', (t) async { await shoot( t, 'three_kitchens', stops: [...vidhya, ...joe, ...third], height: 3000, ); }); testWidgets('accepted', (t) async { await shoot( t, 'accepted', stops: [...vidhya, ...joe], accepted: {for (final s in vidhya) s['orderid'] as String}, height: 2400, ); }); testWidgets('selected', (t) async { await shoot( t, 'selected', stops: [...vidhya, ...joe], accepted: {for (final s in vidhya) s['orderid'] as String}, selected: {for (final s in vidhya.take(3)) s['orderid'] as String}, height: 2400, ); }); testWidgets('arrived', (t) async { await shoot( t, 'arrived', stops: [ ...kitchen('K1', 'Vidhya Kitchen', [ 'Joe Mathew', 'Arun Prakash', 'Ravi Kumar', 'Priya Nair', 'Kumar S', ], status: 'arrived'), ...joe, ], accepted: {for (final s in vidhya) s['orderid'] as String}, height: 2400, ); }); testWidgets('one group picked, another active', (t) async { await shoot( t, 'group_done', stops: [...vidhya, ...joe], accepted: { for (final s in [...vidhya, ...joe]) s['orderid'] as String, }, collected: {for (final s in vidhya) s['orderid'] as String}, height: 2000, ); }); testWidgets('long names', (t) async { await shoot( t, 'long_text', stops: kitchen( 'K9', 'Sri Venkateswara Subramanian Cloud Kitchen & Catering Services', [ 'Venkataraman Subramanian Balasubramaniam', 'Chandrasekaran Muthukrishnan', 'Ravi', ], ), height: 1600, ); }); testWidgets('offline', (t) async { await shoot(t, 'offline', stops: vidhya, offline: true, height: 1800); }); testWidgets('parcel line', (t) async { await shoot( t, 'parcel', stops: vidhya, profile: ServiceProfile.parcel, height: 1800, ); }); testWidgets('text scale 1.3x', (t) async { await shoot( t, 'scale_1_3x', stops: [...vidhya, ...joe], textScale: 1.3, height: 3000, ); }); // ── The states the redesign has to survive, not just the happy one ── // // Everything above was the shape the screen was designed against. These are // the ones that break compositions: a full selection (where the bar owns the // screen and the group's journey action must stand down), a picked-out run // (nothing left to decide), and the two text-scale extremes. testWidgets('all selected', (t) async { await shoot( t, 'all_selected', stops: [...vidhya, ...joe], accepted: {for (final s in vidhya) s['orderid'] as String}, selected: {for (final s in vidhya) s['orderid'] as String}, height: 2400, ); }); testWidgets('pending, nothing decided yet', (t) async { await shoot(t, 'pending', stops: [...vidhya, ...joe], height: 2400); }); testWidgets('the whole run is collected', (t) async { await shoot( t, 'run_complete', stops: [...vidhya, ...joe], accepted: { for (final s in [...vidhya, ...joe]) s['orderid'] as String, }, collected: { for (final s in [...vidhya, ...joe]) s['orderid'] as String, }, height: 1400, ); }); testWidgets('text scale 2.0x on a 320pt phone', (t) async { await shoot( t, 'scale_2x', stops: vidhya, accepted: {for (final s in vidhya) s['orderid'] as String}, textScale: 2.0, width: 320, height: 3000, ); }); }