Files
doormile_customer_app/test/design_snapshot_test.dart
Thiru-tenext 86b6af48c2 Redesign on the Stitch reference, in Plus Jakarta Sans
Four passes, and the shape they landed on.

The type face is Plus Jakarta Sans (variable, wght 200-800), which brings a
fix with it: it carries the rupee glyph and Switzer does not, so prices stop
being set in Geist Mono to work around a missing character. Mono stays where
it is earned - references and phone numbers, read digit by digit.

Surfaces lift rather than outline. Cards carry two very soft shadow layers
instead of a hairline, because eight outlined boxes down a screen read as a
wireframe. The tab bar floats as a pill again for the same reason it was
right to: it is now the same kind of object as everything above it.

Screens:

* Home is the greeting, the address, the sphere and one card. The card lost
  its progress bar - a filling line says "wait", and a parcel two days into
  a journey is not something anyone is waiting through - and gained the size
  that buys.
* Orders cards are four bands: identity, destination, route, and whatever is
  happening right now. Plus a search field, because the list is the archive.
* Tracking leads with the state at display size, then TRIP MILESTONES with a
  step counter, then the courier.
* Review is a route thread over two particular cards.
* Account opens on the person: avatar, name, and two counted figures.

Three real bugs the redesign surfaced:

* Quick dispatch handed `loadCities()` straight to a FutureBuilder, so the
  catalogue was refetched on every rebuild and Home never settled.
* Order cards showed the whole visit's weight on one destination's row -
  somebody else's parcel. Per group now, and only once actually weighed.
* The pickup window was printed beside "In transit", where it reads as a
  delivery time nobody promised.

Nothing invented. The reference shows EXPRESS PRIORITY, CARBON OFFSET,
CONCIERGE ELITE and hub-to-hub routing; this backend sends none of them, so
they are absent rather than mocked up.

flutter analyze: clean. flutter test: 88 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
2026-09-24 11:08:25 +05:30

237 lines
9.2 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: state, district, window, then one screen.
await tester.tap(find.text('BOOK NOW'));
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');
// 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);
// 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('ORIGIN'));
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));
});
}