── The splash, in three beats ── Crimson edge to edge with the truck running across it in white; the invert; then the mark. The red starts before Flutter does. Four surfaces painted white before any Dart runs — launch_background at both API levels, windowSplashScreenBackground on Android 12+ in light and dark, and the iOS launch storyboard — and leaving any one of them white makes the launch a white flash followed by a red one. That flash is the only part of a launch a customer consciously notices. The invert is one gesture rather than a fade. A white truck on a background turning white is an invisible truck, so the ground lightens as the truck darkens, off one controller, and the screen turns itself inside out with the truck still on it. Fading it would have left the screen empty for the moment before the mark. The truck's colour is no longer its own: it is painted through srcIn, so the file's palette is discarded and only alpha survives. A replacement Lottie now needs no preparation, and tool/lottie_brand.py is off this path. `splash.json` is a seamless 3.9s loop — frame 0 is frame 60, the truck never arrives or departs — so there is no completion to hand over on. _truckBeat is a decision about how long a launch may hold somebody, not a property of the file. The comment claiming six seconds was wrong on both counts. ── Three things the splash was getting wrong quietly ── It showed the wrong logo: doormile-icon.png, the previous mark, to a customer who had just tapped the new one. tool/icons.py now cuts doormile-mark.png from the same master alpha as the launcher icon, so they cannot drift again. The fallback loader was invisible on red. _RoadLoader painted in DmColors.brand on what used to be a white screen; on crimson that is crimson on crimson, and it drew nothing at all on exactly the devices that had fallen back to it. The mark appeared and left in the same frame — _minimum was the sum of the beats exactly, so the clock ran out as the entrance finished. Hence _markHold. ── The truck was not in the middle ── Not a layout bug: both beats sit in a Center and always did. The artwork is drawn low and to the right inside its own 500x500 composition, so a centred widget rendered an off-centre picture — 30pt right, 36pt down. splash_centring_test.dart renders a frame at phone size and density, finds the ink and fails if either beat drifts. It is the only form of test that could have caught this, and the one that will catch it again when the Lottie is replaced, which is when the correction goes stale. Two things it taught: one enormous pump does not let the splash's async start-up chain advance, and capturing at pixelRatio 1 rasterizes the speed lines too faintly to detect, which truncates the bounding box and moves the measured centre by 12pt. ── The destination sheet stops resizing ── Tapping ONE TOUCH opened a tall sheet that snapped shorter a few frames later. DmAsyncList renders four skeleton rows while it loads — 302pt — and the states that replace them are nearer 200; the sheet was Flexible, so it was as tall as whichever state its content happened to be in, and the modal is still animating up while that swap happens. The list now lives in a box of one height. That also removes a second resize: the sheet grew from 48% of the screen to 74% when a state was picked. Both steps now measure 64% and it never changes size again. And DmAsyncList takes initialItems, fed by AppState.cachedCities: FutureBuilder reports `waiting` on its first build even for an already-complete future, so a warm cache still flashed a skeleton over data it already had. Three skeleton rows here rather than four — sheet_stability_test caught that 302pt overflows the smallest box the clamp can produce.
270 lines
10 KiB
Dart
270 lines
10 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:doormile_cx/ui/tokens.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));
|
|
}
|
|
}
|
|
|
|
/// Pumps until the splash's ground has left [DmColors.splash].
|
|
///
|
|
/// Returns on the first frame of the invert, so a caller can step into the
|
|
/// middle of it by a known amount. Gives up after six seconds rather than
|
|
/// spinning forever if the splash never starts.
|
|
Future<void> _pumpToInvert(WidgetTester tester) async {
|
|
for (var i = 0; i < 120; i++) {
|
|
final scaffold = tester.widget<Scaffold>(find.byType(Scaffold).first);
|
|
if (scaffold.backgroundColor != DmColors.splash) return;
|
|
await tester.pump(const Duration(milliseconds: 50));
|
|
}
|
|
fail('the splash never left crimson');
|
|
}
|
|
|
|
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-mark.png',
|
|
'assets/images/doormile-logo.png',
|
|
]) {
|
|
await precacheImage(
|
|
AssetImage(asset),
|
|
tester.element(find.byType(MaterialApp)),
|
|
);
|
|
}
|
|
});
|
|
|
|
// All three 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 any of them.
|
|
// 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. Every shot is taken relative to that, not to pumpWidget.
|
|
await tester.pump();
|
|
await tester.pump(const Duration(milliseconds: 2000));
|
|
await _shot(tester, '00-splash-truck');
|
|
|
|
// ── The next two are found, not counted to ──
|
|
//
|
|
// The splash's clock does not start at `pumpWidget`: it waits for the
|
|
// composition to parse and for the engine to report a rasterized frame,
|
|
// and how long that takes depends on what else is in the tree. Two
|
|
// attempts at counting milliseconds from here photographed the invert
|
|
// 25ms in, and then the login screen. So this watches for the beat
|
|
// instead — the ground leaving crimson is unambiguous and needs no
|
|
// estimate of anything.
|
|
await _pumpToInvert(tester);
|
|
await tester.pump(const Duration(milliseconds: 160));
|
|
await _shot(tester, '00b-splash-invert');
|
|
|
|
// Past the invert, and far enough into the mark's entrance to photograph
|
|
// the mark rather than its fade. `_markHold` is what leaves room for this.
|
|
await tester.pump(const Duration(milliseconds: 320));
|
|
await tester.pump(const Duration(milliseconds: 620));
|
|
await _shot(tester, '00c-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));
|
|
});
|
|
}
|