Files
doormile_customer_app/test/design_snapshot_test.dart
Thiru-tenext 49b0de0d8f Pages fall away downward, the pickup comes back, and a real icon set
── Navigation ──

Pages arrived from the right and left to the right. Right is the direction
the system back gesture drags, so a page leaving under a button press read as
a swipe nobody made — and every push in this app is a layer over the one
before it, which arrives from below. The axis is vertical now: the incoming
page rises and covers, the page underneath stays put and dims, and back drops
the top page off the bottom. Full travel rather than the old 14% nudge, which
is what makes the direction nameable. test/page_transition_test.dart holds it.

── Home ──

The pickup pill is back under the greeting. It was cut on the argument that
the form below already carried the pickup; the form is four hundred points
down the screen, and a location you can only see by scrolling to a form is a
location you do not trust. It shows the locality while the form keeps the
door, so the screen answers one question once, and it now has a resolving
state — it used to print the last place you were, confidently, while the fix
was still in flight.

The sphere's caption was falling off the screen. Adding the pill pushed it out
of the scroll viewport, silently, with no overflow warning — the fourth time
Home growing has clipped something under the sphere. DmBookOrb.field is no
longer a constant: the glow gives way to whatever is left, the sphere never
does.

── 74 points of dead canvas, on every root ──

Home, Orders and Account each cleared the floating tab bar by
`MediaQuery.paddingOf(context).bottom + 74`, on the reasoning that the inset is
the home indicator and 74 is the bar above it. Scaffold with extendBody
replaces the body's bottom padding with the bar's whole laid-out height —
measured, a 34pt inset and this bar hand the body 106. They were clearing the
bar, then clearing it again. One helper now, dmTabBarClear().

── One journey, one vocabulary ──

Review said ORIGIN/DESTINATION; Home's form and tracking say PICKUP/DROP.
Three screens a customer walks in order. The tracking card had already written
down the argument; the booking screen hadn't followed.

The rail's first milestone read "Booking created" directly above "Order
created" — down the rail: created, created, in transit. It is "Pickup booked",
which is what JourneyStage.milestoneLabel has called that stage all along on
Orders and on Home's live row.

── The launcher icon ──

tool/icons.py renders all twenty-eight files from one master, because the
failure mode of exporting by hand is nineteen replaced and nine left on the
old mark, at the density nobody checks.

The master is a bare mark on a field, so it cannot be dropped into Android's
adaptive canvas whole: crimson is the background, the white mark is the
foreground, and it is sized by its reach from the canvas centre so a round
mask cannot take the arrow tip. Measured on the built file: 63.7dp against a
66dp safe zone. A real monochrome layer replaces the colour foreground that
was standing in for one — the system tints by alpha, so that themed as a blob.
ic_launcher_round.png exists at last; minSdk is 24 and android:roundIcon had
nothing to resolve to below API 26.

The artwork is flattened (the source vignettes over ten values of red) and its
horizontal offset is placed deliberately rather than inherited — geometric
centring made the mark lean, because the arrow carries the bounding box right
while contributing almost none of the ink.

── Bundle ──

pubspec declared assets/images/ as a folder, which shipped the 974 KB icon
master to every customer for a file no code opens. Named explicitly now.
2026-09-25 13:34:52 +05:30

241 lines
9.1 KiB
Dart

@Tags(['snapshot'])
library;
import 'package:doormile_cx/data/dev_doormile_api.dart';
import 'package:doormile_cx/data/doormile_api.dart';
import 'package:doormile_cx/data/location_service.dart';
import 'package:doormile_cx/main.dart';
import 'package:doormile_cx/state/app_state.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
/// Renders each screen at phone size and writes it to `test/snapshots/`.
///
/// This is a **design** check, not a regression check. A deliberate redesign
/// would fail every one of these, which is worthless as a test signal, so the
/// `snapshot` tag is skipped in a normal run (see `dart_test.yaml`) and the
/// pictures are rendered on demand:
///
/// flutter test test/design_snapshot_test.dart --run-skipped --update-goldens
///
/// It exists because the only honest way to review a visual rebuild is to look
/// at it, and this produces the same pictures on any machine, with the real
/// fonts, without a device attached.
///
/// The real fonts are loaded first — without that, Flutter's test harness draws
/// everything in Ahem's black boxes and the pictures are worthless.
Future<void> _loadFonts() async {
for (final (family, asset) in const [
('PlusJakartaSans', 'assets/fonts/PlusJakartaSans-Variable.ttf'),
('Switzer', 'assets/fonts/Switzer-Variable.ttf'),
('GeistMono', 'assets/fonts/GeistMono-VF.ttf'),
// The icon font too, or every glyph in the picture is a hollow box.
('packages/lucide_icons_flutter/Lucide',
'packages/lucide_icons_flutter/assets/lucide.ttf'),
]) {
final loader = FontLoader(family)..addFont(rootBundle.load(asset));
await loader.load();
}
}
/// Runs the clock forward until a screen has stopped moving.
///
/// Four pumps, not two. An entrance animation is started by a zero-delay
/// `Future.delayed` inside `flutter_animate`, and under the test binding's fake
/// clock that timer only fires on the pump *after* the widget was built — so
/// the controller begins life part-way through the first elapse and a two-pump
/// settle photographs the screen mid-fade. Everything here is under a second,
/// so the extra frames cost nothing and the pictures are of settled screens.
Future<void> _settle(WidgetTester tester, [int ms = 800]) async {
await tester.pump();
await tester.pump(Duration(milliseconds: ms));
for (var i = 0; i < 4; i++) {
await tester.pump(const Duration(milliseconds: 400));
}
}
Future<void> _shot(WidgetTester tester, String name) async {
await expectLater(
find.byType(MaterialApp),
matchesGoldenFile('snapshots/$name.png'),
);
}
void main() {
late DevDoormileApi api;
setUp(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('plugins.flutter.io/path_provider'),
(call) async => '/tmp',
);
api = DevDoormileApi();
DoormileApi.overrideInstance(api);
LocationService.instance = FixedLocationService();
});
tearDown(() => DoormileApi.overrideInstance(null));
testWidgets('screens', (tester) async {
await _loadFonts();
// One phone, portrait: 390x844 at 3x, the frame the design was drawn in.
tester.view.physicalSize = const Size(1170, 2532);
tester.view.devicePixelRatio = 3.0;
addTearDown(tester.view.reset);
final state = AppState();
await tester.pumpWidget(DoormileApp(initialState: state));
// Image.asset resolves off the frame loop, so in a test it never paints
// unless it is decoded first — without this the mark and the wordmark are
// blank gaps in every picture of the entrance.
await tester.runAsync(() async {
for (final asset in const [
'assets/images/doormile-icon.png',
'assets/images/doormile-logo.png',
]) {
await precacheImage(
AssetImage(asset),
tester.element(find.byType(MaterialApp)),
);
}
});
// Both beats, with raw pumps: `_settle` always runs four extra 400ms
// frames at the end, which is 2.5s — past the moment the splash hands
// over, so it would photograph the login screen instead of either one.
// The clock does not start until the engine says it has rasterized a
// frame and the composition has parsed, which under the test binding is
// about 1.2s. Both shots are taken relative to that, not to pumpWidget.
await tester.pump();
await tester.pump(const Duration(milliseconds: 2000));
await _shot(tester, '00-splash-animation');
// Past the handover, plus a frame for the switch to settle.
await tester.pump(const Duration(milliseconds: 1200));
await tester.pump(const Duration(milliseconds: 400));
await _shot(tester, '00b-splash-mark');
// The splash holds for its own minimum — and the waits in front of it — before handing over.
await _settle(tester, 4400);
await _shot(tester, '01-sign-in');
await tester.tap(find.text('Create account'));
await _settle(tester);
await _shot(tester, '01b-sign-up');
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
await _settle(tester);
await tester.enterText(find.byType(TextField).first, '9876543210');
await _settle(tester, 200);
await tester.tap(find.text('Continue'));
await _settle(tester);
await _shot(tester, '02-verify');
await tester.enterText(find.byType(TextField).first, '1111');
await _settle(tester);
await _shot(tester, '03-home');
// The pickup address is a live value in the header, so the search that
// changes it is reachable from Home rather than only from the booking.
await tester.tap(find.text('38 Mettupalayam Road'));
await _settle(tester);
await _shot(tester, '03b-pickup-search');
await tester.tapAt(const Offset(200, 40)); // through the scrim
await _settle(tester);
await tester.tap(find.bySemanticsLabel('Orders'));
await _settle(tester);
await _shot(tester, '04-orders');
await tester.tap(find.bySemanticsLabel('Account'));
await _settle(tester);
await _shot(tester, '05-account');
await tester.tap(find.bySemanticsLabel('Home'));
await _settle(tester);
// The live card, by its reference. Home's quick-dispatch chips are
// named after cities too, so a bare 'Chennai' finder now hits a
// shortcut that starts a new booking instead.
await tester.tap(find.text('DM-482913'));
await _settle(tester);
await _shot(tester, '06-tracking');
// `ensureVisible`, not `dragUntilVisible`.
//
// `IndexedStack` keeps all three tab roots alive under the pushed route,
// so a positional `Scrollable` finder picks Home's list — which the
// tracking screen is covering. `ensureVisible` finds the target's own
// scrollable ancestor and needs no finder at all.
await tester.ensureVisible(find.text('Cancel this pickup'));
await tester.pump();
await tester.tap(find.text('Cancel this pickup'));
await _settle(tester);
await _shot(tester, '06b-cancel-sheet');
await tester.tapAt(const Offset(200, 40));
await _settle(tester);
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
await _settle(tester);
// Booking: BOOK asks where, then the review screen asks the rest.
await tester.tap(find.text('ONE TOUCH'));
await _settle(tester);
await _shot(tester, '07-destination-states');
await tester.tap(find.text('Tamil Nadu'));
await _settle(tester);
await _shot(tester, '07b-destination-districts');
await tester.tap(
find
.descendant(
of: find.byType(BottomSheet),
matching: find.text('Chennai'),
)
.first,
);
await _settle(tester);
// The dev backend allows several destinations, so the row ticked rather
// than closing the sheet — the choice is confirmed.
await tester.tap(find.textContaining('Send to ').first);
await _settle(tester);
// The window sheet follows the destination on its own: the customer
// chooses their slot, it is never chosen for them.
await _shot(tester, '09-window-sheet');
await tester.tap(find.textContaining('Use ').first);
await _settle(tester);
await _shot(tester, '08-send');
// The map editor behind the FROM row: the one screen in the flow whose
// subject is a picture.
await tester.tap(find.text('PICKUP'));
await _settle(tester);
await _shot(tester, '10-pickup-map');
await tester.tap(find.byIcon(LucideIcons.arrowLeft).first);
await _settle(tester);
await tester.tap(find.text('Confirm Booking'));
await _settle(tester);
await _shot(tester, '11-booked');
// A finished order, for the receipt treatment.
await tester.tap(find.text('Back to home'));
await _settle(tester);
await tester.tap(find.bySemanticsLabel('Orders'));
await _settle(tester);
await tester.tap(find.textContaining('Past'));
await _settle(tester);
await _shot(tester, '04b-orders-completed');
await tester.tap(find.text('Salem'));
await _settle(tester);
await _shot(tester, '12-receipt');
await tester.pump(const Duration(seconds: 3));
});
}