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