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

@@ -7,7 +7,6 @@ import '../../../state/app_scope.dart';
import '../../state_marks.dart';
import '../../tokens.dart';
import '../../widgets/buttons.dart';
import '../../widgets/cards.dart';
import '../../widgets/feedback.dart';
import '../../widgets/inputs.dart';
import '../../widgets/states.dart';
@@ -165,8 +164,29 @@ class _DestinationSheetState extends State<_DestinationSheet> {
Expanded(
child: DmSheetHeader(
title: browsing?.name ?? 'Where is it going?',
// ── A line of orientation ──
//
// The sheet asked a question and then showed a list, and a
// customer who has never used it cannot tell whether tapping
// a state chooses it or opens it. One sentence says which,
// and at the district step says how many they may pick.
subtitle: browsing == null
? 'Pick a state to see the cities it serves'
: (app.allowsMultipleDestinations
? 'Choose one or more districts'
: 'Choose a district'),
),
),
// A close, because a sheet that can only be dismissed by dragging
// is a sheet somebody will get stuck in.
DmIconButton(
icon: LucideIcons.x,
tooltip: 'Close',
background: DmColors.surface,
border: Colors.transparent,
size: 38,
onPressed: () => Navigator.of(context).pop(),
),
],
),
DmSearchField(
@@ -295,23 +315,22 @@ class _StateRow extends StatelessWidget {
Widget build(BuildContext context) {
return _Row(
onTap: onTap,
leading: DmTile(icon: stateMark(area.code), tone: DmTileTone.wash),
// The mark, with nothing behind it. It sat in a 38pt washed-crimson
// tile, so a list of six states was six pink squares in a column — six
// containers and six spots of brand to hold six glyphs that read
// perfectly well on the sheet's own surface.
leading: Icon(stateMark(area.code), size: 21, color: DmColors.ink3),
title: area.name,
subtitle:
'$count ${count == 1 ? 'city' : 'cities'}'
'${area.transitTag == null ? '' : ' · ${area.transitTag}'}',
// The number of districts already chosen in this state. It was a filled
// crimson pill — a badge, for a figure that is only ever 1, 2 or 3.
trailing: chosen == 0
? null
: Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 3),
decoration: BoxDecoration(
color: DmColors.brand,
borderRadius: DmRadius.all(DmRadius.pill),
),
child: Text(
'$chosen',
style: DmText.label.copyWith(color: Colors.white),
),
: Text(
'$chosen',
style: DmText.cardTitle.copyWith(color: DmColors.brand),
),
);
}
@@ -400,11 +419,27 @@ class _Row extends StatelessWidget {
selected: selected,
child: InkWell(
onTap: onTap,
child: Container(
// ── Cards, not ruled rows ──
//
// The rules were the right correction to the washed-crimson tiles that
// came before them, and they left the sheet as the one screen in the
// app still made of open rows while everything behind it had moved to
// lifted white. A chooser is a set of objects you pick between, which
// is exactly what a card is for; the selected one takes the brand
// edge, so *chosen* reads at a glance rather than from a tick.
child: AnimatedContainer(
duration: DmMotion.fast,
curve: DmMotion.ease,
constraints: const BoxConstraints(minHeight: DmSpace.tap),
padding: EdgeInsets.symmetric(vertical: leading == null ? 14 : 10),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: DmColors.hairline)),
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.fromLTRB(14, 13, 14, 13),
decoration: BoxDecoration(
color: selected ? DmColors.brandSoft : DmColors.surface,
borderRadius: DmRadius.all(DmRadius.md),
border: selected
? Border.all(color: DmColors.brand, width: 1.5)
: null,
boxShadow: selected ? null : DmShadow.card,
),
child: Row(
children: [

View File

@@ -88,18 +88,27 @@ class _WindowSheetState extends State<_WindowSheet> {
// One row of days rather than a heading per day: the list
// under it stays short enough to take in at a glance, which
// is the whole reason this is a sheet.
Row(
children: [
for (final d in days) ...[
if (d != days.first) const SizedBox(width: 8),
DmChoiceChip(
label: d,
selected: d == day,
expand: true,
onTap: () => setState(() => _day = d),
),
// The segments sit in a groove, which is what makes the
// chosen one read as raised rather than as merely white.
Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: DmColors.groove,
borderRadius: DmRadius.all(DmRadius.md),
),
child: Row(
children: [
for (final d in days) ...[
if (d != days.first) const SizedBox(width: 4),
DmChoiceChip(
label: d,
selected: d == day,
expand: true,
onTap: () => setState(() => _day = d),
),
],
],
],
),
),
const SizedBox(height: 16),
for (final slot in shown) ...[