Files
Thiru-tenext 86b6af48c2 Redesign on the Stitch reference, in Plus Jakarta Sans
Four passes, and the shape they landed on.

The type face is Plus Jakarta Sans (variable, wght 200-800), which brings a
fix with it: it carries the rupee glyph and Switzer does not, so prices stop
being set in Geist Mono to work around a missing character. Mono stays where
it is earned - references and phone numbers, read digit by digit.

Surfaces lift rather than outline. Cards carry two very soft shadow layers
instead of a hairline, because eight outlined boxes down a screen read as a
wireframe. The tab bar floats as a pill again for the same reason it was
right to: it is now the same kind of object as everything above it.

Screens:

* Home is the greeting, the address, the sphere and one card. The card lost
  its progress bar - a filling line says "wait", and a parcel two days into
  a journey is not something anyone is waiting through - and gained the size
  that buys.
* Orders cards are four bands: identity, destination, route, and whatever is
  happening right now. Plus a search field, because the list is the archive.
* Tracking leads with the state at display size, then TRIP MILESTONES with a
  step counter, then the courier.
* Review is a route thread over two particular cards.
* Account opens on the person: avatar, name, and two counted figures.

Three real bugs the redesign surfaced:

* Quick dispatch handed `loadCities()` straight to a FutureBuilder, so the
  catalogue was refetched on every rebuild and Home never settled.
* Order cards showed the whole visit's weight on one destination's row -
  somebody else's parcel. Per group now, and only once actually weighed.
* The pickup window was printed beside "In transit", where it reads as a
  delivery time nobody promised.

Nothing invented. The reference shows EXPRESS PRIORITY, CARBON OFFSET,
CONCIERGE ELITE and hub-to-hub routing; this backend sends none of them, so
they are absent rather than mocked up.

flutter analyze: clean. flutter test: 88 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
2026-09-24 11:08:25 +05:30

839 lines
26 KiB
Dart

import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../tokens.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.
///
/// ── 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,
this.trailing,
this.progress,
this.steps,
this.stepLabel,
this.onBack,
this.background = DmColors.canvas,
});
final String? title;
final Widget? trailing;
/// 0..1 — omit to hide the step indicator.
final double? progress;
/// When given, the indicator is drawn as [steps] separate segments rather
/// than one continuous rail. Discrete steps are countable; a filled bar is
/// only a proportion, and "how many screens until I am done" is the question
/// a customer is actually asking.
final int? steps;
/// The line above the segments — "Step 2 of 4 · Destination".
final String? stepLabel;
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(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 = widget.progress == null || widget.steps == null
? 0
: (widget.progress! * widget.steps!).round().clamp(0, widget.steps!);
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(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 56,
child: Row(
children: [
const SizedBox(width: DmSpace.pad - 14),
DmBackButton(onPressed: widget.onBack),
const SizedBox(width: 4),
Expanded(
child: widget.title == null
? const SizedBox.shrink()
: Text(
widget.title!,
style: DmText.heading.copyWith(fontSize: 17),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
?widget.trailing,
const SizedBox(width: DmSpace.pad),
],
),
),
if (widget.progress != null)
Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
0,
DmSpace.pad,
16,
),
child: DmStepBar(
progress: widget.progress!,
steps: widget.steps,
current: current,
label: widget.stepLabel,
),
),
],
),
),
);
}
}
/// 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;
/// without, it falls back to a single filled rail for callers that only know a
/// fraction.
class DmStepBar extends StatelessWidget {
const DmStepBar({
super.key,
required this.progress,
this.steps,
this.current = 0,
this.label,
this.onFill = false,
this.caption = true,
});
final double progress;
final int? steps;
final int current;
final String? label;
/// Drawn on a crimson field (the live-tracking card), where the track is a
/// 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;
final fill = onFill ? Colors.white : DmColors.brand;
final bar = steps == null
? ClipRRect(
borderRadius: DmRadius.all(DmRadius.pill),
child: SizedBox(
height: 3,
child: Stack(
children: [
Positioned.fill(child: ColoredBox(color: track)),
FractionallySizedBox(
widthFactor: progress.clamp(0, 1),
child: AnimatedContainer(
duration: DmMotion.slow,
curve: DmMotion.ease,
decoration: BoxDecoration(
color: fill,
borderRadius: DmRadius.all(DmRadius.pill),
),
),
),
],
),
),
)
: Row(
children: [
for (var i = 0; i < steps!; i++) ...[
if (i > 0) const SizedBox(width: 4),
Expanded(
child: AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
height: 3,
decoration: BoxDecoration(
color: i < current ? fill : track,
borderRadius: DmRadius.all(DmRadius.pill),
),
),
),
],
],
);
if (!caption || (label == null && steps == null)) return bar;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (label != null || steps != null) ...[
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (steps != null)
Text.rich(
TextSpan(
style: DmText.monoSm.copyWith(
color: onFill ? DmColors.onBrand3 : DmColors.ink3,
),
children: [
const TextSpan(text: 'Step '),
TextSpan(
text: '$current',
style: TextStyle(color: onFill ? Colors.white : DmColors.brand),
),
TextSpan(text: ' of ${steps!}'),
],
),
),
const Spacer(),
if (label != null)
Flexible(
child: Text(
label!,
textAlign: TextAlign.right,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.label.copyWith(
fontSize: 12.5,
color: onFill ? DmColors.onBrand3 : DmColors.ink,
),
),
),
],
),
const SizedBox(height: 9),
],
bar,
],
);
}
}
/// Sticky bottom action area.
///
/// `edge: true` lifts it onto white with a hairline above — used when the
/// content behind it scrolls, so the action never looks like the end of the
/// list. On a short screen the canvas variant keeps the page quiet.
class DmFooter extends StatelessWidget {
const DmFooter({
super.key,
required this.children,
this.edge = false,
this.background,
this.divider = false,
});
final List<Widget> children;
/// White bed, hairline above.
final bool edge;
final Color? background;
/// Retained for call-site compatibility; superseded by [edge].
final bool divider;
@override
Widget build(BuildContext context) {
final lifted = edge || divider;
return DecoratedBox(
decoration: BoxDecoration(
color: background ?? (lifted ? DmColors.surface : DmColors.canvas),
border: lifted
? const Border(top: BorderSide(color: DmColors.border))
: null,
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 14, DmSpace.pad, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < children.length; i++) ...[
if (i > 0) const SizedBox(height: 4),
children[i],
],
],
),
),
),
);
}
}
/// The head of a tab root: an eyebrow, a title, an optional action, and the
/// control that belongs to this destination.
///
/// ── It stopped being a crimson field ──
///
/// This was 260pt of solid brand on Home, Orders and Account — three of the
/// app's five screens opening on a wall of it, with white type, a tinted rose
/// for the secondary line, a recessed track for the address and a lightened
/// control on top. Every one of those tokens existed only to survive the
/// crimson, and the accent that is supposed to mean *do this* was the
/// background of half the product.
///
/// It is the canvas now, with ink type. What is left of the brand on these
/// screens is the sphere, the primary button, and a live dot — which is what
/// an accent is for.
///
/// The arcs survive at a fraction of their old opacity: they were struck from
/// the mark's geometry and they are the one thing here that was never the
/// colour doing the work.
class DmBrandHeader extends StatelessWidget {
const DmBrandHeader({
super.key,
required this.title,
this.eyebrow,
this.leading,
this.action,
this.child,
});
final String title;
final String? eyebrow;
/// Sits at the top-left of the header — the avatar on Account.
final Widget? leading;
/// Top-right — refresh, help.
final Widget? action;
/// The control that belongs to this destination, under the title.
final Widget? child;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(color: DmColors.canvas),
child: CustomPaint(
painter: const DmArcField(opacity: 0.035, colour: DmColors.brand),
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.pad,
2,
DmSpace.pad,
14,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 48,
child: Row(children: [?leading, const Spacer(), ?action]),
),
const SizedBox(height: 6),
if (eyebrow != null) ...[
Text(eyebrow!.toUpperCase(), style: DmText.eyebrow),
const SizedBox(height: 6),
],
Text(title, style: DmText.headerTitle),
if (child != null) ...[const SizedBox(height: 16), child!],
],
),
),
),
),
);
}
}
/// Thin arc strokes struck from the mark's geometry, at the opacity the design
/// uses. Decorative only — it never carries meaning, so it is not announced.
class DmArcField extends CustomPainter {
const DmArcField({this.opacity = 0.15, this.colour = Colors.white});
final double opacity;
/// White on a crimson field; crimson, very faint, on the canvas.
final Color colour;
@override
void paint(Canvas canvas, Size size) {
// The arcs are authored on a 340-square field anchored to the top-right,
// then scaled — so the same geometry reads identically on a 210pt Orders
// header and a 260pt Account one.
const field = 340.0;
final scale = size.width / 390 * 1.0;
canvas.save();
canvas.translate(size.width - field * scale + 62 * scale, -72 * scale);
canvas.scale(scale);
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.5 / scale
..color = colour.withValues(alpha: opacity);
canvas.drawCircle(const Offset(250, 90), 120, paint);
canvas.drawCircle(const Offset(250, 90), 170, paint);
for (final (y1, y2, cx, cy) in const [
(300.0, 40.0, 60.0, 300.0),
(330.0, 60.0, 70.0, 330.0),
]) {
final path = Path()
..moveTo(-20, y1)
..cubicTo(cx, cy, field - 40, y2 + 140, field, y2);
canvas.drawPath(path, paint);
}
canvas.restore();
}
@override
bool shouldRepaint(covariant DmArcField oldDelegate) =>
oldDelegate.opacity != opacity || oldDelegate.colour != colour;
}
/// The recessed strip under a header title that carries one live fact — the
/// address a pickup is coming from, or the customer's phone number.
class DmHeaderTrack extends StatelessWidget {
const DmHeaderTrack({
super.key,
required this.child,
this.leading,
this.trailing,
this.onTap,
});
final Widget child;
final Widget? leading;
final Widget? trailing;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: onTap != null,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
// A bordered white field, not a recess. `onBrandSunken` is 14%
// black, which on the canvas reads as a grey smudge rather than as
// something cut into the surface — a recess only works against a
// colour dark enough to be cut.
decoration: BoxDecoration(
color: DmColors.surface,
borderRadius: DmRadius.all(DmRadius.lg),
border: Border.all(color: DmColors.border),
),
child: Row(
children: [
if (leading != null) ...[leading!, const SizedBox(width: 10)],
Expanded(child: child),
if (trailing != null) ...[const SizedBox(width: 10), trailing!],
],
),
),
),
);
}
}
/// Large title for a screen that has no brand header.
class DmPageTitle extends StatelessWidget {
const DmPageTitle(this.text, {super.key, this.subtitle});
final String text;
final String? subtitle;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 24, DmSpace.pad, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(text, style: DmText.pageTitle),
if (subtitle != null) ...[
const SizedBox(height: 6),
Text(subtitle!, style: DmText.lede),
],
],
),
);
}
}
/// Sentence-case heading above a group of fields or rows.
class DmGroupHead extends StatelessWidget {
const DmGroupHead(this.text, {super.key, this.top = 24});
final String text;
final double top;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(top: top, bottom: 10),
child: Text(text, style: DmText.sectionHead),
);
}
}
/// Screen heading and its supporting line — shared by every booking step.
class DmScreenHeader extends StatelessWidget {
const DmScreenHeader({super.key, required this.title, this.lede, this.leding});
final String title;
final String? lede;
/// Rich alternative to [lede].
final Widget? leding;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(title, style: DmText.title),
if (lede != null) ...[
const SizedBox(height: 8),
Text(lede!, style: DmText.lede),
],
if (leding != null) ...[const SizedBox(height: 10), leding!],
const SizedBox(height: 22),
],
);
}
}
/// The wordmark bar that sits at the top of every tab root.
///
/// ── Why the app grew a permanent header ──
///
/// The three roots each began with their own arrangement: Home with a name,
/// Orders with a title, Account with another name. Nothing on any of them
/// said which app this was, and the two things a customer reaches for from
/// anywhere — find an order, see what changed — had no home at all.
///
/// One bar, on all three. The mark on the left with its dot, a search and a
/// bell on the right. It is 52pt of chrome that makes the three roots read as
/// one product rather than as three screens that happen to share a tab bar.
class DmAppBar extends StatelessWidget {
const DmAppBar({super.key, this.onSearch, this.onNotifications, this.unread = false});
final VoidCallback? onSearch;
final VoidCallback? onNotifications;
/// Puts the brand dot on the bell.
final bool unread;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 6, DmSpace.pad - 4, 2),
child: SizedBox(
height: 46,
child: Row(
children: [
const DmWordmark(),
const Spacer(),
_BarIcon(
icon: LucideIcons.search,
tooltip: 'Search orders',
onPressed: onSearch,
),
_BarIcon(
icon: LucideIcons.bell,
tooltip: 'Notifications',
dot: unread,
onPressed: onNotifications,
),
],
),
),
);
}
}
/// "Doormile ·" — the name, then the mark's dot.
///
/// The dot is the whole logo at this size. A 100pt lockup in a 46pt bar would
/// be a picture of a logo; a word and a full stop in the brand colour is a
/// wordmark, which is what a bar wants.
class DmWordmark extends StatelessWidget {
const DmWordmark({super.key, this.size = 17});
final double size;
@override
Widget build(BuildContext context) {
return Semantics(
label: 'Doormile',
header: true,
excludeSemantics: true,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Doormile',
style: DmText.cardTitle.copyWith(
fontSize: size,
letterSpacing: -0.45,
fontWeight: FontWeight.w700,
fontVariations: const [FontVariation('wght', 700)],
),
),
SizedBox(width: size * 0.22),
Container(
width: size * 0.26,
height: size * 0.26,
decoration: const BoxDecoration(
color: DmColors.brand,
shape: BoxShape.circle,
),
),
],
),
);
}
}
class _BarIcon extends StatelessWidget {
const _BarIcon({
required this.icon,
required this.tooltip,
this.onPressed,
this.dot = false,
});
final IconData icon;
final String tooltip;
final VoidCallback? onPressed;
final bool dot;
@override
Widget build(BuildContext context) {
return Tooltip(
message: tooltip,
child: InkResponse(
onTap: onPressed,
radius: 22,
child: SizedBox(
width: 44,
height: 44,
child: Stack(
alignment: Alignment.center,
children: [
Icon(icon, size: 20, color: DmColors.ink),
if (dot)
Positioned(
top: 11,
right: 11,
child: Container(
width: 7,
height: 7,
decoration: BoxDecoration(
color: DmColors.brand,
shape: BoxShape.circle,
border: Border.all(color: DmColors.canvas, width: 1.5),
),
),
),
],
),
),
),
);
}
}
/// A status mark: a dot, a word, and a tint the colour of the state.
///
/// ── The shape the whole app now says "what is happening" in ──
///
/// It replaces three different objects: the bordered `DmPill`, the bare dot
/// and word on the flat order rows, and the hand-built chip on the live card.
/// One shape, three intensities of the same idea — a coloured dot, uppercase
/// micro-type, and six percent of the dot's colour behind them.
class DmStatusChip extends StatelessWidget {
const DmStatusChip({
super.key,
required this.label,
this.tone,
this.icon,
this.quiet = false,
});
final String label;
/// Defaults to the brand. `ok` for delivered, `ink4` for cancelled.
final Color? tone;
/// Replaces the dot — the lightning bolt on "EXPRESS PRIORITY".
final IconData? icon;
/// A finished state. Same shape, less of it.
final bool quiet;
@override
Widget build(BuildContext context) {
final colour = tone ?? DmColors.brand;
return Container(
padding: EdgeInsets.fromLTRB(icon == null ? 8 : 7, 4.5, 9, 5),
decoration: BoxDecoration(
color: colour.withValues(alpha: quiet ? 0.055 : 0.085),
borderRadius: DmRadius.all(DmRadius.chip),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null)
Icon(icon, size: 11, color: colour)
else
Container(
width: 5.5,
height: 5.5,
decoration: BoxDecoration(color: colour, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Flexible(
child: Text(
label.toUpperCase(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.eyebrow.copyWith(
fontSize: 9.5,
letterSpacing: 0.85,
color: quiet ? DmColors.ink3 : DmColors.ink,
),
),
),
],
),
);
}
}