Files
doormile_milderapp/test/home_shots_test.dart
Thiru-tenext d7348e253f Miler rider app: surface system, visible design language, backend lifecycle
Design system
- MilerSurface ladder (canvas → working → raised → floating) with MilerPanel
  as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1.
- Visible vocabulary applied across Home, Deliveries, Activity, Account and
  the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x),
  canvas wells for anything that opens, small filled tags for shelf labels,
  demoted placeholders. Recorded in DESIGN_SYSTEM.md §6.
- One icon family: 222 Material glyphs migrated to Lucide; none left outside
  lib/xpress.
- Colour semantics corrected: amber only for what is genuinely owed, brand red
  reserved for the live stop, disabled primaries go neutral rather than pale.

Data and lifecycle
- lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart
  makes admin sequence the single ordering authority; service_day.dart, and
  stop_area.dart rewritten against live Coimbatore addresses (digit-token
  stripping, city stoplist, street suffixes, stammer collapse).
- countLabel states the load once, in bags.

Testing
- 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity,
  sheets and verify, with test/failures/ now gitignored (diff debris).
- New pins: home_gutter_test, stop_area_test, plus updated structural bounds.

Note: this commit also carries pre-existing working-tree deletions that were
present before this work (API_SPEC.md, README.md, demo test fixtures).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 05:40:35 +05:30

438 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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<String, dynamic> 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<Map<String, dynamic>> kitchen(
String id,
String name,
List<String> 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<void> shoot(
WidgetTester tester,
String name, {
required List<Map<String, dynamic>> stops,
Set<String> accepted = const {},
Set<String> rejected = const {},
Set<String> selected = const {},
Set<String> collected = const {},
ServiceProfile profile = ServiceProfile.milkMan,
double height = 1400,
double width = 390,
double textScale = 1.0,
bool offline = false,
Future<void> 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,
);
});
}