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
This commit is contained in:
2026-09-24 11:08:25 +05:30
parent 06fa6b797a
commit 86b6af48c2
73 changed files with 3398 additions and 1722 deletions

View File

@@ -60,7 +60,8 @@ class DmBookOrb extends StatefulWidget {
const DmBookOrb({
super.key,
required this.onTap,
this.label = 'BOOK',
this.label = 'BOOK NOW',
this.strapline = 'INSTANT PARCEL',
this.caption,
this.held = false,
});
@@ -80,6 +81,9 @@ class DmBookOrb extends StatefulWidget {
/// second use of this widget elsewhere is not forced to repeat itself.
final String? caption;
/// The small line inside the sphere, under the label.
final String strapline;
@override
State<DmBookOrb> createState() => _DmBookOrbState();
}
@@ -117,8 +121,8 @@ class _DmBookOrbState extends State<DmBookOrb> with TickerProviderStateMixin {
return Semantics(
button: true,
label: widget.caption == null
? widget.label
: '${widget.label}. ${widget.caption}',
? '${widget.label}. ${widget.strapline}'
: '${widget.label}. ${widget.strapline}. ${widget.caption}',
child: Column(
children: [
GestureDetector(
@@ -160,7 +164,11 @@ class _DmBookOrbState extends State<DmBookOrb> with TickerProviderStateMixin {
scale: Tween(begin: 1.0, end: 1.012).animate(
CurvedAnimation(parent: _breath, curve: Curves.easeInOut),
),
child: _Sphere(size: disc, label: widget.label),
child: _Sphere(
size: disc,
label: widget.label,
strapline: widget.strapline,
),
),
),
],
@@ -184,10 +192,15 @@ class _DmBookOrbState extends State<DmBookOrb> with TickerProviderStateMixin {
/// 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});
const _Sphere({
required this.size,
required this.label,
required this.strapline,
});
final double size;
final String label;
final String strapline;
@override
Widget build(BuildContext context) {
@@ -201,7 +214,7 @@ class _Sphere extends StatelessWidget {
// takes its light from.
center: Alignment(-0.38, -0.52),
radius: 1.08,
colors: [Color(0xFFC0230F), DmColors.brand, Color(0xFF5C0802)],
colors: [DmColors.brandLit, DmColors.brand, Color(0xFF5C0802)],
stops: [0.0, 0.5, 1.0],
),
boxShadow: [
@@ -256,28 +269,52 @@ class _Sphere extends StatelessWidget {
),
),
),
// ── The face ──
//
// It was "BOOK / NOW" stacked at 30pt in caps with 3pt of tracking,
// which filled the sphere edge to edge and read as a warning label.
// The reference sets a glyph, a sentence-case line and a small
// strapline — the same three parts every physical button that has
// ever been designed well carries, and the air round them is what
// makes the circle read as an object rather than as a word in a
// red disc.
Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
LucideIcons.navigation,
size: 21,
color: Colors.white,
),
const SizedBox(height: 11),
Text(
label,
textAlign: TextAlign.center,
style: DmText.heading.copyWith(
fontSize: 32,
height: 1,
letterSpacing: 5,
fontWeight: FontWeight.w800,
fontVariations: const [FontVariation('wght', 800)],
fontSize: 23,
height: 1.05,
letterSpacing: -0.5,
fontWeight: FontWeight.w700,
fontVariations: const [FontVariation('wght', 700)],
color: Colors.white,
shadows: const [
Shadow(color: Color(0x4D5C0802), blurRadius: 12, offset: Offset(0, 2)),
Shadow(
color: Color(0x4D5C0802),
blurRadius: 12,
offset: Offset(0, 2),
),
],
),
),
const SizedBox(height: 6),
const Icon(
LucideIcons.arrowUpRight,
size: 18,
color: Color(0xB3FFFFFF),
const SizedBox(height: 5),
Text(
strapline,
textAlign: TextAlign.center,
style: DmText.eyebrow.copyWith(
fontSize: 9.5,
letterSpacing: 1.7,
color: const Color(0xB3FFFFFF),
),
),
],
),

View File

@@ -102,6 +102,11 @@ class _DmButtonState extends State<DmButton> {
? DmColors.surfaceAlt
: (_down ? DmColors.brandPress : DmColors.brand);
foreground = _enabled ? Colors.white : DmColors.ink3;
// A disabled primary on the deepened canvas is a 1% step from it, so
// the screen's one action read as a grey smear rather than as a
// button waiting for the form to be filled. The edge is what keeps it
// a button.
if (!_enabled) border = Border.all(color: DmColors.border);
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.
@@ -109,7 +114,7 @@ class _DmButtonState extends State<DmButton> {
? const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFA6180B), DmColors.brand, Color(0xFF7A0C04)],
colors: [DmColors.brandLit, DmColors.brand, DmColors.brandPress],
stops: [0.0, 0.55, 1.0],
)
: null;

View File

@@ -21,24 +21,40 @@ enum DmCardTone {
sunken,
}
/// A bordered panel that stacks rows and rules them apart.
/// A panel that stacks rows and rules them apart.
///
/// This replaces the shadowed card the app used to draw everywhere. The rule
/// between rows belongs to the card, not to the rows: a row does not know
/// whether it is first, and a card that is handed one child draws no rule at
/// all, so the same [DmCardCell] works alone or in a stack of five.
/// The rule between rows belongs to the card, not to the rows: a row does not
/// know whether it is first, and a card that is handed one child draws no rule
/// at all, so the same [DmCardCell] works alone or in a stack of five.
///
/// ── Edge → shadow (2026-09-23) ──
///
/// This was a bordered panel, which was the right correction to the shadowed
/// card it replaced — that one dropped a shadow under everything, so nothing
/// on a screen could be said to matter more than anything else. Eight hairline
/// boxes down a screen turned out to have the same failure in reverse: the
/// outline became the loudest thing about an object whose job is to be quiet.
///
/// A card is lifted now, not outlined. [DmShadow.card] is two very soft layers
/// — an ambient wash and a short contact shadow — neither of which is legible
/// *as* a shadow; together they read as the white being nearer than the canvas.
/// [raised] doubles them for the one card on a screen that needs acting on.
class DmCard extends StatelessWidget {
const DmCard({
super.key,
required this.children,
this.tone = DmCardTone.plain,
this.rail = false,
this.raised = false,
this.onTap,
});
final List<Widget> children;
final DmCardTone tone;
/// Deeper lift, for the one card in a list that is live or needs a decision.
final bool raised;
/// A 3pt crimson spine down the leading edge — marks the one row in a list
/// that needs the customer's attention now.
final bool rail;
@@ -62,6 +78,9 @@ class DmCard extends StatelessWidget {
/// The rule between two rows of the same card. On crimson it is a lightened
/// white rather than a darker line: a dark rule on a saturated field reads as
/// a crack, a light one as a seam.
/// Only the white card lifts. See the note in [build].
bool get _lifts => tone == DmCardTone.plain;
Color get _rule => switch (tone) {
DmCardTone.plain => DmColors.hairline,
DmCardTone.fill => const Color(0x2EFFFFFF),
@@ -79,18 +98,25 @@ class DmCard extends StatelessWidget {
// and the card lost its outline exactly where it needed one most. A
// foreground decoration paints last, so every cell is clipped inside an
// edge that is always visible.
final radius = DmRadius.all(DmRadius.card);
final card = DecoratedBox(
decoration: BoxDecoration(
color: _background,
borderRadius: DmRadius.all(DmRadius.md),
borderRadius: radius,
boxShadow: _lifts ? (raised ? DmShadow.cardRaised : DmShadow.card) : null,
),
child: Container(
foregroundDecoration: BoxDecoration(
borderRadius: DmRadius.all(DmRadius.md),
border: Border.all(color: _edge),
),
// A tone that is not white keeps its edge: a crimson or washed card is
// already separated from the canvas by its own colour, and a shadow
// under a saturated fill reads as grime rather than as lift.
foregroundDecoration: _lifts
? null
: BoxDecoration(
borderRadius: radius,
border: Border.all(color: _edge),
),
child: ClipRRect(
borderRadius: DmRadius.all(DmRadius.md),
borderRadius: radius,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
@@ -109,7 +135,8 @@ class DmCard extends StatelessWidget {
? Container(
decoration: BoxDecoration(
color: DmColors.brand,
borderRadius: DmRadius.all(DmRadius.md),
borderRadius: radius,
boxShadow: _lifts ? DmShadow.cardRaised : null,
),
padding: const EdgeInsets.only(left: 3),
child: card,

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../tokens.dart';
@@ -387,16 +386,25 @@ class DmFooter extends StatelessWidget {
}
}
/// The crimson field at the top of a tab root.
/// The head of a tab root: an eyebrow, a title, an optional action, and the
/// control that belongs to this destination.
///
/// Home, Orders and Account all open with one. It does three jobs at once —
/// it states where you are (eyebrow + title), it holds that destination's one
/// control (a segmented filter, an address switcher, a phone number), and it
/// gives the app a recognisable brand surface that the white canvas below can
/// then stay completely quiet.
/// ── It stopped being a crimson field ──
///
/// The arcs are drawn, not an asset: they are struck from the same geometry as
/// the mark, they scale to any header height, and they cost no bytes.
/// 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,
@@ -421,57 +429,35 @@ class DmBrandHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: const BorderRadius.vertical(
bottom: Radius.circular(DmRadius.header),
),
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(
bottom: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(
DmSpace.headerPad,
2,
DmSpace.headerPad,
18,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 48,
child: Row(
children: [
?leading,
const Spacer(),
?action,
],
),
),
const SizedBox(height: 10),
if (eyebrow != null) ...[
Text(eyebrow!.toUpperCase(), style: DmText.headerEyebrow),
const SizedBox(height: 8),
],
Text(title, style: DmText.headerTitle),
if (child != null) ...[const SizedBox(height: 18), child!],
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!],
],
),
),
),
@@ -483,10 +469,13 @@ class DmBrandHeader extends StatelessWidget {
/// 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});
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,
@@ -501,7 +490,7 @@ class DmArcField extends CustomPainter {
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.5 / scale
..color = Colors.white.withValues(alpha: opacity);
..color = colour.withValues(alpha: opacity);
canvas.drawCircle(const Offset(250, 90), 120, paint);
canvas.drawCircle(const Offset(250, 90), 170, paint);
@@ -521,126 +510,7 @@ class DmArcField extends CustomPainter {
@override
bool shouldRepaint(covariant DmArcField oldDelegate) =>
oldDelegate.opacity != opacity;
}
/// The segmented filter recessed into the brand header — Orders' Active /
/// Completed / Cancelled, with each tab's count.
///
/// The selected tab lifts out as a white pill and keeps its figure; the others
/// stay in the recess. One control, three states, no separate count row.
class DmSegmented extends StatelessWidget {
const DmSegmented({
super.key,
required this.labels,
required this.counts,
required this.index,
required this.onChanged,
});
final List<String> labels;
/// Null where a tab has no meaningful figure yet (still loading).
final List<int?> counts;
final int index;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: DmColors.onBrandSunken,
borderRadius: DmRadius.all(DmRadius.lg),
),
child: Row(
children: [
for (var i = 0; i < labels.length; i++) ...[
if (i > 0) const SizedBox(width: 4),
Expanded(
child: _Segment(
label: labels[i],
count: i < counts.length ? counts[i] : null,
selected: i == index,
onTap: () => onChanged(i),
),
),
],
],
),
);
}
}
class _Segment extends StatelessWidget {
const _Segment({
required this.label,
required this.count,
required this.selected,
required this.onTap,
});
final String label;
final int? count;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
selected: selected,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
HapticFeedback.selectionClick();
onTap();
},
child: AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 11),
decoration: BoxDecoration(
color: selected ? Colors.white : Colors.transparent,
borderRadius: DmRadius.all(DmRadius.sm),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.label.copyWith(
fontSize: 13,
color: selected ? DmColors.brand : DmColors.onBrand3,
),
),
),
if (count != null) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: selected ? DmColors.brandSoft : DmColors.onBrandRaise,
borderRadius: DmRadius.all(DmRadius.pill),
),
child: Text(
'$count',
style: DmText.monoSm.copyWith(
fontSize: 11,
color: selected ? DmColors.brand : Colors.white,
),
),
),
],
],
),
),
),
);
}
oldDelegate.opacity != opacity || oldDelegate.colour != colour;
}
/// The recessed strip under a header title that carries one live fact — the
@@ -668,9 +538,14 @@ class DmHeaderTrack extends StatelessWidget {
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.onBrandSunken,
color: DmColors.surface,
borderRadius: DmRadius.all(DmRadius.lg),
border: Border.all(color: DmColors.border),
),
child: Row(
children: [
@@ -753,3 +628,211 @@ class DmScreenHeader extends StatelessWidget {
);
}
}
/// 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,
),
),
),
],
),
);
}
}

View File

@@ -259,6 +259,7 @@ class DmRow extends StatelessWidget {
this.onTap,
this.showChevron = false,
this.icon,
this.note,
});
final String label;
@@ -277,6 +278,12 @@ class DmRow extends StatelessWidget {
/// just as well.
final IconData? icon;
/// The line under the label — "3 locations configured", "Real-time SMS &
/// Push". It is what turns a menu into a list of states the customer can
/// check without opening anything, which is most of what a settings screen
/// is for.
final String? note;
@override
Widget build(BuildContext context) {
return Material(
@@ -286,38 +293,64 @@ class DmRow extends StatelessWidget {
splashColor: DmColors.brandSoft,
highlightColor: DmColors.brandSoft.withValues(alpha: 0.5),
child: Container(
constraints: const BoxConstraints(minHeight: 56),
padding: const EdgeInsets.fromLTRB(16, 11, 16, 11),
constraints: const BoxConstraints(minHeight: 58),
padding: const EdgeInsets.fromLTRB(18, 13, 18, 13),
child: Row(
children: [
if (icon != null) ...[
Icon(icon, size: 19, color: DmColors.ink3),
const SizedBox(width: 14),
// A glyph in a well, not a glyph. Six bare strokes down a
// settings list read as a column of marks; six wells read as
// six rows, and the well is what gives the label a left edge
// to sit against.
Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(
color: DmColors.surfaceAlt,
borderRadius: DmRadius.all(DmRadius.xs),
),
child: Icon(icon, size: 17, color: DmColors.ink2),
),
const SizedBox(width: 13),
],
// 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,
maxLines: 1,
overflow: TextOverflow.ellipsis,
// The label is what the row IS and it is never the thing that
// truncates. Two rounds of flex arithmetic here produced
// "Payment metho…" and "38 Mettupal…" in turn; the fix was not a
// third ratio, it was to stop passing values that cannot fit.
// A [value] is a word — "UPI", "SMS and push" — and anything
// longer belongs on the screen the row opens.
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: DmText.cardTitle.copyWith(fontSize: 15.5),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (note != null)
Text(
note!,
style: DmText.small.copyWith(
fontSize: 12.5,
color: DmColors.ink3,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
if (value != null) ...[
const SizedBox(width: 12),
Expanded(
child: Text(
value!,
style: DmText.value,
textAlign: TextAlign.right,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
value!,
style: DmText.value,
textAlign: TextAlign.right,
maxLines: 1,
),
],
?trailing,
@@ -337,7 +370,18 @@ class DmRow extends StatelessWidget {
}
}
/// Card that rules a group of [DmRow]s together.
/// A run of [DmRow]s on one surface.
///
/// ── Card → rules → card ──
///
/// It was a bordered card, then bare ruled rows on the canvas. The bare rows
/// were right about the border — three decorations were doing one job — and
/// wrong about the surface: with nothing under them Account read as a text
/// file, while every other list in the app had come back onto a card.
///
/// It is a [DmCard] again, which now separates by lift rather than by edge.
/// The rules stay, because they belong to the card and are what let one
/// [DmRow] work alone or in a stack of five.
class DmRowGroup extends StatelessWidget {
const DmRowGroup({super.key, required this.children});

View File

@@ -28,6 +28,7 @@ class DmMilestones extends StatelessWidget {
this.title = 'Recent updates',
this.maxRows,
this.onExpand,
this.showStep = false,
});
final Booking booking;
@@ -44,6 +45,10 @@ class DmMilestones extends StatelessWidget {
/// Tapped from the "Full timeline" action. Null hides the action.
final VoidCallback? onExpand;
/// Puts "Step N of 5" opposite the title. Only where the whole rail is on
/// screen — a counter over a compressed list counts rows nobody can see.
final bool showStep;
@override
Widget build(BuildContext context) {
final current = stage ?? booking.stage;
@@ -89,6 +94,20 @@ class DmMilestones extends StatelessWidget {
style: DmText.eyebrow,
),
),
// ── "Step 2 of 5" ──
//
// The rail shows where the parcel is; the counter says how much
// of the journey that is. A customer looking at five rows with
// one of them lit has to count to know, and counting is the one
// thing a progress indicator exists to save them.
if (showStep)
Text(
'Step ${currentIndex + 1} of ${milestones.length}',
style: DmText.label.copyWith(
fontSize: 12.5,
color: DmColors.brand,
),
),
if (onExpand != null && shown.length < ordered.length)
DmTextAction(
// Was "All 7 stages". The number counts something the
@@ -258,9 +277,11 @@ class _MilestoneRow extends StatelessWidget {
const SizedBox(width: 10),
Text(
time,
style: DmText.monoSm.copyWith(
color: isCurrent ? DmColors.brand : DmColors.ink3,
),
// Always quiet. The crimson on the current row is
// the "you are here" marker and it belongs to the
// milestone; a timestamp set in it made the accent
// mean "recent" on one row and "now" on the next.
style: DmText.monoSm.copyWith(color: DmColors.ink3),
),
],
],

View File

@@ -8,8 +8,12 @@ enum DmPillTone {
/// for a Miler, out for delivery. Crimson field, white label, solid dot.
live,
/// Moving, but between beats — assigned, in transit. The crimson is held in
/// an outline so a list of six orders is not six red blocks.
/// Moving, but between beats — assigned, in transit.
///
/// The crimson used to be held in an outline *and* in the label, so a list
/// of six orders was six red words. It is held in the dot now: the colour
/// still says "this one is running", and the word is read at ink weight
/// like every other word on the card.
neutral,
/// Finished. Grey, with a filled dot: the journey happened.
@@ -32,8 +36,8 @@ class DmPill extends StatelessWidget {
DmPillTone.live => (DmColors.brand, Colors.white, null),
DmPillTone.neutral => (
DmColors.surface,
DmColors.brand,
Border.all(color: DmColors.brandLine),
DmColors.ink,
Border.all(color: DmColors.border),
),
DmPillTone.done => (DmColors.surfaceAlt, DmColors.ink3, null),
DmPillTone.off => (DmColors.surfaceAlt, DmColors.ink3, null),
@@ -43,6 +47,10 @@ class DmPill extends StatelessWidget {
// for anything that happened or is happening, hollow for what never did.
final hollow = tone == DmPillTone.off;
// On the neutral pill the dot is the only crimson left, so it does not
// take its colour from the label the way the other tones do.
final dot = tone == DmPillTone.neutral ? DmColors.brand : fg;
return Container(
padding: const EdgeInsets.fromLTRB(11, 5, 11, 5),
decoration: BoxDecoration(
@@ -58,9 +66,9 @@ class DmPill extends StatelessWidget {
height: 6,
margin: const EdgeInsets.only(right: 7),
decoration: BoxDecoration(
color: hollow ? Colors.transparent : fg,
color: hollow ? Colors.transparent : dot,
shape: BoxShape.circle,
border: hollow ? Border.all(color: fg, width: 1.5) : null,
border: hollow ? Border.all(color: dot, width: 1.5) : null,
),
),
Flexible(
@@ -163,34 +171,73 @@ class DmAvatar extends StatelessWidget {
super.key,
required this.initials,
this.size = 42,
this.onBrand = false,
this.quiet = false,
this.badge = false,
});
final String initials;
final double size;
final bool onBrand;
/// A wash with the initials in crimson, instead of a crimson disc.
///
/// The solid disc is right where the avatar stands in for a person the
/// customer is looking at — the Miler on tracking. It is wrong where the
/// avatar is chrome: on Home it put a second saturated circle on a screen
/// whose whole design rests on there being exactly one, and the one that
/// matters is the sphere you press.
final bool quiet;
/// A brand dot on the lower-right, cut out of the surface behind it. Home
/// wears it when a booking is live, which is the one thing the avatar's
/// destination — Account — would not tell you.
final bool badge;
@override
Widget build(BuildContext context) {
return Container(
final disc = Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: onBrand ? DmColors.onBrandRaise : DmColors.brand,
color: quiet ? DmColors.brandSoft : DmColors.brand,
shape: BoxShape.circle,
border: quiet ? Border.all(color: DmColors.brandLine) : null,
),
child: Text(
initials,
style: DmText.label.copyWith(
fontSize: size < 40 ? 12 : 13,
// Scales with the disc. It was capped at 13pt, which is right on a
// 42pt avatar in a row and looks like a typo on the 86pt one at the
// top of Account.
fontSize: size < 40 ? 12 : (size <= 48 ? 13 : size * 0.30),
fontWeight: FontWeight.w800,
fontVariations: const [FontVariation('wght', 800)],
letterSpacing: 0.13,
color: Colors.white,
color: quiet ? DmColors.brand : Colors.white,
),
),
);
if (!badge) return disc;
return Stack(
clipBehavior: Clip.none,
children: [
disc,
Positioned(
right: 0,
bottom: 1,
child: Container(
width: size * 0.28,
height: size * 0.28,
decoration: BoxDecoration(
color: DmColors.brand,
shape: BoxShape.circle,
border: Border.all(color: DmColors.canvas, width: 2),
),
),
),
],
);
}
}

View File

@@ -59,20 +59,27 @@ class _DmOptionTileState extends State<DmOptionTile> {
final selected = widget.selected;
final enabled = widget.enabled;
// ── Selection is a tint and an edge, not a crimson slab ──
//
// A chosen slot used to fill solid brand and carry [DmShadow.lift] — the
// 80%-crimson glow written for the one committing button on a screen. In
// a list of six windows that made the choice the loudest object in the
// sheet, louder than the button that confirms it, and the white text
// inside it broke the type colour every other row was set in.
//
// A wash with a crimson edge says *chosen* just as unambiguously, keeps
// the row's type where it was, and leaves the crimson fill to the footer
// button — which is the thing the customer presses next.
final Color background = selected
? DmColors.brand
? DmColors.brandSoft
: (enabled ? DmColors.surface : DmColors.surfaceAlt);
final Color edge = selected
? DmColors.brand
: (enabled ? DmColors.border : DmColors.border);
final titleColor = selected
? Colors.white
? DmColors.brandPress
: (enabled ? DmColors.ink : DmColors.ink3);
final subColor = selected
? DmColors.onBrand2
? DmColors.brandPress
: (enabled ? DmColors.ink2 : DmColors.ink3);
return Semantics(
@@ -107,8 +114,13 @@ class _DmOptionTileState extends State<DmOptionTile> {
decoration: BoxDecoration(
color: background,
borderRadius: DmRadius.all(DmRadius.md),
border: Border.all(color: edge),
boxShadow: selected ? DmShadow.lift : null,
border: selected
? Border.all(color: DmColors.brand, width: 1.5)
: null,
// An unchosen, available option is a card like any other: lifted
// off the sheet rather than outlined on it. A disabled one is
// flat, because it is not an object you can pick up.
boxShadow: enabled && !selected ? DmShadow.card : null,
),
child: Row(
children: [
@@ -116,7 +128,7 @@ class _DmOptionTileState extends State<DmOptionTile> {
Icon(
widget.leading,
size: 20,
color: selected ? Colors.white : DmColors.ink3,
color: selected ? DmColors.brand : DmColors.ink3,
),
const SizedBox(width: 13),
],
@@ -178,9 +190,11 @@ class _Radio extends StatelessWidget {
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
// The mark takes the brand now that the tile behind it is a wash
// rather than a crimson field — white on `brandSoft` is invisible.
border: Border.all(
color: selected
? Colors.white
? DmColors.brand
: (enabled ? DmColors.ink4 : DmColors.border),
width: selected ? 2 : 1.5,
),
@@ -190,7 +204,7 @@ class _Radio extends StatelessWidget {
width: 10,
height: 10,
decoration: const BoxDecoration(
color: Colors.white,
color: DmColors.brand,
shape: BoxShape.circle,
),
)

View File

@@ -114,25 +114,29 @@ class DmChoiceChip extends StatelessWidget {
@override
Widget build(BuildContext context) {
final fg = selected
? Colors.white
: (enabled ? DmColors.ink2 : DmColors.ink4);
// ── A segmented control, not a crimson block ──
//
// "Today" was a solid brand fill with [DmShadow.liftSm] under it, sitting
// eight points above a list of windows and two hundred above the crimson
// button that confirms one. Three crimson objects on a sheet that asks a
// single question, and the loudest of them was the day — which the
// customer changes once, if ever.
//
// It is a lifted white chip on a sunken track now: the same convention
// iOS and every banking app use for a segment, where *selected* means
// raised rather than coloured.
final fg = selected ? DmColors.ink : (enabled ? DmColors.ink3 : DmColors.ink4);
final chip = AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
constraints: const BoxConstraints(minHeight: 38),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
constraints: const BoxConstraints(minHeight: 40),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected
? DmColors.brand
: (enabled ? DmColors.surface : DmColors.surfaceAlt),
border: Border.all(
color: selected ? DmColors.brand : DmColors.border,
),
borderRadius: DmRadius.all(DmRadius.xs),
boxShadow: selected ? DmShadow.liftSm : null,
color: selected ? DmColors.surface : Colors.transparent,
borderRadius: DmRadius.all(DmRadius.sm),
boxShadow: selected ? DmShadow.card : null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
@@ -143,7 +147,11 @@ class DmChoiceChip extends StatelessWidget {
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DmText.label.copyWith(fontSize: 13, color: fg),
style: DmText.label.copyWith(
fontSize: 13.5,
color: fg,
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
),
),
),
if (count != null) ...[
@@ -151,14 +159,14 @@ class DmChoiceChip extends StatelessWidget {
Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: selected ? const Color(0x3DFFFFFF) : DmColors.surfaceAlt,
color: selected ? DmColors.surfaceAlt : DmColors.surface,
borderRadius: DmRadius.all(DmRadius.pill),
),
child: Text(
'$count',
style: DmText.monoSm.copyWith(
fontSize: 11,
color: selected ? Colors.white : DmColors.ink3,
color: DmColors.ink3,
),
),
),

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../tokens.dart';
@@ -20,15 +21,15 @@ enum DmNodeKind {
/// Where the parcel starts. A hollow circle: a door, not a decision.
origin,
/// A sorting hub in between. An outlined diamond — the parcel passes
/// A sorting hub in between. A hollow crimson ring — the parcel passes
/// through, it does not stop here.
hub,
/// The destination. A filled crimson diamond, the only solid node on the
/// thread, so the eye lands on where the parcel is going.
/// The destination. A filled map pin, the only solid node on the thread, so
/// the eye lands on where the parcel is going.
drop,
/// A leg that has not been arranged yet — dashed outline.
/// A leg that has not been arranged yet — an outlined pin.
pending,
}
@@ -243,44 +244,31 @@ class _Node extends StatelessWidget {
border: Border.all(color: DmColors.ink4, width: 2),
),
),
// Rotated squares rather than a second circle: on a thread of three
// identical dots nothing tells you which end matters, and at 11pt a
// diamond is distinguishable from a circle at a glance where two
// shades of the same dot are not.
DmNodeKind.hub => Transform.rotate(
angle: 0.785398,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: DmColors.surface,
borderRadius: BorderRadius.circular(2),
border: Border.all(color: DmColors.brand, width: 2),
),
// ── The destination is a pin, not a diamond ──
//
// A rotated square distinguishes the ends of the thread, which was
// the whole argument for it, and it does that by being a shape with
// no meaning attached. A map pin is the mark every customer already
// reads as *a place*, so the thread stops needing a legend: hollow
// circle where you are, pin where it is going.
DmNodeKind.hub => Container(
width: 10,
height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: DmColors.surface,
border: Border.all(color: DmColors.brand, width: 2),
),
),
DmNodeKind.drop => Transform.rotate(
angle: 0.785398,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: DmColors.brand,
borderRadius: BorderRadius.circular(2),
),
),
DmNodeKind.drop => const Icon(
LucideIcons.mapPin,
size: 15,
color: DmColors.brand,
),
DmNodeKind.pending => Transform.rotate(
angle: 0.785398,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: DmColors.surface,
borderRadius: BorderRadius.circular(2),
border: Border.all(color: DmColors.ink4, width: 1.5),
),
),
DmNodeKind.pending => const Icon(
LucideIcons.mapPin,
size: 15,
color: DmColors.ink4,
),
},
),