A launch that opens in brand, and a sheet that stops resizing
── 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.
@@ -6,6 +6,7 @@ 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';
|
||||
@@ -56,6 +57,20 @@ Future<void> _settle(WidgetTester tester, [int ms = 800]) async {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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),
|
||||
@@ -95,7 +110,7 @@ void main() {
|
||||
// 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-mark.png',
|
||||
'assets/images/doormile-logo.png',
|
||||
]) {
|
||||
await precacheImage(
|
||||
@@ -105,20 +120,34 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
// Both beats, with raw pumps: `_settle` always runs four extra 400ms
|
||||
// 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 either one.
|
||||
// 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. Both shots are taken relative to that, not to pumpWidget.
|
||||
// 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-animation');
|
||||
await _shot(tester, '00-splash-truck');
|
||||
|
||||
// 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 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);
|
||||
|
||||
140
test/sheet_stability_test.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:doormile_cx/ui/widgets/states.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// A SHEET DOES NOT CHANGE SIZE WHILE IT IS OPENING
|
||||
///
|
||||
/// Tapping ONE TOUCH opened a tall sheet that snapped shorter a few frames
|
||||
/// later. Nothing was wrong with either height — the sheet was simply as tall
|
||||
/// as whatever [DmAsyncList] happened to be rendering, and that changes twice
|
||||
/// before the customer has done anything: four skeleton rows at 302pt, then
|
||||
/// the states, which are nearer 200.
|
||||
///
|
||||
/// The modal is still animating up while that swap happens, so the resize is
|
||||
/// not a subtle reflow. It is the first thing the customer sees.
|
||||
///
|
||||
/// Both halves of the fix are held here: the box the list lives in keeps one
|
||||
/// height across every state the list can be in, and a list handed cached
|
||||
/// items renders them on the first frame instead of a skeleton over data it
|
||||
/// already has.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
Widget host(Widget child) => MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: SizedBox(width: 360, child: child),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// A load that never finishes, so the widget stays in its loading state.
|
||||
Future<List<String>> never({bool refresh = false}) =>
|
||||
Completer<List<String>>().future;
|
||||
|
||||
group('a fixed box holds its height', () {
|
||||
testWidgets('loading and loaded measure the same', (tester) async {
|
||||
const box = 300.0;
|
||||
|
||||
await tester.pumpWidget(
|
||||
host(
|
||||
SizedBox(
|
||||
height: box,
|
||||
child: DmAsyncList<String>(
|
||||
skeletonRows: 3,
|
||||
load: never,
|
||||
emptyTitle: 'none',
|
||||
emptyMessage: 'none',
|
||||
builder: (_, items) => Column(children: [for (final i in items) Text(i)]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
final loading = tester.getSize(find.byType(DmAsyncList<String>));
|
||||
|
||||
await tester.pumpWidget(
|
||||
host(
|
||||
SizedBox(
|
||||
height: box,
|
||||
child: DmAsyncList<String>(
|
||||
skeletonRows: 3,
|
||||
initialItems: const ['Tamil Nadu'],
|
||||
load: never,
|
||||
emptyTitle: 'none',
|
||||
emptyMessage: 'none',
|
||||
builder: (_, items) => Column(children: [for (final i in items) Text(i)]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
final loaded = tester.getSize(find.byType(DmAsyncList<String>));
|
||||
|
||||
expect(loaded, loading, reason: 'the sheet would resize as it opened');
|
||||
expect(loading.height, box);
|
||||
});
|
||||
|
||||
testWidgets('three skeleton rows fit the smallest box the clamp allows', (
|
||||
tester,
|
||||
) async {
|
||||
// The destination sheet floors its list box at 240. Four rows are 302 and
|
||||
// overflowed it, which is why that sheet asks for three.
|
||||
await tester.pumpWidget(
|
||||
host(const SizedBox(height: 240, child: DmSkeleton(rows: 3))),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('cached items skip the skeleton', () {
|
||||
testWidgets('the first frame is the content, not a shimmer', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
host(
|
||||
SizedBox(
|
||||
height: 300,
|
||||
child: DmAsyncList<String>(
|
||||
initialItems: const ['Kerala'],
|
||||
load: never,
|
||||
emptyTitle: 'none',
|
||||
emptyMessage: 'none',
|
||||
builder: (_, items) =>
|
||||
Column(children: [for (final i in items) Text(i)]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
// No settle, no extra pump: this is the frame the sheet opens on.
|
||||
expect(find.text('Kerala'), findsOneWidget);
|
||||
expect(find.byType(DmSkeleton), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('without them it still loads honestly', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
host(
|
||||
SizedBox(
|
||||
height: 300,
|
||||
child: DmAsyncList<String>(
|
||||
// Three for the same reason the destination sheet asks for
|
||||
// three: the default of four is 302pt and does not fit here.
|
||||
skeletonRows: 3,
|
||||
load: never,
|
||||
emptyTitle: 'none',
|
||||
emptyMessage: 'none',
|
||||
builder: (_, items) =>
|
||||
Column(children: [for (final i in items) Text(i)]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.byType(DmSkeleton), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
Before Width: | Height: | Size: 27 KiB |
BIN
test/snapshots/00-splash-truck.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
test/snapshots/00b-splash-invert.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 54 KiB |
BIN
test/snapshots/00c-splash-mark.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 248 KiB After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 119 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 186 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 185 KiB After Width: | Height: | Size: 185 KiB |
174
test/splash_centring_test.dart
Normal file
@@ -0,0 +1,174 @@
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:doormile_cx/ui/screens/splash_screen.dart';
|
||||
import 'package:doormile_cx/ui/tokens.dart';
|
||||
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
/// THE SPLASH IS CENTRED
|
||||
///
|
||||
/// ── Why this reads pixels instead of checking the layout ──
|
||||
///
|
||||
/// The layout was never the problem. Both beats sit in a `Center`, and the
|
||||
/// widget tree was correct the whole time the truck was visibly 30pt right and
|
||||
/// 36pt low: `splash.json` is a 500x500 composition with the truck drawn low
|
||||
/// and to one side inside it, so a perfectly centred widget renders an
|
||||
/// off-centre picture. Nothing about the tree can catch that.
|
||||
///
|
||||
/// So this renders a frame and looks for the ink. It is the only form of the
|
||||
/// test that would have failed before the fix and passes after it — and the
|
||||
/// only one that will still mean something if somebody drops in a different
|
||||
/// Lottie, which is exactly when `SplashScreen._truckNudge` goes stale.
|
||||
/// ─────────────────────────────────────────────────────────────────────────
|
||||
void main() {
|
||||
const key = ValueKey('splash-frame');
|
||||
|
||||
/// How far the drawn content sits from the middle of the screen.
|
||||
///
|
||||
/// Finds the bounding box of everything that is not the background colour —
|
||||
/// the truck on the first beat, the mark on the last — and returns its
|
||||
/// centre relative to the frame's. Zero is centred.
|
||||
///
|
||||
/// The frame's own dimensions come from the captured image rather than from
|
||||
/// the view: they are the same number until they are not, and a one-row
|
||||
/// disagreement between them is an index past the end of the buffer.
|
||||
Future<Offset> inkOffset(WidgetTester tester) async {
|
||||
late Uint8List pixels;
|
||||
late int w;
|
||||
late int h;
|
||||
await tester.runAsync(() async {
|
||||
final boundary =
|
||||
tester.renderObject<RenderRepaintBoundary>(find.byKey(key));
|
||||
// At the phone's own density. The default pixelRatio of 1 rasterizes
|
||||
// the thin speed lines so faintly that the threshold below misses them,
|
||||
// which truncates the bounding box and moves the measured centre — the
|
||||
// first calibration of the nudge was made against exactly that error.
|
||||
final image = await boundary.toImage(pixelRatio: 3);
|
||||
w = image.width;
|
||||
h = image.height;
|
||||
final data = await image.toByteData(format: ui.ImageByteFormat.rawRgba);
|
||||
pixels = data!.buffer.asUint8List();
|
||||
image.dispose();
|
||||
});
|
||||
|
||||
int at(int x, int y, int c) => pixels[(y * w + x) * 4 + c];
|
||||
|
||||
// The ground is whatever the corner is, so this works on either beat
|
||||
// without being told which one it is looking at.
|
||||
final bg = [at(2, 2, 0), at(2, 2, 1), at(2, 2, 2)];
|
||||
|
||||
int? minX, maxX, minY, maxY;
|
||||
for (var y = 0; y < h; y++) {
|
||||
for (var x = 0; x < w; x++) {
|
||||
final delta = (at(x, y, 0) - bg[0]).abs() +
|
||||
(at(x, y, 1) - bg[1]).abs() +
|
||||
(at(x, y, 2) - bg[2]).abs();
|
||||
if (delta < 40) continue;
|
||||
minX = minX == null || x < minX ? x : minX;
|
||||
maxX = maxX == null || x > maxX ? x : maxX;
|
||||
minY = minY == null || y < minY ? y : minY;
|
||||
maxY = maxY == null || y > maxY ? y : maxY;
|
||||
}
|
||||
}
|
||||
if (minX == null) fail('nothing drawn on the splash');
|
||||
|
||||
// Back to logical points, so the tolerance means the same thing here as
|
||||
// it does in the widget code.
|
||||
return Offset(
|
||||
((minX + maxX!) / 2 - w / 2) / 3,
|
||||
((minY! + maxY!) / 2 - h / 2) / 3,
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders at the size the app actually ships at.
|
||||
///
|
||||
/// The default 800x600 test window is wider and much shorter than a phone,
|
||||
/// and the truck is drawn 704pt tall once it is scaled — so on the default
|
||||
/// surface it overflows top and bottom, the capture clips it, and the
|
||||
/// measured centre is of a truncated shape. Measuring the real thing means
|
||||
/// rendering the real shape.
|
||||
Future<void> pumpSplash(WidgetTester tester) async {
|
||||
tester.view.devicePixelRatio = 3;
|
||||
tester.view.physicalSize = const Size(1170, 2532);
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: RepaintBoundary(
|
||||
key: key,
|
||||
child: SplashScreen(ready: true, onDone: () {}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// An `Image.asset` never decodes under the test binding unless the decode
|
||||
// is given a real async turn, and an undecoded image draws nothing — which
|
||||
// is indistinguishable from a mark that is missing, and was read as one.
|
||||
await tester.runAsync(() async {
|
||||
await precacheImage(
|
||||
const AssetImage('assets/images/doormile-mark.png'),
|
||||
tester.element(find.byType(MaterialApp)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Advances the clock in frames rather than in one jump.
|
||||
///
|
||||
/// The splash does not start its beats until it has walked an async chain —
|
||||
/// the composition parses, the engine reports a rasterized frame, a short
|
||||
/// delay covers the system splash's own exit. Each `await` in that chain
|
||||
/// needs the event loop to turn, and one enormous `pump` turns it once. Two
|
||||
/// of these tests were pumping four seconds in two calls and finding the
|
||||
/// screen exactly where it started.
|
||||
Future<void> pumpFor(WidgetTester tester, int ms) async {
|
||||
const step = Duration(milliseconds: 100);
|
||||
for (var t = 0; t < ms; t += 100) {
|
||||
await tester.pump(step);
|
||||
}
|
||||
}
|
||||
|
||||
/// Generous, because the truck's own ink is not symmetrical — the speed
|
||||
/// lines run off one side — and this is guarding against 30pt, not 3.
|
||||
const tolerance = 12.0;
|
||||
|
||||
testWidgets('the truck is in the middle of the screen', (tester) async {
|
||||
await pumpSplash(tester);
|
||||
|
||||
// Into the first beat: the composition has parsed and the truck is up,
|
||||
// and the invert has not started.
|
||||
await pumpFor(tester, 2500);
|
||||
|
||||
final off = await inkOffset(tester);
|
||||
expect(off.dx.abs(), lessThan(tolerance), reason: 'truck is $off off');
|
||||
expect(off.dy.abs(), lessThan(tolerance), reason: 'truck is $off off');
|
||||
});
|
||||
|
||||
testWidgets('and so is the mark', (tester) async {
|
||||
await pumpSplash(tester);
|
||||
|
||||
// Past the truck beat and the invert, with the entrance settled — and
|
||||
// before `_markHold` runs out and the splash hands over.
|
||||
await pumpFor(tester, 4300);
|
||||
|
||||
final off = await inkOffset(tester);
|
||||
expect(off.dx.abs(), lessThan(tolerance), reason: 'mark is $off off');
|
||||
expect(off.dy.abs(), lessThan(tolerance), reason: 'mark is $off off');
|
||||
});
|
||||
|
||||
testWidgets('the ground inverts, the ink inverts with it', (tester) async {
|
||||
await pumpSplash(tester);
|
||||
await pumpFor(tester, 2500);
|
||||
|
||||
Color ground() =>
|
||||
tester.widget<Scaffold>(find.byType(Scaffold)).backgroundColor!;
|
||||
|
||||
expect(ground(), DmColors.splash, reason: 'beat 1 is crimson');
|
||||
|
||||
await pumpFor(tester, 1800);
|
||||
expect(ground(), DmColors.surface, reason: 'beat 3 is white');
|
||||
});
|
||||
}
|
||||