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.
This commit is contained in:
2026-09-28 12:54:03 +05:30
parent 49b0de0d8f
commit 8427824951
41 changed files with 772 additions and 132 deletions

View File

@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Shown from process start until the first Flutter frame.
Deliberately empty. It used to centre a mark on it, which meant the
customer saw a logo, then Flutter's own splash animation, then the logo
again — the same image twice with a jump between them. The window
background is now the surface the Flutter splash opens on, so the handover
is a single continuous screen. -->
Deliberately empty, and deliberately crimson. Empty because it used to
centre a mark, which meant the customer saw a logo, then Flutter's splash,
then the logo again. Crimson because the Flutter splash now opens on
crimson: paint this white and the launch is a white flash followed by a
red one, which is the seam this file exists to remove. -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/dm_surface" />
<item android:drawable="@color/dm_splash_bg" />
</layer-list>

View File

@@ -1,11 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Shown from process start until the first Flutter frame.
Deliberately empty. It used to centre a mark on it, which meant the
customer saw a logo, then Flutter's own splash animation, then the logo
again — the same image twice with a jump between them. The window
background is now the surface the Flutter splash opens on, so the handover
is a single continuous screen. -->
Deliberately empty, and deliberately crimson. Empty because it used to
centre a mark, which meant the customer saw a logo, then Flutter's splash,
then the logo again. Crimson because the Flutter splash now opens on
crimson: paint this white and the launch is a white flash followed by a
red one, which is the seam this file exists to remove. -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/dm_surface" />
<item android:drawable="@color/dm_splash_bg" />
</layer-list>

View File

@@ -3,13 +3,13 @@
<!-- Android 12+ uses the platform splash screen API, and it cannot be
switched off: leave the icon unset and the platform draws the launcher
icon instead. So it is set to a fully transparent one, over the same
white the Flutter splash opens on, which makes the system beat a blank
white screen indistinguishable from the frame after it.
crimson the Flutter splash opens on, which makes the system beat a
blank red screen indistinguishable from the frame after it.
The customer sees two screens, not three: the truck, then the mark. -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowSplashScreenBackground">@color/dm_surface</item>
<item name="android:windowSplashScreenBackground">@color/dm_splash_bg</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/dm_splash_blank</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">

View File

@@ -9,7 +9,7 @@
The customer sees two screens, not three: the truck, then the mark. -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">@drawable/launch_background</item>
<item name="android:windowSplashScreenBackground">@color/dm_surface</item>
<item name="android:windowSplashScreenBackground">@color/dm_splash_bg</item>
<item name="android:windowSplashScreenAnimatedIcon">@drawable/dm_splash_blank</item>
</style>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">

View File

@@ -7,4 +7,11 @@
tool/icons.py, not typed here: if the logo is redrawn in a
different crimson, the adaptive background follows it. -->
<color name="dm_icon_bg">#BD0921</color>
<!-- What the window is painted while the process starts, and what the
Flutter splash opens on. The same crimson as the launcher icon, on
purpose: the customer taps a red icon and the screen fills with that
red, which reads as one gesture rather than as an app loading. It
hands over to white before any UI appears, so this and dm_brand never
sit side by side. -->
<color name="dm_splash_bg">#BD0921</color>
</resources>

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 185 KiB

View File

@@ -21,8 +21,9 @@ addresses and references are invented.
| File | Screen | |
| --- | --- | --- |
| [`00-splash-animation.png`](00-splash-animation.png) | **Splash · first beat** | What the app does: a parcel on the road. |
| [`00b-splash-mark.png`](00b-splash-mark.png) | **Splash · second beat** | Whose app it is. Caught mid-cross-fade. |
| [`00-splash-truck.png`](00-splash-truck.png) | **Splash · first beat** | What the app does, in white on the brand crimson the launcher icon opens from. |
| [`00b-splash-invert.png`](00b-splash-invert.png) | **Splash · second beat** | The invert, caught halfway: the ground lightening as the truck darkens, both on one curve. |
| [`00c-splash-mark.png`](00c-splash-mark.png) | **Splash · third beat** | Whose app it is. The same mark the launcher icon is cut from. |
| [`01-sign-in.png`](01-sign-in.png) | **Sign in** | One question, one field, one button. |
| [`02-sign-up.png`](02-sign-up.png) | **Create account** | The same shell, three fields. |
| [`03-verify-code.png`](03-verify-code.png) | **Verify the code** | One field behind four boxes that only draw. |

View File

@@ -19,7 +19,12 @@
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<!-- #BD0921, the same crimson as the launcher icon and as
Android's launch_background. iOS shows this view from
the moment the app is tapped until Flutter's first
frame; leave it white and the launch is a white flash
followed by a red one. -->
<color key="backgroundColor" red="0.74117647058823533" green="0.035294117647058823" blue="0.12941176470588237" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>

View File

@@ -782,6 +782,27 @@ class AppState extends ChangeNotifier {
/// The districts come back in parallel — five states is five calls, not a
/// waterfall — and each city carries its state so [selectCity] can set both
/// codes the booking contract needs.
/// The cities already in hand, or null when any part still has to be fetched.
///
/// The synchronous half of [loadCities], for a caller that would otherwise
/// render a loading state over data it already has. Null unless *every*
/// state's districts are cached, because a partial answer shown as a
/// complete one is a list of cities with some quietly missing.
List<CityOption>? get cachedCities {
final states = statesCache;
if (states == null) return null;
final out = <CityOption>[];
for (final state in states) {
final districts = districtCache[state.code];
if (districts == null) return null;
for (final district in districts) {
out.add(CityOption(state: state, district: district));
}
}
return out;
}
Future<List<CityOption>> loadCities({bool refresh = false}) async {
if (refresh) {
statesCache = null;

View File

@@ -197,10 +197,35 @@ class _DestinationSheetState extends State<_DestinationSheet> {
onChanged: (v) => setState(() => _q = v.trim().toLowerCase()),
),
const SizedBox(height: 6),
Flexible(
// ── One height, whatever is inside it ──
//
// This was `Flexible`, so the sheet was as tall as its content — and
// its content changes height twice before the customer has done
// anything. `DmAsyncList` renders four skeleton rows while it loads,
// which is 302pt; the states that replace them are nearer 200. The
// modal is still animating up while that swap happens, so tapping ONE
// TOUCH opened a tall sheet that snapped shorter a few frames later.
//
// It also grew again — 48% of the screen for the states, 74% for the
// districts — so browsing resized the surface under the customer's
// thumb.
//
// A fixed box fixes both. The sheet rises once, at one height, and
// loading, states, districts, empty and error all happen inside it.
// Sized against the screen rather than fixed in points, and floored
// above the skeleton's own height so it can never be the thing that
// decides how tall this is.
SizedBox(
height: (MediaQuery.sizeOf(context).height * 0.44).clamp(240.0, 420.0),
child: DmAsyncList<CityOption>(
key: const ValueKey('destinationList'),
skeletonRows: 4,
// Three, not four: 3 x 68 plus gaps is 224, which fits inside the
// smallest box the clamp above can produce. Four did not.
skeletonRows: 3,
// Skips the skeleton entirely when the cities are already known,
// which after the first open they always are. A shimmer that
// appears and vanishes inside one frame is noise.
initialItems: app.cachedCities,
load: ({bool refresh = false}) => app.loadCities(refresh: refresh),
emptyIcon: LucideIcons.mapPinOff,
emptyTitle: 'No cities open yet',

View File

@@ -33,26 +33,60 @@ import '../tokens.dart';
/// logo forever, and `_Launch` shows the login screen, which is the right place
/// for somebody whose session could not be read.
///
/// ── Two beats, in this order ──
/// ── Three beats, in this order ──
///
/// The animation first, alone, and the mark after it. It was the other way
/// round — mark, then the animation appearing underneath it — which read as a
/// logo with a loading bar stuck to the bottom. Running them in sequence makes
/// the first beat about *what this app does* and the second about *whose app
/// it is*, and neither is competing with the other for the same moment.
/// 1. Crimson, edge to edge, with the truck running across it in white.
/// 2. The invert: the screen goes to white and the truck goes to crimson,
/// on the same curve, at the same time.
/// 3. The mark.
///
/// ── The animation is a drop-in, and it is recoloured ──
/// The animation first and the mark after it. It was the other way round —
/// mark, then the animation appearing underneath it — which read as a logo
/// with a loading bar stuck to the bottom. In sequence, the first beat is
/// about *what this app does* and the last about *whose app it is*, and
/// neither competes with the other for the same moment.
///
/// ── The invert is one gesture, not a fade ──
///
/// A white truck on a background turning white is an invisible truck, so
/// something has to give at beat 2. Fading it out is the obvious answer and
/// the worse one: the screen would be empty for the moment before the mark
/// arrives. Instead both colours run off [_flip] at once — the ground lightens
/// as the truck darkens — so the whole screen turns itself inside out and the
/// truck is still there when it lands.
///
/// ── The red starts before Flutter does ──
///
/// [DmColors.splash] is painted by the *window*, from process start:
/// `launch_background.xml`, `windowSplashScreenBackground` on Android 12+ —
/// both orientations of dark mode included — and the iOS launch storyboard.
/// All four were white. Leaving any one of them white makes the launch a white
/// flash followed by a red one, and the flash is the only part a customer
/// consciously notices.
///
/// ── The truck never finishes, so a beat is chosen for it ──
///
/// `splash.json` is a seamless 3.9s loop: the truck sits in place with speed
/// lines behind it and frame 0 is frame 60. There is no arrival to wait for,
/// which means waiting out a full cycle buys nothing and costs a second and a
/// half. [_truckBeat] is a decision about how long a launch screen may hold
/// somebody, not a property of the file.
///
/// It is also drawn small inside a 500x500 composition — about a quarter of
/// it — so it is scaled up rather than sized up; see [_truckScale].
///
/// ── The animation is a drop-in, and its colour is not its own ──
///
/// If `assets/animations/splash.json` exists it is played. If it does not, the
/// road loader below runs instead.
/// road loader below runs instead, on the same timeline and in the same two
/// colours.
///
/// The file shipped as black line art, which is not a colour this app owns —
/// the darkest thing in the palette is `DmColors.ink`, and the splash is the
/// one screen that is nothing but brand. `tool/lottie_brand.py` rewrites its
/// near-black fills and strokes to the crimson; it is idempotent, so run it
/// again after replacing the file. Both are the same size and the same beat, so
/// which one is present changes nothing about the screen's timing — see
/// `assets/animations/README.txt`.
/// Whatever colours the file was drawn in are thrown away: it is painted
/// through `BlendMode.srcIn`, so every visible pixel becomes the one colour
/// [_flip] is currently on and the alpha is all that survives. That works here
/// because the artwork is line art — verified by rendering it — and it means a
/// replacement file needs no preparation at all. `tool/lottie_brand.py`, which
/// used to rewrite the JSON's fills to crimson, is no longer on this path.
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key, required this.ready, required this.onDone});
@@ -62,33 +96,87 @@ class SplashScreen extends StatefulWidget {
/// Called once, when the splash has served its purpose.
final VoidCallback onDone;
/// Long enough for both beats to read: the animation, then the mark.
static const _minimum = Duration(milliseconds: 2800);
/// How long the truck holds the red screen before the invert starts.
static const _truckBeat = Duration(milliseconds: 2000);
/// The invert: crimson to white, and the truck the other way.
static const _invert = Duration(milliseconds: 450);
/// The mark's entrance, once the screen is white.
static const _markIn = Duration(milliseconds: 600);
/// How long the mark is simply *there* once it has finished arriving.
///
/// Without this the clock ran out on the frame the entrance finished, so the
/// last beat of the launch was a logo that appeared and left in the same
/// moment. A mark nobody gets to look at is not worth animating in.
static const _markHold = Duration(milliseconds: 400);
/// Long enough for all three beats to read — their sum, not a guess. Change
/// one of them and this follows.
///
/// A getter rather than a `const`: adding two `Duration`s is not a constant
/// expression in Dart, and writing the total out as a literal is how the
/// three beats and the clock that outlives them drift apart.
static Duration get _minimum => _truckBeat + _invert + _markIn + _markHold;
/// Past this the app moves on whatever the session is doing.
static const _maximum = Duration(seconds: 7);
/// When the animation gives way to the mark.
/// The truck occupies about a quarter of its 500x500 composition, so at the
/// natural size it is a detail on an empty red field. Scaled rather than
/// sized: [Transform.scale] does not affect layout, so the surrounding box
/// stays the size the mark will need and the two beats cannot jump.
static const _truckScale = 2.2;
/// Pulls the truck back to the middle of the screen.
///
/// The truck runs six seconds end to end, so this shows the opening of it
/// rather than the whole thing. That is the right trade: a launch screen
/// that waits out a six-second loop is a launch screen nobody thanks you
/// for, and the part worth seeing — the truck arriving — is at the front.
static const _handover = Duration(milliseconds: 1800);
/// ── The artwork is not centred in its own composition ──
///
/// `splash.json` is 500x500 and the truck is drawn low and to the right
/// inside it, so centring the *widget* leaves the truck about 30pt right and
/// 36pt below the middle of the phone. Centred layout, off-centre picture —
/// which is the kind of thing that looks like nothing until it is beside a
/// logo that really is centred, and then it looks like a mistake.
///
/// Measured off a render rather than guessed, and held there by
/// `splash_centring_test.dart`: it finds the ink in a rendered frame and
/// fails if either beat drifts off the middle. Replace the Lottie and that
/// test will tell you this number is wrong.
///
/// In unscaled units, applied under [_truckScale], so changing the scale
/// does not silently move the truck off centre again.
static const _truckNudge = Offset(-13.8, -16.5);
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin {
/// Started at the handover, not at launch: the mark is the second beat.
late final AnimationController _mark = AnimationController(
with TickerProviderStateMixin {
/// The invert. 0 is crimson ground and a white truck, 1 is the other way.
late final AnimationController _flip = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 620),
duration: SplashScreen._invert,
);
/// False while the animation has the screen; true once the mark takes it.
/// Started when [_flip] lands, not at launch: the mark is the last beat.
late final AnimationController _mark = AnimationController(
vsync: this,
duration: SplashScreen._markIn,
);
late final Animation<double> _flipped = CurvedAnimation(
parent: _flip,
curve: DmMotion.ease,
);
late final Animation<double> _entrance = CurvedAnimation(
parent: _mark,
curve: Curves.easeOutBack,
);
/// False while the truck has the screen; true once the mark takes it.
bool _onMark = false;
bool _started = false;
@@ -125,6 +213,12 @@ class _SplashScreenState extends State<SplashScreen>
// anything reaches the glass — that was the first attempt at this fix, and
// it moved the problem without solving it. `waitUntilFirstFrameRasterized`
// is the signal Android itself waits on before taking its splash down.
_flip.addStatusListener((status) {
if (status != AnimationStatus.completed || !mounted) return;
setState(() => _onMark = true);
_mark.forward();
});
_composition = _loadComposition();
unawaited(_waitForScreen());
@@ -155,10 +249,10 @@ class _SplashScreenState extends State<SplashScreen>
if (_started || !mounted) return;
_started = true;
_holdTimer = Timer(SplashScreen._handover, () {
if (!mounted) return;
setState(() => _onMark = true);
_mark.forward();
// Beat 2 starts on a clock; beat 3 starts when beat 2 lands, so the mark
// can never arrive over a screen that is still turning.
_holdTimer = Timer(SplashScreen._truckBeat, () {
if (mounted) _flip.forward();
});
_minTimer = Timer(SplashScreen._minimum, () {
_elapsed = true;
@@ -196,70 +290,115 @@ class _SplashScreenState extends State<SplashScreen>
_minTimer?.cancel();
_maxTimer?.cancel();
_holdTimer?.cancel();
_flip.dispose();
_mark.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final entrance = CurvedAnimation(parent: _mark, curve: Curves.easeOutBack);
// One rebuild per frame of the invert, and nothing else in the tree is
// listening — the truck and the mark both take their colour from here.
return AnimatedBuilder(
animation: _flipped,
builder: (context, _) {
final t = _flipped.value;
final ground = Color.lerp(DmColors.splash, DmColors.surface, t)!;
final ink = Color.lerp(Colors.white, DmColors.splash, t)!;
return Scaffold(
backgroundColor: DmColors.surface,
body: AnnotatedRegion<SystemUiOverlayStyle>(
value: const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
systemNavigationBarColor: DmColors.surface,
systemNavigationBarIconBrightness: Brightness.dark,
),
child: Semantics(
label: 'Doormile',
child: Center(
// One box, two beats, cross-faded. A `Column` holding both would
// reserve height for the one that is not showing and push the
// other off centre.
// Sized to the screen, not to a number: the splash is the one
// surface with nothing else on it, so both beats are given most
// of the width rather than sitting small in the middle of it.
child: SizedBox(
height: 320,
child: AnimatedSwitcher(
duration: DmMotion.base,
switchInCurve: DmMotion.ease,
switchOutCurve: DmMotion.ease,
child: _onMark
? ScaleTransition(
key: const ValueKey('mark'),
scale: Tween(begin: 0.82, end: 1.0).animate(entrance),
child: FadeTransition(
opacity: _mark,
child: Image.asset(
'assets/images/doormile-icon.png',
width: 210,
height: 210,
filterQuality: FilterQuality.medium,
),
),
)
: FutureBuilder<LottieComposition?>(
key: const ValueKey('animation'),
future: _composition,
builder: (context, snap) {
final composition = snap.data;
if (composition == null) return const _RoadLoader();
return Lottie(
composition: composition,
width: 340,
height: 320,
fit: BoxFit.contain,
repeat: true,
);
},
),
// The system bars belong to whichever half of the invert is showing.
// Left on dark icons, the status bar is unreadable for the two seconds
// the screen is crimson — which is most of the launch.
final onRed = t < 0.5;
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
// Android and iOS name this the opposite way round: one describes
// the icons, the other the surface behind them.
statusBarIconBrightness: onRed ? Brightness.light : Brightness.dark,
statusBarBrightness: onRed ? Brightness.dark : Brightness.light,
systemNavigationBarColor: ground,
systemNavigationBarIconBrightness: onRed
? Brightness.light
: Brightness.dark,
),
child: Scaffold(
backgroundColor: ground,
body: Semantics(
label: 'Doormile',
child: Center(
// One box, two beats, cross-faded. A `Column` holding both
// would reserve height for the one that is not showing and
// push the other off centre.
child: SizedBox(
height: 320,
child: AnimatedSwitcher(
duration: DmMotion.base,
switchInCurve: DmMotion.ease,
switchOutCurve: DmMotion.ease,
child: _onMark ? _buildMark() : _buildTruck(ink),
),
),
),
),
),
);
},
);
}
/// Beat 1 and 2: the truck, in whatever colour the invert is on.
///
/// `srcIn` keeps the alpha and throws the colour away, so the file's own
/// palette is irrelevant and a replacement needs no preparation. It works
/// because the artwork is line art — a filled illustration would flatten to
/// a silhouette here, which is worth checking before dropping one in.
Widget _buildTruck(Color ink) {
return ColorFiltered(
key: const ValueKey('animation'),
colorFilter: ColorFilter.mode(ink, BlendMode.srcIn),
child: FutureBuilder<LottieComposition?>(
future: _composition,
builder: (context, snap) {
final composition = snap.data;
if (composition == null) return const _RoadLoader();
return Transform.scale(
scale: SplashScreen._truckScale,
child: Transform.translate(
offset: SplashScreen._truckNudge,
child: Lottie(
composition: composition,
width: 340,
height: 320,
fit: BoxFit.contain,
repeat: true,
),
),
);
},
),
);
}
/// Beat 3: the mark, on white.
///
/// `doormile-mark.png` is cut from the launcher icon's own master by
/// `tool/icons.py`. It used to be `doormile-icon.png`, a separate file that
/// was nobody's job to keep in step — and by the time the icon was redrawn
/// the splash was showing a customer the previous mark half a second after
/// they tapped the new one.
Widget _buildMark() {
return ScaleTransition(
key: const ValueKey('mark'),
scale: Tween(begin: 0.82, end: 1.0).animate(_entrance),
child: FadeTransition(
opacity: _mark,
child: Image.asset(
'assets/images/doormile-mark.png',
width: 210,
height: 210,
filterQuality: FilterQuality.medium,
),
),
);
@@ -272,6 +411,14 @@ class _SplashScreenState extends State<SplashScreen>
/// in the one shape the mark already contains — the road curving through the D.
/// It is also two primitives and no dependency, so the splash works on a fresh
/// checkout with nothing downloaded.
///
/// ── It used to be invisible half the time ──
///
/// Every colour in here was a fixed `DmColors.brand` or `DmColors.border`, from
/// when the splash was white throughout. On the crimson beat that is crimson on
/// crimson: the fallback drew nothing at all, on exactly the devices that had
/// fallen back to it. It takes its ink from the same [ColorFiltered] the truck
/// does now, so both beats invert together.
class _RoadLoader extends StatefulWidget {
const _RoadLoader();
@@ -280,7 +427,7 @@ class _RoadLoader extends StatefulWidget {
}
class _RoadLoaderState extends State<_RoadLoader>
with SingleTickerProviderStateMixin {
with TickerProviderStateMixin {
late final AnimationController _run = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
@@ -325,7 +472,11 @@ class _RoadPainter extends CustomPainter {
Paint()
..strokeWidth = 2
..strokeCap = StrokeCap.round
..color = DmColors.border,
// Everything here is painted in white and recoloured by the
// `ColorFiltered` wrapping this widget — `srcIn` keeps only the alpha,
// so what matters is the *opacity* of each part, not its hue. The road
// is the quietest thing on it.
..color = Colors.white.withValues(alpha: 0.30),
);
// Lane markings, travelling backwards under the parcel. They are what
@@ -343,10 +494,9 @@ class _RoadPainter extends CustomPainter {
Paint()
..strokeWidth = 2
..strokeCap = StrokeCap.round
// Strong enough to read as movement. At `brandLine` on white they
// were technically present and invisible, which made the parcel look
// like it was sliding on nothing.
..color = DmColors.brand.withValues(alpha: 0.28),
// Strong enough to read as movement. Too faint and the parcel looks
// like it is sliding on nothing.
..color = Colors.white.withValues(alpha: 0.55),
);
}
@@ -363,18 +513,21 @@ class _RoadPainter extends CustomPainter {
rect.shift(const Offset(0, 3)),
Paint()
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4)
..color = DmColors.brand.withValues(alpha: 0.22),
..color = Colors.white.withValues(alpha: 0.22),
);
canvas.drawRRect(rect, Paint()..color = DmColors.brand);
canvas.drawRRect(rect, Paint()..color = Colors.white);
// The seam across the box, which is what makes it a parcel and not a
// rectangle.
// Punched out of the parcel rather than drawn over it: under `srcIn` a
// lighter stroke on top would come out the same colour as the box.
canvas.drawLine(
Offset(cx, y - 22),
Offset(cx, y - 4),
Paint()
..blendMode = BlendMode.clear
..strokeWidth = 1.6
..color = Colors.white.withValues(alpha: 0.55),
..color = Colors.white,
);
}

View File

@@ -43,6 +43,25 @@ class DmColors {
/// rather than repeated as a literal in three files.
static const brandLit = Color(0xFFA6180B);
/// What the launch fills the screen with, before anything else exists.
///
/// ── Why this is not [brand] ──
///
/// It is the launcher icon's own crimson, and it matches on purpose: the
/// customer taps a red tile and the screen fills with that same red, which
/// reads as one continuous gesture rather than as an app starting up. [brand]
/// is the UI's red and is four shades deeper; using it here would make the
/// tap and the screen after it two different colours for no reason a customer
/// could name.
///
/// The two never appear together — the splash has inverted to white before a
/// single piece of UI is drawn.
///
/// Mirrored in `android/app/src/main/res/values/colors.xml` as `dm_splash_bg`
/// and in `ios/Runner/Base.lproj/LaunchScreen.storyboard`, because the window
/// is painted this colour from process start — long before Flutter can.
static const splash = Color(0xFFBD0921);
/// Tint behind selected options, wash cards and brand chips.
static const brandSoft = Color(0xFFFBF3F2);

View File

@@ -145,6 +145,7 @@ class DmAsyncList<T> extends StatefulWidget {
this.onEmptyAction,
this.skeletonRows = 4,
this.reloadToken,
this.initialItems,
});
/// Called on first build and on every retry.
@@ -161,6 +162,18 @@ class DmAsyncList<T> extends StatefulWidget {
final int skeletonRows;
/// What to render on the very first frame, when the caller already has it.
///
/// A `FutureBuilder` reports `waiting` on its first build even when the
/// future it is given is already complete, so a list backed by a warm cache
/// still flashed a skeleton for a frame before showing data it had all
/// along. Passing the cached items here skips that: the list opens on the
/// content and the future, when it lands, simply confirms it.
///
/// Null means there is nothing cached, which is the honest first-run case
/// and still gets the skeleton.
final List<T>? initialItems;
/// Changes when the caller has invalidated what this list is showing.
///
/// The future is held in state, so a screen the customer returns to would
@@ -194,10 +207,8 @@ class DmAsyncListState<T> extends State<DmAsyncList<T>> {
Widget build(BuildContext context) {
return FutureBuilder<List<T>>(
future: _future,
initialData: widget.initialItems,
builder: (context, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return DmSkeleton(rows: widget.skeletonRows);
}
if (snap.hasError) {
return DmEmptyState(
icon: LucideIcons.wifiOff,
@@ -207,7 +218,12 @@ class DmAsyncListState<T> extends State<DmAsyncList<T>> {
onAction: reload,
);
}
final items = snap.data ?? const [];
// Data before connection state, deliberately. `waiting` is true on the
// first build even for an already-complete future, so checking it
// first would throw away [initialItems] for the one frame it exists
// to cover.
final items = snap.data;
if (items == null) return DmSkeleton(rows: widget.skeletonRows);
if (items.isEmpty) {
return DmEmptyState(
icon: widget.emptyIcon,

View File

@@ -113,7 +113,7 @@ flutter:
# Declaring the folder shipped it to every customer, along with anything
# else dropped in there to work from. These two are the only images any
# code actually opens; see `splash_screen.dart` and `auth_scaffold.dart`.
- assets/images/doormile-icon.png
- assets/images/doormile-mark.png
- assets/images/doormile-logo.png
- assets/animations/

View File

@@ -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);

View 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);
});
});
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 185 KiB

After

Width:  |  Height:  |  Size: 185 KiB

View 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');
});
}

View File

@@ -50,6 +50,12 @@ distinctive thing on the mark and the first thing a circular mask would take.
but the alpha channel, so a full-colour foreground themes as a solid blob. The
monochrome asset is the same silhouette with the colour thrown away.
**The splash** gets the mark on its own, crimson on transparent. Not a
separate export: the splash used to show a logo
file that was nobody's job to keep in step, and by the time the launcher icon
was redrawn it was showing the previous mark to a customer who had just tapped
the new one. Both come off this master's alpha, so they cannot drift.
**Web** gets both — a plain icon that is the artwork, and a `maskable` one
padded the way Android's is, for the same reason.
"""
@@ -397,12 +403,55 @@ def build_web(flat: Image.Image, field: tuple[int, int, int]) -> None:
write(resize(flat, favicon.width), os.path.join(out, 'favicon.png'))
# How much of the mark's own canvas is margin, in the splash cut-outs.
#
# Tighter than the launcher icon, which has a mask eating its edges. Nothing
# crops these — the splash sizes them directly — so the margin only has to stop
# the strokes touching the box.
MARK_PAD = 0.06
def build_marks(placed: Image.Image, field: tuple[int, int, int]) -> None:
"""The mark alone, on transparent, in the field's own crimson.
Cropped to the ink and re-padded, so the splash can size the file and get
the mark at that size rather than at whatever fraction of it the master's
own padding left over.
"""
out = os.path.join(ROOT, 'assets/images')
alpha = placed.split()[3]
box = alpha.point(lambda a: 255 if a > 8 else 0).getbbox()
if box is None:
raise SystemExit('no mark to cut')
x0, y0, x1, y1 = box
side = max(x1 - x0, y1 - y0)
pad = round(side * MARK_PAD)
side += pad * 2
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
cut = alpha.crop((
round(cx - side / 2), round(cy - side / 2),
round(cx + side / 2), round(cy + side / 2),
)).resize((1024, 1024), Image.LANCZOS)
log('Splash marks')
for name, rgb in (('doormile-mark.png', field),):
layer = Image.merge('RGBA', (
Image.new('L', cut.size, rgb[0]),
Image.new('L', cut.size, rgb[1]),
Image.new('L', cut.size, rgb[2]),
cut,
))
write(layer, os.path.join(out, name))
def main() -> None:
flat, placed, field, radius = build_master()
build_ios(flat)
build_android_legacy(flat)
build_android_adaptive(placed, radius)
build_android_xml(field)
build_marks(placed, field)
build_web(flat, field)
log('\ndone')

View File

@@ -8,8 +8,9 @@ python3 - <<'PY'
import shutil, os
src, dst = 'test/snapshots', 'design/screens'
pairs = [
('00-splash-animation.png', '00-splash-animation.png'),
('00b-splash-mark.png', '00b-splash-mark.png'),
('00-splash-truck.png', '00-splash-truck.png'),
('00b-splash-invert.png', '00b-splash-invert.png'),
('00c-splash-mark.png', '00c-splash-mark.png'),
('01-sign-in.png', '01-sign-in.png'),
('01b-sign-up.png', '02-sign-up.png'),
('02-verify.png', '03-verify-code.png'),