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. /// /// ── Two 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. /// /// ── The animation is a drop-in, and it is recoloured ── /// /// If `assets/animations/splash.json` exists it is played. If it does not, the /// road loader below runs instead. /// /// 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`. 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; /// Long enough for both beats to read: the animation, then the mark. static const _minimum = Duration(milliseconds: 2800); /// 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 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); @override State createState() => _SplashScreenState(); } class _SplashScreenState extends State with SingleTickerProviderStateMixin { /// Started at the handover, not at launch: the mark is the second beat. late final AnimationController _mark = AnimationController( vsync: this, duration: const Duration(milliseconds: 620), ); /// False while the animation 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. _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; _holdTimer = Timer(SplashScreen._handover, () { if (!mounted) return; setState(() => _onMark = true); _mark.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(); _mark.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final entrance = CurvedAnimation(parent: _mark, curve: Curves.easeOutBack); return Scaffold( backgroundColor: DmColors.surface, body: AnnotatedRegion( 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( 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 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. class _RoadLoader extends StatefulWidget { const _RoadLoader(); @override State<_RoadLoader> createState() => _RoadLoaderState(); } class _RoadLoaderState extends State<_RoadLoader> with SingleTickerProviderStateMixin { 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 ..color = DmColors.border, ); // 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. 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), ); } // 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 = DmColors.brand.withValues(alpha: 0.22), ); canvas.drawRRect(rect, Paint()..color = DmColors.brand); // The seam across the box, which is what makes it a parcel and not a // rectangle. canvas.drawLine( Offset(cx, y - 22), Offset(cx, y - 4), Paint() ..strokeWidth = 1.6 ..color = Colors.white.withValues(alpha: 0.55), ); } @override bool shouldRepaint(_RoadPainter old) => old.t != t; }