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:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user