Two ways to book the same parcel, and the difference said where the choice is made rather than in a paragraph about it. ONE TOUCH is the sphere: a state, a district, a window, and nothing else. The door, the recipient and the weight go out omitted, which the contract already treats as "not added yet" - the Miler writes them down standing at the address with the parcel in hand, which is the only moment anybody knows them. The pick/drop form under it is the other way in. PICKUP already carries the detected address; DROP is the only row with a question on it, and it asks for the full door - once per destination - before the window. Captions rather than an explainer: "Just a city and a time" under the sphere, "Enter the full address yourself" on the row. The pickup screen is a map screen now instead of a screen with a map on it. Full-bleed basemap, pin nailed to the centre, floating search and back, and a fixed sheet carrying what the pin resolved to. The collect-from sheet can hand off to it and carry the pin back. Also: * Home reads what-is-running, then the one gesture, then the form. The live card lost its courier row and half its height. * The sphere turns inside out on press - white floods from the centre and the word inverts through the clip, not by tween - and bounces back on release. * Order IDs copy, on Orders and on tracking. * Tracking says PICKUP and DROP with an arrow between them, carries the reference at the foot of the card it identifies, and shows every timing exactly once. * The wordmark bar is gone from all three roots; Orders and Account had no SafeArea under it, so their titles were sitting under the notch. flutter analyze: clean. flutter test: 90 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
241 lines
9.1 KiB
Dart
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('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));
|
|
});
|
|
}
|