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:
2026-09-22 17:45:54 +05:30
parent 8207e27a97
commit 06fa6b797a
107 changed files with 4597 additions and 2071 deletions

View File

@@ -0,0 +1,405 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../tokens.dart';
/// The one action on Home, as a crimson sphere that breathes.
///
/// ── Why a circle, and why it says one word ──
///
/// Home was a filled rectangular card: a heading, a sentence and a white
/// button inside it. Correct, and completely inert — three stacked rectangles
/// on a screen of stacked rectangles, where the thing the app exists for
/// looked like the things it does not.
///
/// A disc is the one shape nothing else on the screen uses, so it needs no
/// border, no label and no arrow to be found. It is also the shape the gesture
/// wants: a thumb lands on a circle, and the press can scale from the centre
/// without the corners doing anything awkward.
///
/// ── Why it is lit, and not a flat fill ──
///
/// The first version of this was a flat crimson circle with two hairline rings
/// around it, and it read as exactly what it was: shapes a program drew. Three
/// things separate a designed object from a drawn one, and all three are here.
///
/// *Light has a direction.* The fill is a radial gradient with its hot point up
/// and to the left, falling to a much deeper crimson at the lower right, with a
/// soft specular bloom over the top of it and a rim that is bright along the
/// top edge and dark along the bottom. That is one light source, consistently
/// obeyed, and it is what makes a circle read as a sphere.
///
/// *Nothing terminates hard.* Every ring here is drawn through a blur, so it
/// has a soft shoulder instead of a 1px edge; the glow underneath is two
/// shadows at different radii, not one. Hairlines are for dividing content.
///
/// *Something is always moving, slowly.* A comet of light orbits the sphere
/// once every six seconds and the sphere itself breathes about 1%. Neither is
/// fast enough to notice directly — they are there so the screen is never
/// completely still under a thumb.
///
/// ── The rings are the product, not decoration ──
///
/// Three of them leave the sphere and fade outward. This app sends somebody to
/// your door, and the radar sweep is what that looks like — the same reason a
/// ride app pulses over a pickup pin. They are slow and low-contrast on
/// purpose: a fast pulse reads as an alarm, and this is meant to sit under a
/// thumb for as long as somebody is deciding.
///
/// ── What the word alone could not do ──
///
/// This screen carried a 68pt BOOK bar once and it was replaced, for a good
/// reason recorded at the time: "a word with no object, which on first run told
/// a new customer nothing about what the app does." That is still true of the
/// word by itself, so [caption] sits under the sphere and does the explaining.
/// The boldness is in the shape; the sentence is what stops it being a shout.
class DmBookOrb extends StatefulWidget {
const DmBookOrb({
super.key,
required this.onTap,
this.label = 'BOOK',
this.caption,
this.held = false,
});
final VoidCallback onTap;
final String label;
/// Keeps the sphere compressed while the surface it opened is on screen.
///
/// Without this the sphere springs back the instant the finger lifts, and
/// the sheet that rises a moment later reads as an unrelated screen arriving
/// over the top. Held down, it reads as the thing that pushed the sheet up —
/// which is the only reason the customer believes they are still on Home.
final bool held;
/// One line under the sphere saying what will happen. Optional only so a
/// second use of this widget elsewhere is not forced to repeat itself.
final String? caption;
@override
State<DmBookOrb> createState() => _DmBookOrbState();
}
class _DmBookOrbState extends State<DmBookOrb> with TickerProviderStateMixin {
/// Drives the pulses and the orbiting comet from one clock. Separate
/// controllers would drift apart over a long session and the pair would stop
/// reading as one rhythm.
late final AnimationController _radar = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 6000),
)..repeat();
/// The breath. Its own clock because it mirrors, and because 1% at four
/// seconds has no business being locked to the radar's six.
late final AnimationController _breath = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 4000),
)..repeat(reverse: true);
bool _down = false;
@override
void dispose() {
_radar.dispose();
_breath.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
const disc = 204.0;
const field = disc + 116;
return Semantics(
button: true,
label: widget.caption == null
? widget.label
: '${widget.label}. ${widget.caption}',
child: Column(
children: [
GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => setState(() => _down = true),
onTapCancel: () => setState(() => _down = false),
onTap: () {
setState(() => _down = false);
// The press is a commitment — it opens the booking flow — and a
// sphere gives no edge feedback the way a bordered button does.
HapticFeedback.mediumImpact();
widget.onTap();
},
child: SizedBox(
width: field,
height: field,
child: Stack(
alignment: Alignment.center,
children: [
// Pulses and comet sit behind and are never hit-tested — they
// leave the sphere's edge, so a tap near them is a tap that
// was meant for it.
IgnorePointer(
child: AnimatedBuilder(
animation: _radar,
builder: (_, _) => CustomPaint(
size: const Size.square(field),
painter: _RadarPainter(_radar.value, disc / 2),
),
),
),
AnimatedScale(
scale: _down || widget.held ? 0.94 : 1,
// Just past the press, so the finger feels the give rather
// than watching it happen.
duration: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
child: ScaleTransition(
scale: Tween(begin: 1.0, end: 1.012).animate(
CurvedAnimation(parent: _breath, curve: Curves.easeInOut),
),
child: _Sphere(size: disc, label: widget.label),
),
),
],
),
),
),
if (widget.caption != null) ...[
const SizedBox(height: 4),
Text(
widget.caption!,
textAlign: TextAlign.center,
style: DmText.small.copyWith(color: DmColors.ink3, height: 1.45),
),
],
],
),
);
}
}
/// The lit body. Four layers, all obeying one light from the upper left:
/// gradient fill, specular bloom, rim, then the word.
class _Sphere extends StatelessWidget {
const _Sphere({required this.size, required this.label});
final double size;
final String label;
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: const BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
// Up and to the left, which is where every lit object in this app
// takes its light from.
center: Alignment(-0.38, -0.52),
radius: 1.08,
colors: [Color(0xFFC0230F), DmColors.brand, Color(0xFF5C0802)],
stops: [0.0, 0.5, 1.0],
),
boxShadow: [
// Two radii rather than one: the tight shadow gives the sphere its
// contact with the page, the wide one is the light it throws. Both
// are pulled in hard — at full strength they bloom into a pink cloud
// that reads as a smudge under the object rather than as its shadow.
BoxShadow(
color: Color(0x478F0F06),
blurRadius: 26,
spreadRadius: -16,
offset: Offset(0, 14),
),
BoxShadow(
color: Color(0x1A8F0F06),
blurRadius: 52,
spreadRadius: -18,
offset: Offset(0, 20),
),
],
),
child: Stack(
alignment: Alignment.center,
children: [
// Specular — the sheen where the light actually lands. Small and
// soft; a hard highlight would read as plastic.
const Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
center: Alignment(-0.42, -0.58),
radius: 0.62,
colors: [Color(0x40FFFFFF), Color(0x00FFFFFF)],
),
),
),
),
// Rim: bright where the light grazes the top edge, dark where the
// body turns away at the bottom. A uniform border would flatten it.
const Positioned.fill(child: CustomPaint(painter: _RimPainter())),
// One hairline set in from the edge. Barely visible, and the reason
// the face reads as a made object rather than a filled circle.
Positioned.fill(
child: Padding(
padding: const EdgeInsets.all(15),
child: DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: const Color(0x14FFFFFF)),
),
),
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: DmText.heading.copyWith(
fontSize: 32,
height: 1,
letterSpacing: 5,
fontWeight: FontWeight.w800,
fontVariations: const [FontVariation('wght', 800)],
color: Colors.white,
shadows: const [
Shadow(color: Color(0x4D5C0802), blurRadius: 12, offset: Offset(0, 2)),
],
),
),
const SizedBox(height: 6),
const Icon(
LucideIcons.arrowUpRight,
size: 18,
color: Color(0xB3FFFFFF),
),
],
),
],
),
);
}
}
/// A rim that changes with the light: white along the top arc, shadow along
/// the bottom, nothing at the sides.
class _RimPainter extends CustomPainter {
const _RimPainter();
@override
void paint(Canvas canvas, Size size) {
final rect = Offset.zero & size;
canvas.drawCircle(
rect.center,
size.width / 2 - 0.5,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.2
..shader = const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0x59FFFFFF), Color(0x0DFFFFFF), Color(0x334A0602)],
stops: [0.0, 0.45, 1.0],
).createShader(rect),
);
}
@override
bool shouldRepaint(_RimPainter old) => false;
}
/// What surrounds the sphere: three pulses leaving it, and one comet of light
/// orbiting it.
class _RadarPainter extends CustomPainter {
const _RadarPainter(this.t, this.startRadius);
/// 0→1, repeating once every six seconds.
final double t;
/// Where a pulse is born: the sphere's edge, so it looks emitted rather than
/// drawn around.
final double startRadius;
@override
void paint(Canvas canvas, Size size) {
final centre = Offset(size.width / 2, size.height / 2);
_pulses(canvas, centre, size);
_comet(canvas, centre);
}
void _pulses(Canvas canvas, Offset centre, Size size) {
final travel = (size.width / 2) - startRadius;
for (final phase in const [0.0, 0.34, 0.67]) {
final p = (t + phase) % 1.0;
// Eased outward: quick to leave, slow to die, which is how a real pulse
// decays. Linear travel reads mechanical.
final eased = 1 - math.pow(1 - p, 2.4).toDouble();
final radius = startRadius + travel * eased;
// Fades the whole way out, so nothing ever sits at full strength at the
// edge where it would read as a second object.
final opacity = (1 - p) * (1 - p) * 0.42;
if (opacity <= 0.003) continue;
canvas.drawCircle(
centre,
radius,
Paint()
..style = PaintingStyle.stroke
// Thins as it expands, like a wavefront losing energy.
..strokeWidth = 2.2 - 1.2 * eased
// The blur is the point. A crisp 1px ring is a drawn shape; a ring
// with a soft shoulder is light.
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 3.2)
..color = DmColors.brand.withValues(alpha: opacity),
);
}
}
/// A short arc of light running round the sphere's edge, with a bright head.
/// One slow-moving detail is what stops a static object looking switched off.
void _comet(Canvas canvas, Offset centre) {
final r = startRadius + 16;
final rect = Rect.fromCircle(center: centre, radius: r);
final angle = t * 2 * math.pi;
canvas.drawCircle(
centre,
r,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.6
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2.6)
..shader = SweepGradient(
// The tail runs behind the head and dies over a third of the circle.
colors: const [
Color(0x00000000),
Color(0x148F0F06),
Color(0x4D8F0F06),
Color(0x00000000),
],
stops: const [0.0, 0.2, 0.33, 0.34],
transform: GradientRotation(angle - math.pi / 2),
).createShader(rect),
);
// The head, a touch brighter than the arc it leads.
canvas.drawCircle(
centre + Offset(math.cos(angle - math.pi / 2), math.sin(angle - math.pi / 2)) * r,
2.6,
Paint()
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2.4)
..color = DmColors.brand.withValues(alpha: 0.75),
);
}
@override
bool shouldRepaint(_RadarPainter old) => old.t != t;
}

View File

@@ -28,6 +28,20 @@ enum DmButtonKind {
/// does not fade the crimson — it goes to the sunken neutral, because a
/// half-opacity red still reads as "press me" and this design uses exactly one
/// red for exactly one meaning.
///
/// ── Why the primary is lit (2026-09-22) ──
///
/// It was a flat crimson rectangle at the field radius, which is what a button
/// looks like when nobody has designed it. The BOOK sphere on Home is lit from
/// the upper left; a committing action that is not lit the same way reads as a
/// different material, and the app ends up with one crafted object and a row of
/// plain boxes underneath it.
///
/// So the primary now takes the sphere's treatment, dialled right down: the
/// same directional gradient, a white rim along the top edge where the light
/// would catch, and the crimson glow it already had. It also finally uses
/// [DmRadius.book] — the token that exists for exactly this and was never
/// wired up, leaving every CTA at the 12pt field corner.
class DmButton extends StatefulWidget {
const DmButton({
super.key,
@@ -78,7 +92,9 @@ class _DmButtonState extends State<DmButton> {
final Color foreground;
BoxBorder? border;
List<BoxShadow>? shadow;
Gradient? gradient;
double height = widget.height ?? DmSpace.button;
double radius = DmRadius.book;
switch (widget.kind) {
case DmButtonKind.primary:
@@ -87,6 +103,16 @@ class _DmButtonState extends State<DmButton> {
: (_down ? DmColors.brandPress : DmColors.brand);
foreground = _enabled ? Colors.white : DmColors.ink3;
shadow = _enabled && !_down ? DmShadow.lift : null;
// Lit like the sphere, and only while it can be pressed: a disabled
// control that still catches the light is a control that is lying.
gradient = _enabled && !_down
? const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFA6180B), DmColors.brand, Color(0xFF7A0C04)],
stops: [0.0, 0.55, 1.0],
)
: null;
case DmButtonKind.outline:
background = _down ? DmColors.surfaceAlt : DmColors.surface;
foreground = widget.danger ? DmColors.danger : DmColors.ink;
@@ -102,6 +128,7 @@ class _DmButtonState extends State<DmButton> {
background = Colors.transparent;
foreground = widget.danger ? DmColors.brand : DmColors.ink2;
height = widget.height ?? DmSpace.tap;
radius = DmRadius.sm;
}
final content = Row(
@@ -141,24 +168,45 @@ class _DmButtonState extends State<DmButton> {
],
);
// Presses translate rather than scale: the design's buttons sit on a
// hairline grid, and scaling a 1px border makes it shimmer.
final button = AnimatedSlide(
offset: _down && _enabled ? const Offset(0, 0.018) : Offset.zero,
final filled = widget.kind == DmButtonKind.primary;
// Presses translate rather than scale for anything with an edge: these
// buttons sit on a hairline grid, and scaling a 1px border makes it
// shimmer. The primary has no border, so it also takes a touch of scale —
// which is what makes a filled surface feel pressed rather than nudged.
final button = AnimatedScale(
scale: _down && _enabled && filled ? 0.985 : 1,
duration: DmMotion.fast,
curve: DmMotion.ease,
child: AnimatedContainer(
child: AnimatedSlide(
offset: _down && _enabled ? const Offset(0, 0.018) : Offset.zero,
duration: DmMotion.fast,
constraints: BoxConstraints(minHeight: height),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: background,
border: border,
borderRadius: DmRadius.all(DmRadius.sm),
boxShadow: shadow,
curve: DmMotion.ease,
child: AnimatedContainer(
duration: DmMotion.fast,
constraints: BoxConstraints(minHeight: height),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: gradient == null ? background : null,
gradient: gradient,
border: border,
borderRadius: DmRadius.all(radius),
boxShadow: shadow,
),
// The rim the light catches. Drawn in the foreground so the gradient
// cannot cover it, and only along the top — a full outline would
// read as a border and flatten the fill back out.
foregroundDecoration: gradient == null
? null
: BoxDecoration(
borderRadius: DmRadius.all(radius),
border: const Border(
top: BorderSide(color: Color(0x33FFFFFF), width: 1.2),
),
),
alignment: Alignment.center,
child: content,
),
alignment: Alignment.center,
child: content,
),
);
@@ -173,7 +221,14 @@ class _DmButtonState extends State<DmButton> {
onTapCancel: _enabled ? () => setState(() => _down = false) : null,
onTap: _enabled
? () {
HapticFeedback.selectionClick();
// A primary action commits to something. Everything else is
// navigation or a change of mind, and a medium impact for
// those makes the one that matters feel ordinary.
if (filled) {
HapticFeedback.mediumImpact();
} else {
HapticFeedback.selectionClick();
}
widget.onPressed!();
}
: null,

View File

@@ -3,12 +3,26 @@ import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../tokens.dart';
import 'buttons.dart';
/// Top bar for pushed screens: a square back affordance, a quiet title, an
/// optional trailing slot (usually the booking reference in mono), and the
/// booking step indicator.
class DmTopBar extends StatelessWidget implements PreferredSizeWidget {
///
/// ── It knows whether anything is under it ──
///
/// The bar used to be a flat slab of canvas, the same colour as the page, with
/// no line under it. Content scrolled beneath it and simply disappeared — an
/// address row would slide up and vanish behind a back arrow with nothing to
/// say it had gone anywhere. A bar that never changes is not a layer; it is
/// paint.
///
/// It listens to the [ScrollNotificationObserver] that [Scaffold] installs
/// around its body — the same mechanism Material's own `AppBar` uses for
/// `scrolledUnderElevation` — and the moment a list has scrolled under it, the
/// canvas turns to surface and a hairline fades in along the bottom. At the top
/// of a page it is invisible again. Nothing is announced, nothing moves; the
/// screen just stops looking like one plane.
class DmTopBar extends StatefulWidget implements PreferredSizeWidget {
const DmTopBar({
super.key,
this.title,
@@ -38,17 +52,67 @@ class DmTopBar extends StatelessWidget implements PreferredSizeWidget {
final VoidCallback? onBack;
final Color background;
/// The +1 is the hairline. A transparent [BorderSide] still measures 1pt, so
/// the bar is the same height scrolled or not — but it is a point taller than
/// the row inside it, and without this the Column overflows by exactly that
/// on every screen with a top bar.
@override
Size get preferredSize => Size.fromHeight(56 + (progress == null ? 0 : 42));
Size get preferredSize => Size.fromHeight(57 + (progress == null ? 0 : 42));
@override
State<DmTopBar> createState() => _DmTopBarState();
}
class _DmTopBarState extends State<DmTopBar> {
ScrollNotificationObserverState? _observer;
bool _under = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_observer?.removeListener(_onScroll);
_observer = ScrollNotificationObserver.maybeOf(context);
_observer?.addListener(_onScroll);
}
@override
void dispose() {
_observer?.removeListener(_onScroll);
_observer = null;
super.dispose();
}
void _onScroll(ScrollNotification notification) {
// The predicate is what keeps a horizontal strip inside the page — or a
// list inside a sheet over it — from driving the bar behind them.
if (!defaultScrollNotificationPredicate(notification)) return;
if (notification is! ScrollUpdateNotification &&
notification is! ScrollMetricsNotification) {
return;
}
// A few points of slack: a list that rests one pixel off the top, or a
// bounce at the end of an overscroll, should not flicker the line on.
final under = notification.metrics.extentBefore > 4;
if (under != _under && mounted) setState(() => _under = under);
}
@override
Widget build(BuildContext context) {
final current = progress == null || steps == null
final current = widget.progress == null || widget.steps == null
? 0
: (progress! * steps!).round().clamp(0, steps!);
: (widget.progress! * widget.steps!).round().clamp(0, widget.steps!);
return Material(
color: background,
return AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
decoration: BoxDecoration(
color: _under ? DmColors.surface : widget.background,
border: Border(
bottom: BorderSide(
color: _under ? DmColors.border : Colors.transparent,
),
),
),
child: SafeArea(
bottom: false,
child: Column(
@@ -58,39 +122,37 @@ class DmTopBar extends StatelessWidget implements PreferredSizeWidget {
height: 56,
child: Row(
children: [
const SizedBox(width: DmSpace.pad - 12),
DmIconButton(
icon: LucideIcons.arrowLeft,
tooltip: 'Back',
onPressed: onBack ?? () => Navigator.of(context).maybePop(),
),
const SizedBox(width: 6),
const SizedBox(width: DmSpace.pad - 14),
DmBackButton(onPressed: widget.onBack),
const SizedBox(width: 4),
Expanded(
child: title == null
child: widget.title == null
? const SizedBox.shrink()
: Text(
title!,
style: DmText.cardTitle.copyWith(
fontSize: 15.5,
letterSpacing: -0.31,
),
widget.title!,
style: DmText.heading.copyWith(fontSize: 17),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
?trailing,
?widget.trailing,
const SizedBox(width: DmSpace.pad),
],
),
),
if (progress != null)
if (widget.progress != null)
Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 16),
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
16,
),
child: DmStepBar(
progress: progress!,
steps: steps,
progress: widget.progress!,
steps: widget.steps,
current: current,
label: stepLabel,
label: widget.stepLabel,
),
),
],
@@ -100,6 +162,49 @@ class DmTopBar extends StatelessWidget implements PreferredSizeWidget {
}
}
/// Back.
///
/// ── Why it lost its box ──
///
/// It was a 40pt white square with a hairline border and a 12pt corner, which
/// on a canvas of white bordered cards read as *a card containing an arrow*
/// rather than as a way out of the screen. On the send screen, where the title
/// is now the route underneath it, that left a small empty card alone in the
/// top-left corner of an otherwise open page.
///
/// A back affordance is the one control in an app that needs no explaining and
/// no container. It is the glyph, a 48pt target around it, and a circular
/// ripple in the brand wash when it is pressed.
class DmBackButton extends StatelessWidget {
const DmBackButton({super.key, this.onPressed});
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
label: 'Back',
child: SizedBox(
width: DmSpace.tap,
height: DmSpace.tap,
child: InkResponse(
onTap: onPressed ?? () => Navigator.of(context).maybePop(),
radius: 24,
highlightShape: BoxShape.circle,
splashColor: DmColors.brandSoft,
highlightColor: DmColors.brandSoft,
child: const Icon(
LucideIcons.arrowLeft,
size: 22,
color: DmColors.ink,
),
),
),
);
}
}
/// Booking progress.
///
/// With [steps] it draws that many segments, captioned "Step n of m" in mono;
@@ -113,6 +218,7 @@ class DmStepBar extends StatelessWidget {
this.current = 0,
this.label,
this.onFill = false,
this.caption = true,
});
final double progress;
@@ -124,6 +230,14 @@ class DmStepBar extends StatelessWidget {
/// lightened white and the fill is solid white.
final bool onFill;
/// Whether to print "Step n of m" above the segments.
///
/// True inside the booking flow, where the customer is answering questions
/// and "how many left" is the thing they want. False on tracking, where the
/// same segments describe a journey nobody is working through — there,
/// counting the stages turns a parcel into a progress bar with homework.
final bool caption;
@override
Widget build(BuildContext context) {
final track = onFill ? const Color(0x52FFFFFF) : DmColors.border;
@@ -171,7 +285,7 @@ class DmStepBar extends StatelessWidget {
],
);
if (label == null && steps == null) return bar;
if (!caption || (label == null && steps == null)) return bar;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -311,8 +425,19 @@ class DmBrandHeader extends StatelessWidget {
borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(DmRadius.header),
),
child: ColoredBox(
color: DmColors.brand,
child: DecoratedBox(
// Lit from the top-left like every other brand object in the app, so
// the crimson field has a direction instead of sitting flat. The spread
// is deliberately narrow — a header that reads as a gradient rather
// than as a colour has stopped being the brand.
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFA0150A), DmColors.brand, Color(0xFF7A0C04)],
stops: [0.0, 0.52, 1.0],
),
),
child: CustomPaint(
painter: const DmArcField(),
child: SafeArea(

View File

@@ -32,6 +32,7 @@ class DmTextField extends StatefulWidget {
this.digitsOnly = false,
this.mono = false,
this.onClear,
this.floating = false,
});
/// The caption above the field, or null for a field that stands alone.
@@ -59,6 +60,16 @@ class DmTextField extends StatefulWidget {
/// Shows a clear button on the right when non-null.
final VoidCallback? onClear;
/// Puts [label] inside the field while it is empty, and floats it above once
/// there is something to caption.
///
/// An empty field under its own caption is two rows to ask one question, and
/// on a screen with two of them that is four rows of chrome around two
/// slabs of white. Floating, the question sits where the answer will go — so
/// the field is never blank and the caption only appears when it has a job,
/// which is reminding the customer what they already typed.
final bool floating;
@override
State<DmTextField> createState() => _DmTextFieldState();
}
@@ -70,10 +81,16 @@ class _DmTextFieldState extends State<DmTextField> {
void initState() {
super.initState();
_node.addListener(() => setState(() {}));
if (widget.floating) {
widget.controller.addListener(_onText);
}
}
void _onText() => setState(() {});
@override
void dispose() {
if (widget.floating) widget.controller.removeListener(_onText);
_node.dispose();
super.dispose();
}
@@ -81,6 +98,12 @@ class _DmTextFieldState extends State<DmTextField> {
@override
Widget build(BuildContext context) {
final focused = _node.hasFocus;
// A floating label is above the field only once it has a reason to be:
// something typed, or a cursor in it.
final lifted = !widget.floating ||
focused ||
widget.controller.text.isNotEmpty;
final textStyle = widget.mono
? DmText.monoLg.copyWith(fontSize: 15.5, color: DmColors.ink)
: DmText.field;
@@ -90,25 +113,33 @@ class _DmTextFieldState extends State<DmTextField> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.label != null) ...[
Text.rich(
TextSpan(
style: DmText.label,
children: [
TextSpan(text: widget.label),
if (widget.optional)
const TextSpan(
text: ' Optional',
style: TextStyle(
color: DmColors.ink4,
fontWeight: FontWeight.w500,
if (widget.label != null)
AnimatedSize(
duration: DmMotion.fast,
curve: DmMotion.ease,
alignment: Alignment.bottomLeft,
child: !lifted
? const SizedBox(width: double.infinity)
: Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text.rich(
TextSpan(
style: DmText.label,
children: [
TextSpan(text: widget.label),
if (widget.optional)
const TextSpan(
text: ' Optional',
style: TextStyle(
color: DmColors.ink4,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
],
),
),
const SizedBox(height: 8),
],
AnimatedContainer(
duration: DmMotion.fast,
curve: DmMotion.ease,
@@ -172,7 +203,12 @@ class _DmTextFieldState extends State<DmTextField> {
],
decoration: InputDecoration(
isDense: true,
hintText: widget.hint,
// While the label is down inside the field it IS the
// placeholder; [hint] only gets the slot once the label
// has floated out of it.
hintText: lifted
? widget.hint
: (widget.label ?? widget.hint),
hintStyle: DmText.field.copyWith(
color: DmColors.ink3,
fontWeight: FontWeight.w500,
@@ -231,8 +267,14 @@ class DmRow extends StatelessWidget {
final VoidCallback? onTap;
final bool showChevron;
/// The glyph in the row's leading tile. Account's rows all carry one, so the
/// list can be scanned by shape before it is read.
/// The row's leading glyph. Account's rows all carry one, so the list can be
/// scanned by shape before it is read.
///
/// It used to sit in a washed crimson tile. Four of those down a settings
/// list put four brand-coloured blocks on the quietest screen in the app,
/// and each one cost 56pt of the width the row's value needed — which is why
/// "38 Mettupalayam Road" rendered as "38 Mettupal…". The glyph alone scans
/// just as well.
final IconData? icon;
@override
@@ -249,15 +291,16 @@ class DmRow extends StatelessWidget {
child: Row(
children: [
if (icon != null) ...[
DmTile(icon: icon, tone: DmTileTone.wash),
const SizedBox(width: 12),
Icon(icon, size: 19, color: DmColors.ink3),
const SizedBox(width: 14),
],
// The label is what the row IS, so it never wraps and never
// gives up width; the value is context and ellipsizes. A saved
// address as a value would otherwise push "Saved addresses" onto
// two lines.
Expanded(
flex: 3,
// The label takes the width it needs and the value takes what
// is left. A fixed 3:2 split gave the label room it was not
// using — "Saved addresses" is short — and rendered the value
// beside it as "38 Mettupal…", which is a truncation nobody can
// read anything out of. The label stays Flexible so a long one
// at large text sizes can still give way.
Flexible(
child: Text(
label,
style: DmText.cardTitle,
@@ -266,9 +309,8 @@ class DmRow extends StatelessWidget {
),
),
if (value != null) ...[
const SizedBox(width: 10),
Flexible(
flex: 2,
const SizedBox(width: 12),
Expanded(
child: Text(
value!,
style: DmText.value,
@@ -328,3 +370,64 @@ class DmSwitch extends StatelessWidget {
);
}
}
/// The search field used by every picker sheet.
///
/// It was written inline inside the pickup sheet and then wanted again by the
/// destination sheet, which is the moment a style becomes a component. Filled
/// white on the canvas rather than outlined: a search field that is the only
/// control above a list does not need an edge to be found, and the fill is what
/// makes it read as a place to type rather than as the first row of the list.
class DmSearchField extends StatelessWidget {
const DmSearchField({
super.key,
required this.controller,
required this.hint,
this.onChanged,
this.autofocus = false,
});
final TextEditingController controller;
final String hint;
final ValueChanged<String>? onChanged;
final bool autofocus;
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
onChanged: onChanged,
autofocus: autofocus,
cursorColor: DmColors.brand,
style: DmText.field,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: hint,
hintStyle: DmText.field.copyWith(
color: DmColors.ink3,
fontWeight: FontWeight.w500,
),
prefixIcon: const Icon(
LucideIcons.search,
size: 20,
color: DmColors.ink3,
),
filled: true,
fillColor: DmColors.surface,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
border: _border(DmColors.border, 1),
enabledBorder: _border(DmColors.border, 1),
focusedBorder: _border(DmColors.brand, 1.5),
),
);
}
static OutlineInputBorder _border(Color color, double width) =>
OutlineInputBorder(
borderRadius: DmRadius.all(DmRadius.sm),
borderSide: BorderSide(color: color, width: width),
);
}

View File

@@ -1,10 +1,10 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../data/models.dart';
import '../format.dart';
import '../tokens.dart';
import 'buttons.dart';
import 'cards.dart';
/// The customer's journey: seven milestones, not nine backend states.
///
@@ -41,7 +41,7 @@ class DmMilestones extends StatelessWidget {
/// Show at most this many rows, newest first. Null shows the whole journey.
final int? maxRows;
/// Tapped from the "All n stages" action. Null hides the action.
/// Tapped from the "Full timeline" action. Null hides the action.
final VoidCallback? onExpand;
@override
@@ -51,58 +51,71 @@ class DmMilestones extends StatelessWidget {
final milestones = CustomerMilestone.values;
final currentIndex = currentMilestone.index;
// Newest first: the current milestone, then everything already done, then
// — only if there is room — what is still to come.
final ordered = [
for (var i = currentIndex; i >= 0; i--) i,
for (var i = currentIndex + 1; i < milestones.length; i++) i,
];
final shown = maxRows == null ? ordered : ordered.take(maxRows!).toList();
// ── Chronological, because the line is the progress ──
//
// This ran newest-first, on the argument that "where is my parcel right
// now" should not be at the bottom of the list. That was right when the
// rail was a list of rows and wrong now that it is a rail: the travelled
// part of the spine is drawn crimson and the rest grey, and a reversed
// order makes a filling line read backwards. The answer is also no longer
// down here — the crimson card at the top of tracking says it outright,
// so this is free to answer the other question, which is *how far along*.
//
// Compressed, it shows the current milestone and the two before it: recent
// activity stays on screen without the rail claiming the whole page.
final ordered = [for (var i = 0; i < milestones.length; i++) i];
final shown = maxRows == null
? ordered
: ordered
.sublist(
(currentIndex - maxRows! + 1).clamp(0, milestones.length),
(currentIndex + 1).clamp(1, milestones.length),
)
.toList();
return DmCard(
children: [
DmCardCell(
padding: const EdgeInsets.fromLTRB(16, 15, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Row(
children: [
Expanded(
child: Text(
title.toUpperCase(),
style: DmText.eyebrow.copyWith(color: DmColors.brand),
),
),
if (onExpand != null && shown.length < ordered.length)
DmTextAction(
label: 'All ${milestones.length} stages',
onPressed: onExpand,
minHeight: 24,
)
else
Text(
'Step ${currentIndex + 1} of ${milestones.length}',
style: DmText.monoSm,
),
],
Expanded(
child: Text(
title.toUpperCase(),
// Neutral. It heads a rail that is deliberately receding,
// beside a crimson action — two accents on a group nobody
// is meant to study.
style: DmText.eyebrow,
),
),
const SizedBox(height: 14),
for (var n = 0; n < shown.length; n++)
_MilestoneRow(
milestone: milestones[shown[n]],
done: shown[n] < currentIndex,
isCurrent: shown[n] == currentIndex,
last: n == shown.length - 1,
time: shown[n] <= currentIndex
? relativeTime(booking.timeOf(milestones[shown[n]].firstStage))
: '',
detail: shown[n] == currentIndex ? _detailFor(current) : null,
if (onExpand != null && shown.length < ordered.length)
DmTextAction(
// Was "All 7 stages". The number counts something the
// customer has no model for — they do not know a parcel has
// seven of anything — and it changes with the backend's own
// vocabulary.
label: 'Full timeline',
onPressed: onExpand,
minHeight: 24,
),
],
),
),
],
const SizedBox(height: 16),
for (var n = 0; n < shown.length; n++)
_MilestoneRow(
milestone: milestones[shown[n]],
done: shown[n] < currentIndex,
isCurrent: shown[n] == currentIndex,
first: n == 0,
last: n == shown.length - 1,
time: shown[n] <= currentIndex
? relativeTime(booking.timeOf(milestones[shown[n]].firstStage))
: '',
detail: shown[n] == currentIndex ? _detailFor(current) : null,
),
],
),
);
}
@@ -130,11 +143,29 @@ class DmMilestones extends StatelessWidget {
};
}
/// One milestone on the spine.
///
/// ── The line is the progress bar ──
///
/// Every connector used to be the same grey hairline, which made the rail a
/// list of dots that happened to be joined up: to work out how far along a
/// parcel was you had to find the filled dots and count them. The segment
/// *above* a node is now crimson when that node has been reached and grey when
/// it has not, so the line fills from the top as the parcel moves and "how far
/// along" is answered before a single word is read.
///
/// ── Identity left, time right ──
///
/// The same split the Miler app's route rail uses: what happened on the left,
/// when it happened on the right, on one baseline. Four stacked left-aligned
/// lines make the eye walk down the block to find the time; two columns answer
/// both in one fixation, and down a rail the times stack into a scan column.
class _MilestoneRow extends StatelessWidget {
const _MilestoneRow({
required this.milestone,
required this.done,
required this.isCurrent,
required this.first,
required this.last,
required this.time,
this.detail,
@@ -143,6 +174,7 @@ class _MilestoneRow extends StatelessWidget {
final CustomerMilestone milestone;
final bool done;
final bool isCurrent;
final bool first;
final bool last;
final String time;
final String? detail;
@@ -150,22 +182,42 @@ class _MilestoneRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final upcoming = !done && !isCurrent;
final reached = done || isCurrent;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 12,
width: 14,
child: Column(
children: [
const SizedBox(height: 3),
// The segment above this node, which is what says whether the
// parcel has got here. Absent on the first row: a line running
// up out of the top of the rail has nowhere to come from.
SizedBox(
height: 6,
child: first
? null
: Center(
child: Container(
width: 2,
color: reached
? DmColors.brand
: DmColors.border,
),
),
),
_Marker(done: done, isCurrent: isCurrent),
if (!last)
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Container(width: 1.5, color: DmColors.border),
child: Center(
child: Container(
width: 2,
// Crimson only as far as the parcel has come. The
// segment below the current node is the road ahead.
color: done ? DmColors.brand : DmColors.border,
),
),
),
],
@@ -174,30 +226,47 @@ class _MilestoneRow extends StatelessWidget {
const SizedBox(width: 14),
Expanded(
child: Padding(
padding: EdgeInsets.only(bottom: last ? 0 : 15),
padding: EdgeInsets.only(top: 2, bottom: last ? 0 : 18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Expanded(
child: Text(
milestone.label,
style: upcoming
? DmText.small.copyWith(color: DmColors.ink3)
: DmText.cardTitle.copyWith(fontSize: 14.5),
// The one happening now is the only thing on the
// rail set in the brand colour, and the only one at
// full weight. Everything behind it is history and
// everything ahead of it has not happened.
style: switch ((isCurrent, upcoming)) {
(true, _) => DmText.cardTitle.copyWith(
fontSize: 15.5,
color: DmColors.brand,
),
(_, true) => DmText.small.copyWith(
color: DmColors.ink4,
),
_ => DmText.cardTitle.copyWith(fontSize: 14.5),
},
),
),
if (time.isNotEmpty) ...[
const SizedBox(width: 10),
Text(time, style: DmText.monoSm),
Text(
time,
style: DmText.monoSm.copyWith(
color: isCurrent ? DmColors.brand : DmColors.ink3,
),
),
],
],
),
if (detail != null) ...[
const SizedBox(height: 4),
const SizedBox(height: 3),
Text(detail!, style: DmText.small),
],
],
@@ -210,6 +279,11 @@ class _MilestoneRow extends StatelessWidget {
}
}
/// The node itself.
///
/// Three states and three shapes, so the rail is readable without colour: a
/// filled disc behind you, a ring with a lit centre where you are, a hollow
/// outline ahead.
class _Marker extends StatelessWidget {
const _Marker({required this.done, required this.isCurrent});
@@ -220,23 +294,50 @@ class _Marker extends StatelessWidget {
Widget build(BuildContext context) {
if (isCurrent) {
return Container(
width: 12,
height: 12,
width: 14,
height: 14,
alignment: Alignment.center,
decoration: BoxDecoration(
color: DmColors.surface,
shape: BoxShape.circle,
border: Border.all(color: DmColors.brand, width: 2),
boxShadow: const [
BoxShadow(color: DmColors.brandSoft, spreadRadius: 3.5),
],
),
child: Container(
width: 5,
height: 5,
decoration: const BoxDecoration(
color: DmColors.brand,
shape: BoxShape.circle,
),
),
);
}
if (done) {
return Container(
width: 14,
height: 14,
alignment: Alignment.center,
decoration: const BoxDecoration(
color: DmColors.brand,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(color: DmColors.brandSoft, spreadRadius: 4),
],
),
child: const Icon(
LucideIcons.check,
size: 9,
color: Colors.white,
),
);
}
return Container(
width: 12,
height: 12,
width: 14,
height: 14,
decoration: BoxDecoration(
color: done ? DmColors.brand : DmColors.border,
color: DmColors.canvas,
shape: BoxShape.circle,
border: Border.all(color: DmColors.border, width: 2),
),
);
}

View File

@@ -394,67 +394,3 @@ class _MapLabel extends StatelessWidget {
);
}
}
/// Radar sweep shown while the backend is matching a Miler.
class DmRadarPulse extends StatefulWidget {
const DmRadarPulse({super.key, this.size = 150, this.child});
final double size;
final Widget? child;
@override
State<DmRadarPulse> createState() => _DmRadarPulseState();
}
class _DmRadarPulseState extends State<DmRadarPulse>
with SingleTickerProviderStateMixin {
late final AnimationController _c = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2600),
)..repeat();
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: widget.size,
height: widget.size,
child: AnimatedBuilder(
animation: _c,
builder: (context, child) {
return Stack(
alignment: Alignment.center,
children: [
for (var i = 0; i < 3; i++) _ring(i),
?child,
],
);
},
child: widget.child,
),
);
}
Widget _ring(int index) {
final t = (_c.value + index / 3) % 1;
return Opacity(
opacity: (1 - t) * 0.5,
child: Container(
width: widget.size * (0.35 + t * 0.65),
height: widget.size * (0.35 + t * 0.65),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: DmColors.brand.withValues(alpha: 0.45),
width: 1.5,
),
),
),
);
}
}