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

@@ -36,6 +36,9 @@ class ShellScreenState extends State<ShellScreen> {
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: DmColors.canvas,
// The bar floats, so it is stacked over the content rather than laid
// out beneath it — and each root pads its own scroll view past it.
extendBody: true,
body: IndexedStack(
index: _index,
children: const [HomeScreen(), OrdersScreen(), AccountScreen()],
@@ -47,35 +50,22 @@ class ShellScreenState extends State<ShellScreen> {
/// The tab bar, fixed to the bottom edge.
///
/// ── Why it is no longer a floating pill-shaped bar ──
/// ── A floating pill, after all ──
///
/// It used to hover 10pt above the bottom with a 28pt radius and a shadow
/// under it, which put a second rounded surface between the content and the
/// gesture bar and made every screen end in two stacked edges. It now sits on
/// the edge with a single hairline above it, like the footers in the rest of
/// the app.
/// This bar has been three things. It hovered as a pill, then sat on the edge
/// with a hairline over it and a crimson indicator travelling along it, then
/// lost the indicator's edge. The reference the app is now drawn against puts
/// it back in the air — and the reason is better than the first time round.
///
/// The indicator does the work instead: a crimson pill that travels to the tab
/// you pick, with the glyph turning white as it arrives. Selection reads as one
/// object moving rather than three tabs changing colour in place — and the
/// label under it stays crimson, so the tab is still identifiable in the
/// half-second the pill is in flight.
/// The pill works *because* the screens above it stopped having edges. When
/// every card was outlined, a floating bar was a fourth rounded surface in a
/// stack of them. Now the content is white shapes lifted off a near-white
/// page, and the bar is one more of those: same radius language, same shadow,
/// read as the same kind of object.
///
/// ── What the 2026-09-22 pass changed ──
///
/// **The pill stopped glowing.** It carried [DmShadow.liftSm] — a 65% crimson
/// shadow built for the committing button on a screen — which on a bar resting
/// against the bottom edge read as a smear under the tab rather than as lift.
/// It is a quarter of that strength now and sits closer to the shape.
///
/// **The glyph arrives.** The icon pops to 1.14 and settles as the pill lands
/// under it, on the same overshoot curve the pill travels on, so the two read
/// as one gesture instead of a moving rectangle and an icon that recolours when
/// it happens to be covered.
///
/// **A tab gives way when pressed.** The whole column scales to 0.9 under the
/// finger. Without it the only feedback for a tap was the pill starting to
/// move, which on the tab you are already on is no feedback at all.
/// The labels went with the edge. Three glyphs a customer has seen on every
/// app they own do not need naming, and without them the bar is 62pt instead
/// of 86 — which is the space the pill is floating in.
class _TabBar extends StatelessWidget {
const _TabBar({required this.index, required this.onChanged});
@@ -91,80 +81,46 @@ class _TabBar extends StatelessWidget {
(label: 'Account', icon: LucideIcons.userRound),
];
/// The travelling pill's size. Narrower than its slot so it reads as an
/// object sitting in the row rather than as a selected cell.
static const double _pillWidth = 62;
static const double _pillHeight = 34;
/// The pill's own lift. Not [DmShadow.liftSm]: that is the glow under a
/// committing action, and at the bottom edge of the screen it read as dirt
/// under the tab.
static const _pillShadow = [
BoxShadow(
color: Color(0x2E8F0F06),
blurRadius: 10,
spreadRadius: -2,
offset: Offset(0, 4),
),
];
static const double _slot = 62;
static const double _height = 58;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(
color: DmColors.surface,
border: Border(top: BorderSide(color: DmColors.border)),
),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(6, 9, 6, 7),
child: SizedBox(
// The row is the pill plus the label, and the label is type: at
// 1.8x accessibility text a fixed 58 clipped it by two points. The
// bar gets taller instead, which is what a larger-text user asked
// for.
height: _pillHeight +
5 +
MediaQuery.textScalerOf(context).scale(11.5) * 1.3,
child: LayoutBuilder(
builder: (context, constraints) {
final slot = constraints.maxWidth / _items.length;
return Stack(
children: [
AnimatedPositioned(
duration: DmMotion.slow,
curve: DmMotion.glide,
left: slot * index + (slot - _pillWidth) / 2,
width: _pillWidth,
top: 0,
height: _pillHeight,
child: DecoratedBox(
decoration: BoxDecoration(
color: DmColors.brand,
borderRadius: DmRadius.all(DmRadius.pill),
boxShadow: _pillShadow,
),
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(DmSpace.pad, 0, DmSpace.pad, 10),
// A Row, not a Center: `bottomNavigationBar` hands its child an
// unbounded height, and a Center in that slot expands to fill the
// whole screen with the pill floating in the middle of it.
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
height: _height,
padding: const EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration(
color: DmColors.surface,
borderRadius: DmRadius.all(DmRadius.pill),
boxShadow: DmShadow.cardRaised,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < _items.length; i++)
SizedBox(
width: _slot,
child: _TabButton(
label: _items[i].label,
icon: _items[i].icon,
selected: index == i,
onTap: () => onChanged(i),
),
),
Row(
children: [
for (var i = 0; i < _items.length; i++)
Expanded(
child: _TabButton(
label: _items[i].label,
icon: _items[i].icon,
selected: index == i,
onTap: () => onChanged(i),
),
),
],
),
],
);
},
],
),
),
),
],
),
),
);
@@ -193,9 +149,11 @@ class _TabButtonState extends State<_TabButton> {
@override
Widget build(BuildContext context) {
final selected = widget.selected;
return Semantics(
button: true,
selected: widget.selected,
selected: selected,
label: widget.label,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => setState(() => _down = true),
@@ -203,54 +161,34 @@ class _TabButtonState extends State<_TabButton> {
onTapCancel: () => setState(() => _down = false),
onTap: widget.onTap,
child: AnimatedScale(
// The whole column, including the label: a press that only moved the
// glyph would read as the icon coming loose from its own tab.
scale: _down ? 0.9 : 1,
duration: DmMotion.fast,
curve: DmMotion.ease,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
SizedBox(
height: _TabBar._pillHeight,
child: Center(
child: AnimatedScale(
// Lands with the pill, on the pill's own curve. The
// overshoot is what ties the two together — the glyph is
// reacting to the thing arriving under it.
scale: widget.selected ? 1.14 : 1,
duration: DmMotion.slow,
curve: DmMotion.glide,
child: AnimatedSwitcher(
duration: DmMotion.base,
child: Icon(
widget.icon,
key: ValueKey(widget.selected),
size: 21,
color: widget.selected ? Colors.white : DmColors.ink3,
),
),
),
child: Center(
child: AnimatedContainer(
duration: DmMotion.base,
curve: DmMotion.ease,
width: 42,
height: 42,
alignment: Alignment.center,
decoration: BoxDecoration(
color: selected ? DmColors.brandSoft : Colors.transparent,
borderRadius: DmRadius.all(DmRadius.sm),
),
child: AnimatedScale(
// A shade of overshoot as the tint arrives under it, so the
// two read as one gesture rather than as a glyph that
// recolours when a rectangle happens to appear behind it.
scale: selected ? 1.06 : 1,
duration: DmMotion.slow,
curve: DmMotion.glide,
child: Icon(
widget.icon,
size: 21,
color: selected ? DmColors.brand : DmColors.ink4,
),
),
const SizedBox(height: 5),
AnimatedDefaultTextStyle(
duration: DmMotion.base,
curve: DmMotion.ease,
style: DmText.label.copyWith(
fontSize: 11.5,
height: 1.0,
color: widget.selected ? DmColors.brand : DmColors.ink3,
fontWeight:
widget.selected ? FontWeight.w700 : FontWeight.w600,
),
child: Text(
widget.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
),