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>
438 lines
14 KiB
Dart
438 lines
14 KiB
Dart
@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,
|
||
);
|
||
});
|
||
}
|