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
237 lines
9.2 KiB
Dart
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));
|
|
});
|
|
}
|