Redesign: Poppins, a two-step destination, and a splash that says what the app does
The effort pass, end to end. Every screen was run through one test — if I remove this sentence, does the customer make a worse decision? — and the parts that failed it are gone. The flow Home ▸ BOOK ▸ Where is it going? ▸ When shall we collect? ▸ details ▸ booked BOOK opens a sheet, not a form. The destination is browsed state-then-district because a flat list of every serviceable district survives twelve and not sixty, and search cuts across states because somebody who knows they are sending to Chennai should not have to know which state it is in. Districts multi-select, but only where the server allows it: BookingLimits advertises maxDestinations: 1 until the Miler build keys on consignmentid, and a sheet that ignored that would sell a booking the network cannot complete. The pickup window is now a step the customer answers rather than a slot chosen for them. A pickup window is a promise about somebody's afternoon. What the screens stopped saying Home lost the orb caption for returning customers and a four-cell live card. Send lost the city strip, both address fields, the optional disclosure and three sentences about charging — the route, the packages and the button are what is left. Tracking lost a radar with a bike in it, a Milers-in-your-zone count, a "Step 2 of 7" and a sentence describing the screen you were looking at. The window sheet lost "Fastest pickup", "4 Milers nearby" and "Relaxed evening handover". Type Poppins, which has no variable release — four static cuts, and the sans styles set fontWeight alone because fontVariations on a static font is ignored in silence. Every weight dropped a step and the tracking went deeper: Poppins is built on near-circles and carries more ink than the humanist faces before it. Objects One lit sphere on Home, and the primary button now takes its gradient and rim because a committing action that is not lit like the hero reads as a different material. The tracking rail's connector is crimson as far as the parcel has come, so the line is the progress bar. Confirmation is a white tick on green: crimson is this app's action colour and that screen has nothing left to do. Bugs found on the way The OTP screen dropped digits. Four fields passing focus along lose a keystroke that arrives mid-transition, so "1234" became "124" and the screen answered "That code did not match" — blaming the customer for its own race. One field now, four boxes that only draw. Nothing ever asked for the customer's location: detectPickupLocation was the OTP screen's job, so a restored session or an auto-login never triggered the permission prompt and the pickup map had nothing to centre on. The launcher icon and both splash screens pointed at a house drawn as two vector paths — a placeholder that shipped. The splash clock started when the widget was built rather than when it was visible, so the truck got 0.45s of a 1.8s beat behind Android's own splash. It waits on waitUntilFirstFrameRasterized now, raced against a timeout so a binding that never reports one cannot strand the app. Also: design/screens/ holds all 19 screens under readable names, tool/ has the scripts that refresh them and rebrand the Lottie, and DESIGN.md is current. flutter analyze clean. 88 tests, 1 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
This commit is contained in:
383
lib/ui/screens/splash_screen.dart
Normal file
383
lib/ui/screens/splash_screen.dart
Normal file
@@ -0,0 +1,383 @@
|
||||
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<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(
|
||||
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<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.
|
||||
_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;
|
||||
|
||||
_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<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();
|
||||
_mark.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final entrance = CurvedAnimation(parent: _mark, curve: Curves.easeOutBack);
|
||||
|
||||
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 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;
|
||||
}
|
||||
Reference in New Issue
Block a user