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

@@ -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,
),
),
),
],
),
);
}
}