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:
@@ -60,7 +60,8 @@ class DmBookOrb extends StatefulWidget {
|
||||
const DmBookOrb({
|
||||
super.key,
|
||||
required this.onTap,
|
||||
this.label = 'BOOK',
|
||||
this.label = 'BOOK NOW',
|
||||
this.strapline = 'INSTANT PARCEL',
|
||||
this.caption,
|
||||
this.held = false,
|
||||
});
|
||||
@@ -80,6 +81,9 @@ class DmBookOrb extends StatefulWidget {
|
||||
/// second use of this widget elsewhere is not forced to repeat itself.
|
||||
final String? caption;
|
||||
|
||||
/// The small line inside the sphere, under the label.
|
||||
final String strapline;
|
||||
|
||||
@override
|
||||
State<DmBookOrb> createState() => _DmBookOrbState();
|
||||
}
|
||||
@@ -117,8 +121,8 @@ class _DmBookOrbState extends State<DmBookOrb> with TickerProviderStateMixin {
|
||||
return Semantics(
|
||||
button: true,
|
||||
label: widget.caption == null
|
||||
? widget.label
|
||||
: '${widget.label}. ${widget.caption}',
|
||||
? '${widget.label}. ${widget.strapline}'
|
||||
: '${widget.label}. ${widget.strapline}. ${widget.caption}',
|
||||
child: Column(
|
||||
children: [
|
||||
GestureDetector(
|
||||
@@ -160,7 +164,11 @@ class _DmBookOrbState extends State<DmBookOrb> with TickerProviderStateMixin {
|
||||
scale: Tween(begin: 1.0, end: 1.012).animate(
|
||||
CurvedAnimation(parent: _breath, curve: Curves.easeInOut),
|
||||
),
|
||||
child: _Sphere(size: disc, label: widget.label),
|
||||
child: _Sphere(
|
||||
size: disc,
|
||||
label: widget.label,
|
||||
strapline: widget.strapline,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -184,10 +192,15 @@ class _DmBookOrbState extends State<DmBookOrb> with TickerProviderStateMixin {
|
||||
/// The lit body. Four layers, all obeying one light from the upper left:
|
||||
/// gradient fill, specular bloom, rim, then the word.
|
||||
class _Sphere extends StatelessWidget {
|
||||
const _Sphere({required this.size, required this.label});
|
||||
const _Sphere({
|
||||
required this.size,
|
||||
required this.label,
|
||||
required this.strapline,
|
||||
});
|
||||
|
||||
final double size;
|
||||
final String label;
|
||||
final String strapline;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -201,7 +214,7 @@ class _Sphere extends StatelessWidget {
|
||||
// takes its light from.
|
||||
center: Alignment(-0.38, -0.52),
|
||||
radius: 1.08,
|
||||
colors: [Color(0xFFC0230F), DmColors.brand, Color(0xFF5C0802)],
|
||||
colors: [DmColors.brandLit, DmColors.brand, Color(0xFF5C0802)],
|
||||
stops: [0.0, 0.5, 1.0],
|
||||
),
|
||||
boxShadow: [
|
||||
@@ -256,28 +269,52 @@ class _Sphere extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
// ── The face ──
|
||||
//
|
||||
// It was "BOOK / NOW" stacked at 30pt in caps with 3pt of tracking,
|
||||
// which filled the sphere edge to edge and read as a warning label.
|
||||
// The reference sets a glyph, a sentence-case line and a small
|
||||
// strapline — the same three parts every physical button that has
|
||||
// ever been designed well carries, and the air round them is what
|
||||
// makes the circle read as an object rather than as a word in a
|
||||
// red disc.
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.navigation,
|
||||
size: 21,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(height: 11),
|
||||
Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: DmText.heading.copyWith(
|
||||
fontSize: 32,
|
||||
height: 1,
|
||||
letterSpacing: 5,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontVariations: const [FontVariation('wght', 800)],
|
||||
fontSize: 23,
|
||||
height: 1.05,
|
||||
letterSpacing: -0.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontVariations: const [FontVariation('wght', 700)],
|
||||
color: Colors.white,
|
||||
shadows: const [
|
||||
Shadow(color: Color(0x4D5C0802), blurRadius: 12, offset: Offset(0, 2)),
|
||||
Shadow(
|
||||
color: Color(0x4D5C0802),
|
||||
blurRadius: 12,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Icon(
|
||||
LucideIcons.arrowUpRight,
|
||||
size: 18,
|
||||
color: Color(0xB3FFFFFF),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
strapline,
|
||||
textAlign: TextAlign.center,
|
||||
style: DmText.eyebrow.copyWith(
|
||||
fontSize: 9.5,
|
||||
letterSpacing: 1.7,
|
||||
color: const Color(0xB3FFFFFF),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -102,6 +102,11 @@ class _DmButtonState extends State<DmButton> {
|
||||
? DmColors.surfaceAlt
|
||||
: (_down ? DmColors.brandPress : DmColors.brand);
|
||||
foreground = _enabled ? Colors.white : DmColors.ink3;
|
||||
// A disabled primary on the deepened canvas is a 1% step from it, so
|
||||
// the screen's one action read as a grey smear rather than as a
|
||||
// button waiting for the form to be filled. The edge is what keeps it
|
||||
// a button.
|
||||
if (!_enabled) border = Border.all(color: DmColors.border);
|
||||
shadow = _enabled && !_down ? DmShadow.lift : null;
|
||||
// Lit like the sphere, and only while it can be pressed: a disabled
|
||||
// control that still catches the light is a control that is lying.
|
||||
@@ -109,7 +114,7 @@ class _DmButtonState extends State<DmButton> {
|
||||
? const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFA6180B), DmColors.brand, Color(0xFF7A0C04)],
|
||||
colors: [DmColors.brandLit, DmColors.brand, DmColors.brandPress],
|
||||
stops: [0.0, 0.55, 1.0],
|
||||
)
|
||||
: null;
|
||||
|
||||
@@ -21,24 +21,40 @@ enum DmCardTone {
|
||||
sunken,
|
||||
}
|
||||
|
||||
/// A bordered panel that stacks rows and rules them apart.
|
||||
/// A panel that stacks rows and rules them apart.
|
||||
///
|
||||
/// This replaces the shadowed card the app used to draw everywhere. The rule
|
||||
/// between rows belongs to the card, not to the rows: a row does not know
|
||||
/// whether it is first, and a card that is handed one child draws no rule at
|
||||
/// all, so the same [DmCardCell] works alone or in a stack of five.
|
||||
/// The rule between rows belongs to the card, not to the rows: a row does not
|
||||
/// know whether it is first, and a card that is handed one child draws no rule
|
||||
/// at all, so the same [DmCardCell] works alone or in a stack of five.
|
||||
///
|
||||
/// ── Edge → shadow (2026-09-23) ──
|
||||
///
|
||||
/// This was a bordered panel, which was the right correction to the shadowed
|
||||
/// card it replaced — that one dropped a shadow under everything, so nothing
|
||||
/// on a screen could be said to matter more than anything else. Eight hairline
|
||||
/// boxes down a screen turned out to have the same failure in reverse: the
|
||||
/// outline became the loudest thing about an object whose job is to be quiet.
|
||||
///
|
||||
/// A card is lifted now, not outlined. [DmShadow.card] is two very soft layers
|
||||
/// — an ambient wash and a short contact shadow — neither of which is legible
|
||||
/// *as* a shadow; together they read as the white being nearer than the canvas.
|
||||
/// [raised] doubles them for the one card on a screen that needs acting on.
|
||||
class DmCard extends StatelessWidget {
|
||||
const DmCard({
|
||||
super.key,
|
||||
required this.children,
|
||||
this.tone = DmCardTone.plain,
|
||||
this.rail = false,
|
||||
this.raised = false,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final List<Widget> children;
|
||||
final DmCardTone tone;
|
||||
|
||||
/// Deeper lift, for the one card in a list that is live or needs a decision.
|
||||
final bool raised;
|
||||
|
||||
/// A 3pt crimson spine down the leading edge — marks the one row in a list
|
||||
/// that needs the customer's attention now.
|
||||
final bool rail;
|
||||
@@ -62,6 +78,9 @@ class DmCard extends StatelessWidget {
|
||||
/// The rule between two rows of the same card. On crimson it is a lightened
|
||||
/// white rather than a darker line: a dark rule on a saturated field reads as
|
||||
/// a crack, a light one as a seam.
|
||||
/// Only the white card lifts. See the note in [build].
|
||||
bool get _lifts => tone == DmCardTone.plain;
|
||||
|
||||
Color get _rule => switch (tone) {
|
||||
DmCardTone.plain => DmColors.hairline,
|
||||
DmCardTone.fill => const Color(0x2EFFFFFF),
|
||||
@@ -79,18 +98,25 @@ class DmCard extends StatelessWidget {
|
||||
// and the card lost its outline exactly where it needed one most. A
|
||||
// foreground decoration paints last, so every cell is clipped inside an
|
||||
// edge that is always visible.
|
||||
final radius = DmRadius.all(DmRadius.card);
|
||||
final card = DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: _background,
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
borderRadius: radius,
|
||||
boxShadow: _lifts ? (raised ? DmShadow.cardRaised : DmShadow.card) : null,
|
||||
),
|
||||
child: Container(
|
||||
foregroundDecoration: BoxDecoration(
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
border: Border.all(color: _edge),
|
||||
),
|
||||
// A tone that is not white keeps its edge: a crimson or washed card is
|
||||
// already separated from the canvas by its own colour, and a shadow
|
||||
// under a saturated fill reads as grime rather than as lift.
|
||||
foregroundDecoration: _lifts
|
||||
? null
|
||||
: BoxDecoration(
|
||||
borderRadius: radius,
|
||||
border: Border.all(color: _edge),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
borderRadius: radius,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -109,7 +135,8 @@ class DmCard extends StatelessWidget {
|
||||
? Container(
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.brand,
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
borderRadius: radius,
|
||||
boxShadow: _lifts ? DmShadow.cardRaised : null,
|
||||
),
|
||||
padding: const EdgeInsets.only(left: 3),
|
||||
child: card,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,6 +259,7 @@ class DmRow extends StatelessWidget {
|
||||
this.onTap,
|
||||
this.showChevron = false,
|
||||
this.icon,
|
||||
this.note,
|
||||
});
|
||||
|
||||
final String label;
|
||||
@@ -277,6 +278,12 @@ class DmRow extends StatelessWidget {
|
||||
/// just as well.
|
||||
final IconData? icon;
|
||||
|
||||
/// The line under the label — "3 locations configured", "Real-time SMS &
|
||||
/// Push". It is what turns a menu into a list of states the customer can
|
||||
/// check without opening anything, which is most of what a settings screen
|
||||
/// is for.
|
||||
final String? note;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
@@ -286,38 +293,64 @@ class DmRow extends StatelessWidget {
|
||||
splashColor: DmColors.brandSoft,
|
||||
highlightColor: DmColors.brandSoft.withValues(alpha: 0.5),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: 56),
|
||||
padding: const EdgeInsets.fromLTRB(16, 11, 16, 11),
|
||||
constraints: const BoxConstraints(minHeight: 58),
|
||||
padding: const EdgeInsets.fromLTRB(18, 13, 18, 13),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 19, color: DmColors.ink3),
|
||||
const SizedBox(width: 14),
|
||||
// A glyph in a well, not a glyph. Six bare strokes down a
|
||||
// settings list read as a column of marks; six wells read as
|
||||
// six rows, and the well is what gives the label a left edge
|
||||
// to sit against.
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surfaceAlt,
|
||||
borderRadius: DmRadius.all(DmRadius.xs),
|
||||
),
|
||||
child: Icon(icon, size: 17, color: DmColors.ink2),
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
],
|
||||
// The label takes the width it needs and the value takes what
|
||||
// is left. A fixed 3:2 split gave the label room it was not
|
||||
// using — "Saved addresses" is short — and rendered the value
|
||||
// beside it as "38 Mettupal…", which is a truncation nobody can
|
||||
// read anything out of. The label stays Flexible so a long one
|
||||
// at large text sizes can still give way.
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: DmText.cardTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
// The label is what the row IS and it is never the thing that
|
||||
// truncates. Two rounds of flex arithmetic here produced
|
||||
// "Payment metho…" and "38 Mettupal…" in turn; the fix was not a
|
||||
// third ratio, it was to stop passing values that cannot fit.
|
||||
// A [value] is a word — "UPI", "SMS and push" — and anything
|
||||
// longer belongs on the screen the row opens.
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: DmText.cardTitle.copyWith(fontSize: 15.5),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (note != null)
|
||||
Text(
|
||||
note!,
|
||||
style: DmText.small.copyWith(
|
||||
fontSize: 12.5,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (value != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value!,
|
||||
style: DmText.value,
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
value!,
|
||||
style: DmText.value,
|
||||
textAlign: TextAlign.right,
|
||||
maxLines: 1,
|
||||
),
|
||||
],
|
||||
?trailing,
|
||||
@@ -337,7 +370,18 @@ class DmRow extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Card that rules a group of [DmRow]s together.
|
||||
/// A run of [DmRow]s on one surface.
|
||||
///
|
||||
/// ── Card → rules → card ──
|
||||
///
|
||||
/// It was a bordered card, then bare ruled rows on the canvas. The bare rows
|
||||
/// were right about the border — three decorations were doing one job — and
|
||||
/// wrong about the surface: with nothing under them Account read as a text
|
||||
/// file, while every other list in the app had come back onto a card.
|
||||
///
|
||||
/// It is a [DmCard] again, which now separates by lift rather than by edge.
|
||||
/// The rules stay, because they belong to the card and are what let one
|
||||
/// [DmRow] work alone or in a stack of five.
|
||||
class DmRowGroup extends StatelessWidget {
|
||||
const DmRowGroup({super.key, required this.children});
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ class DmMilestones extends StatelessWidget {
|
||||
this.title = 'Recent updates',
|
||||
this.maxRows,
|
||||
this.onExpand,
|
||||
this.showStep = false,
|
||||
});
|
||||
|
||||
final Booking booking;
|
||||
@@ -44,6 +45,10 @@ class DmMilestones extends StatelessWidget {
|
||||
/// Tapped from the "Full timeline" action. Null hides the action.
|
||||
final VoidCallback? onExpand;
|
||||
|
||||
/// Puts "Step N of 5" opposite the title. Only where the whole rail is on
|
||||
/// screen — a counter over a compressed list counts rows nobody can see.
|
||||
final bool showStep;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final current = stage ?? booking.stage;
|
||||
@@ -89,6 +94,20 @@ class DmMilestones extends StatelessWidget {
|
||||
style: DmText.eyebrow,
|
||||
),
|
||||
),
|
||||
// ── "Step 2 of 5" ──
|
||||
//
|
||||
// The rail shows where the parcel is; the counter says how much
|
||||
// of the journey that is. A customer looking at five rows with
|
||||
// one of them lit has to count to know, and counting is the one
|
||||
// thing a progress indicator exists to save them.
|
||||
if (showStep)
|
||||
Text(
|
||||
'Step ${currentIndex + 1} of ${milestones.length}',
|
||||
style: DmText.label.copyWith(
|
||||
fontSize: 12.5,
|
||||
color: DmColors.brand,
|
||||
),
|
||||
),
|
||||
if (onExpand != null && shown.length < ordered.length)
|
||||
DmTextAction(
|
||||
// Was "All 7 stages". The number counts something the
|
||||
@@ -258,9 +277,11 @@ class _MilestoneRow extends StatelessWidget {
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
time,
|
||||
style: DmText.monoSm.copyWith(
|
||||
color: isCurrent ? DmColors.brand : DmColors.ink3,
|
||||
),
|
||||
// Always quiet. The crimson on the current row is
|
||||
// the "you are here" marker and it belongs to the
|
||||
// milestone; a timestamp set in it made the accent
|
||||
// mean "recent" on one row and "now" on the next.
|
||||
style: DmText.monoSm.copyWith(color: DmColors.ink3),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -8,8 +8,12 @@ enum DmPillTone {
|
||||
/// for a Miler, out for delivery. Crimson field, white label, solid dot.
|
||||
live,
|
||||
|
||||
/// Moving, but between beats — assigned, in transit. The crimson is held in
|
||||
/// an outline so a list of six orders is not six red blocks.
|
||||
/// Moving, but between beats — assigned, in transit.
|
||||
///
|
||||
/// The crimson used to be held in an outline *and* in the label, so a list
|
||||
/// of six orders was six red words. It is held in the dot now: the colour
|
||||
/// still says "this one is running", and the word is read at ink weight
|
||||
/// like every other word on the card.
|
||||
neutral,
|
||||
|
||||
/// Finished. Grey, with a filled dot: the journey happened.
|
||||
@@ -32,8 +36,8 @@ class DmPill extends StatelessWidget {
|
||||
DmPillTone.live => (DmColors.brand, Colors.white, null),
|
||||
DmPillTone.neutral => (
|
||||
DmColors.surface,
|
||||
DmColors.brand,
|
||||
Border.all(color: DmColors.brandLine),
|
||||
DmColors.ink,
|
||||
Border.all(color: DmColors.border),
|
||||
),
|
||||
DmPillTone.done => (DmColors.surfaceAlt, DmColors.ink3, null),
|
||||
DmPillTone.off => (DmColors.surfaceAlt, DmColors.ink3, null),
|
||||
@@ -43,6 +47,10 @@ class DmPill extends StatelessWidget {
|
||||
// for anything that happened or is happening, hollow for what never did.
|
||||
final hollow = tone == DmPillTone.off;
|
||||
|
||||
// On the neutral pill the dot is the only crimson left, so it does not
|
||||
// take its colour from the label the way the other tones do.
|
||||
final dot = tone == DmPillTone.neutral ? DmColors.brand : fg;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(11, 5, 11, 5),
|
||||
decoration: BoxDecoration(
|
||||
@@ -58,9 +66,9 @@ class DmPill extends StatelessWidget {
|
||||
height: 6,
|
||||
margin: const EdgeInsets.only(right: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: hollow ? Colors.transparent : fg,
|
||||
color: hollow ? Colors.transparent : dot,
|
||||
shape: BoxShape.circle,
|
||||
border: hollow ? Border.all(color: fg, width: 1.5) : null,
|
||||
border: hollow ? Border.all(color: dot, width: 1.5) : null,
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
@@ -163,34 +171,73 @@ class DmAvatar extends StatelessWidget {
|
||||
super.key,
|
||||
required this.initials,
|
||||
this.size = 42,
|
||||
this.onBrand = false,
|
||||
this.quiet = false,
|
||||
this.badge = false,
|
||||
});
|
||||
|
||||
final String initials;
|
||||
final double size;
|
||||
final bool onBrand;
|
||||
|
||||
/// A wash with the initials in crimson, instead of a crimson disc.
|
||||
///
|
||||
/// The solid disc is right where the avatar stands in for a person the
|
||||
/// customer is looking at — the Miler on tracking. It is wrong where the
|
||||
/// avatar is chrome: on Home it put a second saturated circle on a screen
|
||||
/// whose whole design rests on there being exactly one, and the one that
|
||||
/// matters is the sphere you press.
|
||||
final bool quiet;
|
||||
|
||||
/// A brand dot on the lower-right, cut out of the surface behind it. Home
|
||||
/// wears it when a booking is live, which is the one thing the avatar's
|
||||
/// destination — Account — would not tell you.
|
||||
final bool badge;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
final disc = Container(
|
||||
width: size,
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: onBrand ? DmColors.onBrandRaise : DmColors.brand,
|
||||
color: quiet ? DmColors.brandSoft : DmColors.brand,
|
||||
shape: BoxShape.circle,
|
||||
border: quiet ? Border.all(color: DmColors.brandLine) : null,
|
||||
),
|
||||
child: Text(
|
||||
initials,
|
||||
style: DmText.label.copyWith(
|
||||
fontSize: size < 40 ? 12 : 13,
|
||||
// Scales with the disc. It was capped at 13pt, which is right on a
|
||||
// 42pt avatar in a row and looks like a typo on the 86pt one at the
|
||||
// top of Account.
|
||||
fontSize: size < 40 ? 12 : (size <= 48 ? 13 : size * 0.30),
|
||||
fontWeight: FontWeight.w800,
|
||||
fontVariations: const [FontVariation('wght', 800)],
|
||||
letterSpacing: 0.13,
|
||||
color: Colors.white,
|
||||
color: quiet ? DmColors.brand : Colors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (!badge) return disc;
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
disc,
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 1,
|
||||
child: Container(
|
||||
width: size * 0.28,
|
||||
height: size * 0.28,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.brand,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: DmColors.canvas, width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,20 +59,27 @@ class _DmOptionTileState extends State<DmOptionTile> {
|
||||
final selected = widget.selected;
|
||||
final enabled = widget.enabled;
|
||||
|
||||
// ── Selection is a tint and an edge, not a crimson slab ──
|
||||
//
|
||||
// A chosen slot used to fill solid brand and carry [DmShadow.lift] — the
|
||||
// 80%-crimson glow written for the one committing button on a screen. In
|
||||
// a list of six windows that made the choice the loudest object in the
|
||||
// sheet, louder than the button that confirms it, and the white text
|
||||
// inside it broke the type colour every other row was set in.
|
||||
//
|
||||
// A wash with a crimson edge says *chosen* just as unambiguously, keeps
|
||||
// the row's type where it was, and leaves the crimson fill to the footer
|
||||
// button — which is the thing the customer presses next.
|
||||
final Color background = selected
|
||||
? DmColors.brand
|
||||
? DmColors.brandSoft
|
||||
: (enabled ? DmColors.surface : DmColors.surfaceAlt);
|
||||
|
||||
final Color edge = selected
|
||||
? DmColors.brand
|
||||
: (enabled ? DmColors.border : DmColors.border);
|
||||
|
||||
final titleColor = selected
|
||||
? Colors.white
|
||||
? DmColors.brandPress
|
||||
: (enabled ? DmColors.ink : DmColors.ink3);
|
||||
|
||||
final subColor = selected
|
||||
? DmColors.onBrand2
|
||||
? DmColors.brandPress
|
||||
: (enabled ? DmColors.ink2 : DmColors.ink3);
|
||||
|
||||
return Semantics(
|
||||
@@ -107,8 +114,13 @@ class _DmOptionTileState extends State<DmOptionTile> {
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: DmRadius.all(DmRadius.md),
|
||||
border: Border.all(color: edge),
|
||||
boxShadow: selected ? DmShadow.lift : null,
|
||||
border: selected
|
||||
? Border.all(color: DmColors.brand, width: 1.5)
|
||||
: null,
|
||||
// An unchosen, available option is a card like any other: lifted
|
||||
// off the sheet rather than outlined on it. A disabled one is
|
||||
// flat, because it is not an object you can pick up.
|
||||
boxShadow: enabled && !selected ? DmShadow.card : null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -116,7 +128,7 @@ class _DmOptionTileState extends State<DmOptionTile> {
|
||||
Icon(
|
||||
widget.leading,
|
||||
size: 20,
|
||||
color: selected ? Colors.white : DmColors.ink3,
|
||||
color: selected ? DmColors.brand : DmColors.ink3,
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
],
|
||||
@@ -178,9 +190,11 @@ class _Radio extends StatelessWidget {
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
// The mark takes the brand now that the tile behind it is a wash
|
||||
// rather than a crimson field — white on `brandSoft` is invisible.
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? Colors.white
|
||||
? DmColors.brand
|
||||
: (enabled ? DmColors.ink4 : DmColors.border),
|
||||
width: selected ? 2 : 1.5,
|
||||
),
|
||||
@@ -190,7 +204,7 @@ class _Radio extends StatelessWidget {
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
color: DmColors.brand,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -114,25 +114,29 @@ class DmChoiceChip extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fg = selected
|
||||
? Colors.white
|
||||
: (enabled ? DmColors.ink2 : DmColors.ink4);
|
||||
// ── A segmented control, not a crimson block ──
|
||||
//
|
||||
// "Today" was a solid brand fill with [DmShadow.liftSm] under it, sitting
|
||||
// eight points above a list of windows and two hundred above the crimson
|
||||
// button that confirms one. Three crimson objects on a sheet that asks a
|
||||
// single question, and the loudest of them was the day — which the
|
||||
// customer changes once, if ever.
|
||||
//
|
||||
// It is a lifted white chip on a sunken track now: the same convention
|
||||
// iOS and every banking app use for a segment, where *selected* means
|
||||
// raised rather than coloured.
|
||||
final fg = selected ? DmColors.ink : (enabled ? DmColors.ink3 : DmColors.ink4);
|
||||
|
||||
final chip = AnimatedContainer(
|
||||
duration: DmMotion.base,
|
||||
curve: DmMotion.ease,
|
||||
constraints: const BoxConstraints(minHeight: 38),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
constraints: const BoxConstraints(minHeight: 40),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? DmColors.brand
|
||||
: (enabled ? DmColors.surface : DmColors.surfaceAlt),
|
||||
border: Border.all(
|
||||
color: selected ? DmColors.brand : DmColors.border,
|
||||
),
|
||||
borderRadius: DmRadius.all(DmRadius.xs),
|
||||
boxShadow: selected ? DmShadow.liftSm : null,
|
||||
color: selected ? DmColors.surface : Colors.transparent,
|
||||
borderRadius: DmRadius.all(DmRadius.sm),
|
||||
boxShadow: selected ? DmShadow.card : null,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -143,7 +147,11 @@ class DmChoiceChip extends StatelessWidget {
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: DmText.label.copyWith(fontSize: 13, color: fg),
|
||||
style: DmText.label.copyWith(
|
||||
fontSize: 13.5,
|
||||
color: fg,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (count != null) ...[
|
||||
@@ -151,14 +159,14 @@ class DmChoiceChip extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? const Color(0x3DFFFFFF) : DmColors.surfaceAlt,
|
||||
color: selected ? DmColors.surfaceAlt : DmColors.surface,
|
||||
borderRadius: DmRadius.all(DmRadius.pill),
|
||||
),
|
||||
child: Text(
|
||||
'$count',
|
||||
style: DmText.monoSm.copyWith(
|
||||
fontSize: 11,
|
||||
color: selected ? Colors.white : DmColors.ink3,
|
||||
color: DmColors.ink3,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
import '../tokens.dart';
|
||||
|
||||
@@ -20,15 +21,15 @@ enum DmNodeKind {
|
||||
/// Where the parcel starts. A hollow circle: a door, not a decision.
|
||||
origin,
|
||||
|
||||
/// A sorting hub in between. An outlined diamond — the parcel passes
|
||||
/// A sorting hub in between. A hollow crimson ring — the parcel passes
|
||||
/// through, it does not stop here.
|
||||
hub,
|
||||
|
||||
/// The destination. A filled crimson diamond, the only solid node on the
|
||||
/// thread, so the eye lands on where the parcel is going.
|
||||
/// The destination. A filled map pin, the only solid node on the thread, so
|
||||
/// the eye lands on where the parcel is going.
|
||||
drop,
|
||||
|
||||
/// A leg that has not been arranged yet — dashed outline.
|
||||
/// A leg that has not been arranged yet — an outlined pin.
|
||||
pending,
|
||||
}
|
||||
|
||||
@@ -243,44 +244,31 @@ class _Node extends StatelessWidget {
|
||||
border: Border.all(color: DmColors.ink4, width: 2),
|
||||
),
|
||||
),
|
||||
// Rotated squares rather than a second circle: on a thread of three
|
||||
// identical dots nothing tells you which end matters, and at 11pt a
|
||||
// diamond is distinguishable from a circle at a glance where two
|
||||
// shades of the same dot are not.
|
||||
DmNodeKind.hub => Transform.rotate(
|
||||
angle: 0.785398,
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
border: Border.all(color: DmColors.brand, width: 2),
|
||||
),
|
||||
// ── The destination is a pin, not a diamond ──
|
||||
//
|
||||
// A rotated square distinguishes the ends of the thread, which was
|
||||
// the whole argument for it, and it does that by being a shape with
|
||||
// no meaning attached. A map pin is the mark every customer already
|
||||
// reads as *a place*, so the thread stops needing a legend: hollow
|
||||
// circle where you are, pin where it is going.
|
||||
DmNodeKind.hub => Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: DmColors.surface,
|
||||
border: Border.all(color: DmColors.brand, width: 2),
|
||||
),
|
||||
),
|
||||
DmNodeKind.drop => Transform.rotate(
|
||||
angle: 0.785398,
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.brand,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
DmNodeKind.drop => const Icon(
|
||||
LucideIcons.mapPin,
|
||||
size: 15,
|
||||
color: DmColors.brand,
|
||||
),
|
||||
DmNodeKind.pending => Transform.rotate(
|
||||
angle: 0.785398,
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: DmColors.surface,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
border: Border.all(color: DmColors.ink4, width: 1.5),
|
||||
),
|
||||
),
|
||||
DmNodeKind.pending => const Icon(
|
||||
LucideIcons.mapPin,
|
||||
size: 15,
|
||||
color: DmColors.ink4,
|
||||
),
|
||||
},
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user