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>
This commit is contained in:
2026-08-22 05:40:35 +05:30
parent c8350563d9
commit d7348e253f
387 changed files with 80693 additions and 12272 deletions

437
test/home_shots_test.dart Normal file
View File

@@ -0,0 +1,437 @@
@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,
);
});
}