── 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.
537 lines
20 KiB
Dart
537 lines
20 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:lottie/lottie.dart';
|
|
|
|
import '../tokens.dart';
|
|
|
|
/// What the app opens on: the mark, then something moving.
|
|
///
|
|
/// ── Why there is a splash at all ──
|
|
///
|
|
/// The Android window background already shows the mark from process start, so
|
|
/// this is not covering a blank screen. It exists because the first thing that
|
|
/// happens after it is a *question* — a login form, or a Home screen the
|
|
/// customer has to read — and arriving at either one mid-stride is worse than
|
|
/// arriving at it after a beat that says whose app this is.
|
|
///
|
|
/// It also has real work to hide behind it. `restoreSession` reads the keychain
|
|
/// and may refresh a 60-day token against the network, which is the one moment
|
|
/// the app cannot say whether the customer is signed in. Showing a login screen
|
|
/// that vanishes a moment later is the failure this replaces.
|
|
///
|
|
/// ── It never makes anybody wait longer than it has to ──
|
|
///
|
|
/// Two clocks, and the screen leaves on the later of them: [_minimum], so the
|
|
/// animation is not a flicker, and whatever the session restore takes. A cold
|
|
/// start with a stored session usually finishes inside the minimum, so the
|
|
/// splash costs nothing; a slow network extends it rather than flashing.
|
|
///
|
|
/// [_maximum] is the safety net. If the restore hangs — a captive portal, a
|
|
/// server not answering — the app moves on rather than holding a customer on a
|
|
/// logo forever, and `_Launch` shows the login screen, which is the right place
|
|
/// for somebody whose session could not be read.
|
|
///
|
|
/// ── Three beats, in this order ──
|
|
///
|
|
/// 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 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, on the same timeline and in the same two
|
|
/// colours.
|
|
///
|
|
/// 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});
|
|
|
|
/// True once the session restore has finished, however it finished.
|
|
final bool ready;
|
|
|
|
/// Called once, when the splash has served its purpose.
|
|
final VoidCallback onDone;
|
|
|
|
/// 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);
|
|
|
|
/// 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 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 TickerProviderStateMixin {
|
|
/// The invert. 0 is crimson ground and a white truck, 1 is the other way.
|
|
late final AnimationController _flip = AnimationController(
|
|
vsync: this,
|
|
duration: SplashScreen._invert,
|
|
);
|
|
|
|
/// 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;
|
|
bool _elapsed = false;
|
|
bool _left = false;
|
|
Timer? _minTimer;
|
|
Timer? _maxTimer;
|
|
Timer? _holdTimer;
|
|
|
|
/// The Lottie file, if somebody dropped one in. Resolved once, at launch:
|
|
/// a `FutureBuilder` on every frame of a splash is work for nothing.
|
|
Future<LottieComposition?>? _composition;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
// ── The clock starts when the splash is on screen, not when it is built ──
|
|
//
|
|
// These timers used to start here, and the truck got 0.45s of a 1.8s beat.
|
|
// Two things happen between `initState` and the customer seeing anything:
|
|
// Android holds its own splash over the window until Flutter's first
|
|
// frame, and parsing a 300KB Lottie takes a few hundred milliseconds on a
|
|
// mid-range phone. Both were being spent against a clock that was already
|
|
// running, so most of the first beat elapsed behind a white rectangle and
|
|
// the animation looked like it flashed past on the way to the logo.
|
|
//
|
|
// Now the clock starts once three things are true: the composition has
|
|
// settled, the engine has **rasterized** its first frame, and the system
|
|
// splash has had time to fade out over it.
|
|
//
|
|
// Rasterized, not built. `addPostFrameCallback` fires on the first frame
|
|
// Flutter *assembles*, which on a debug build happens over a second before
|
|
// 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());
|
|
|
|
// The safety net runs from launch regardless. If the composition never
|
|
// resolves, the app must still move on.
|
|
_maxTimer = Timer(SplashScreen._maximum, _leave);
|
|
}
|
|
|
|
/// Blocks until there is a customer looking at this, then starts the clock.
|
|
///
|
|
/// The rasterizer wait is raced against a timeout so a binding that never
|
|
/// reports one — the widget-test harness, a platform that does not
|
|
/// implement it — cannot leave the app sitting on a splash forever. The
|
|
/// extra beat after it covers the system splash's own exit animation.
|
|
Future<void> _waitForScreen() async {
|
|
await _composition;
|
|
await WidgetsBinding.instance.waitUntilFirstFrameRasterized.timeout(
|
|
const Duration(milliseconds: 900),
|
|
onTimeout: () {},
|
|
);
|
|
await Future<void>.delayed(const Duration(milliseconds: 220));
|
|
_start();
|
|
}
|
|
|
|
/// Begins the two beats. Idempotent: whichever of the waits above finishes
|
|
/// last is the one that counts.
|
|
void _start() {
|
|
if (_started || !mounted) return;
|
|
_started = true;
|
|
|
|
// 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;
|
|
_leaveIfDone();
|
|
});
|
|
}
|
|
|
|
/// Null when there is no file, which is not an error — it is the default.
|
|
Future<LottieComposition?> _loadComposition() async {
|
|
try {
|
|
return await AssetLottie('assets/animations/splash.json').load();
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(SplashScreen old) {
|
|
super.didUpdateWidget(old);
|
|
if (widget.ready && !old.ready) _leaveIfDone();
|
|
}
|
|
|
|
void _leaveIfDone() {
|
|
if (_elapsed && widget.ready) _leave();
|
|
}
|
|
|
|
void _leave() {
|
|
if (_left || !mounted) return;
|
|
_left = true;
|
|
widget.onDone();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_minTimer?.cancel();
|
|
_maxTimer?.cancel();
|
|
_holdTimer?.cancel();
|
|
_flip.dispose();
|
|
_mark.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// 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)!;
|
|
|
|
// 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,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The built-in animation: a parcel travelling a road.
|
|
///
|
|
/// Not a spinner. A spinner says "waiting"; this says what the waiting is for,
|
|
/// 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();
|
|
|
|
@override
|
|
State<_RoadLoader> createState() => _RoadLoaderState();
|
|
}
|
|
|
|
class _RoadLoaderState extends State<_RoadLoader>
|
|
with TickerProviderStateMixin {
|
|
late final AnimationController _run = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 1500),
|
|
)..repeat();
|
|
|
|
@override
|
|
void dispose() {
|
|
_run.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Center(
|
|
child: SizedBox(
|
|
width: 190,
|
|
height: 40,
|
|
child: AnimatedBuilder(
|
|
animation: _run,
|
|
builder: (_, _) => CustomPaint(painter: _RoadPainter(_run.value)),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RoadPainter extends CustomPainter {
|
|
const _RoadPainter(this.t);
|
|
|
|
/// 0→1, repeating.
|
|
final double t;
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final y = size.height * 0.72;
|
|
|
|
// The road: a full-width track at the weight of a hairline, so the parcel
|
|
// is the only thing on it with any presence.
|
|
canvas.drawLine(
|
|
Offset(0, y),
|
|
Offset(size.width, y),
|
|
Paint()
|
|
..strokeWidth = 2
|
|
..strokeCap = StrokeCap.round
|
|
// 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
|
|
// makes it read as movement rather than as a dot sliding on a line.
|
|
const dash = 14.0;
|
|
const gap = 12.0;
|
|
final shift = -t * (dash + gap);
|
|
for (var x = shift; x < size.width; x += dash + gap) {
|
|
final a = x.clamp(0.0, size.width);
|
|
final b = (x + dash).clamp(0.0, size.width);
|
|
if (b <= a) continue;
|
|
canvas.drawLine(
|
|
Offset(a, y),
|
|
Offset(b, y),
|
|
Paint()
|
|
..strokeWidth = 2
|
|
..strokeCap = StrokeCap.round
|
|
// 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),
|
|
);
|
|
}
|
|
|
|
// The parcel. Eased at both ends so it leaves and arrives rather than
|
|
// scrolling past at a constant speed.
|
|
final eased = Curves.easeInOutCubic.transform(t);
|
|
final cx = 16 + eased * (size.width - 32);
|
|
final rect = RRect.fromRectAndRadius(
|
|
Rect.fromCenter(center: Offset(cx, y - 13), width: 22, height: 18),
|
|
const Radius.circular(4),
|
|
);
|
|
|
|
canvas.drawRRect(
|
|
rect.shift(const Offset(0, 3)),
|
|
Paint()
|
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4)
|
|
..color = Colors.white.withValues(alpha: 0.22),
|
|
);
|
|
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,
|
|
);
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(_RoadPainter old) => old.t != t;
|
|
}
|