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 createState() => _SplashScreenState(); } class _SplashScreenState extends State 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 _flipped = CurvedAnimation( parent: _flip, curve: DmMotion.ease, ); late final Animation _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? _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 _waitForScreen() async { await _composition; await WidgetsBinding.instance.waitUntilFirstFrameRasterized.timeout( const Duration(milliseconds: 900), onTimeout: () {}, ); await Future.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 _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( 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( 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; }