The canvas was #F1F0EC — a warm grey that made every screen look slightly dull. It is white. ── You cannot have both ── That canvas was not an accident: it was deepened from #F7F6F4 on 2026-09-23 precisely so a white card would sit *on* something and its 1px edge could go quiet. A white card on a white page has nothing to sit on. Measured rather than judged by eye: after the canvas change, a pixel scan across a card boundary on the Orders golden read #FFFFFF from end to end. The shadow contributed nothing findable. The cards had not become subtle, they had disappeared. So the edge comes back. `DmCard` drew its border only for tones that are not white — the warm canvas was doing that job for the white ones — and now draws it for every tone, with the lift still underneath. Border plus a soft shadow is the iOS and Revolut shape rather than the borderless Airbnb one, and that is the price of a white page. Paid deliberately. ── What moved with it ── `surfaceAlt` lifts to #F4F2EF. With a white page it is the only token that makes a surface read as recessed — filled inputs, tiles, skeletons, the sunken card — so it can no longer sit within a point of the canvas. `DmCardTone.sunken` filled itself with the canvas colour, which on a white page is not sunken but invisible. It takes `surfaceAlt`.
510 lines
15 KiB
Dart
510 lines
15 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../tokens.dart';
|
|
|
|
/// How loudly a card speaks.
|
|
enum DmCardTone {
|
|
/// White on the canvas, hairline edge. Almost everything.
|
|
plain,
|
|
|
|
/// Filled crimson. **One per screen, at most** — it is the screen's answer
|
|
/// to "what is happening right now": the live ETA, the booked confirmation,
|
|
/// the delivered summary. A second one on the same screen and neither wins.
|
|
fill,
|
|
|
|
/// A crimson wash. Supporting brand information that must not shout: the
|
|
/// address being collected from, the rider on the way, a reassurance.
|
|
wash,
|
|
|
|
/// The canvas itself, edged. For content that is deliberately inert — the
|
|
/// build panel on Account.
|
|
sunken,
|
|
}
|
|
|
|
/// A panel that stacks rows and rules them apart.
|
|
///
|
|
/// 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;
|
|
|
|
final VoidCallback? onTap;
|
|
|
|
Color get _background => switch (tone) {
|
|
DmCardTone.plain => DmColors.surface,
|
|
DmCardTone.fill => DmColors.brand,
|
|
DmCardTone.wash => DmColors.brandSoft,
|
|
// Not the canvas any more: the page is white, and a card filled with
|
|
// the page colour is not sunken, it is invisible.
|
|
DmCardTone.sunken => DmColors.surfaceAlt,
|
|
};
|
|
|
|
Color get _edge => switch (tone) {
|
|
DmCardTone.plain => DmColors.border,
|
|
DmCardTone.fill => DmColors.brand,
|
|
DmCardTone.wash => DmColors.brandLine,
|
|
DmCardTone.sunken => DmColors.border,
|
|
};
|
|
|
|
/// 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),
|
|
DmCardTone.wash => DmColors.brandLine,
|
|
DmCardTone.sunken => DmColors.border,
|
|
};
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// The edge is drawn in the FOREGROUND, over the children.
|
|
//
|
|
// As a background border it was painted first and then covered: a cell with
|
|
// its own tint — the washed status strip at the head of an order card, the
|
|
// crimson rows on the booked screen — filled the whole box, border included,
|
|
// 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: radius,
|
|
boxShadow: _lifts ? (raised ? DmShadow.cardRaised : DmShadow.card) : null,
|
|
),
|
|
child: Container(
|
|
// ── Every card keeps its edge now ──
|
|
//
|
|
// A white card used to have none: the warm canvas underneath was doing
|
|
// the separating, and a border on top of that contrast read as an
|
|
// outlined box rather than a lifted surface.
|
|
//
|
|
// The page is white. A white card on it is invisible — measured, not
|
|
// guessed: a scan across a card boundary on the Orders golden was
|
|
// #FFFFFF from end to end, with the shadow contributing nothing the
|
|
// eye could find. One of the two has to do the separating, and with
|
|
// the page white it has to be the edge.
|
|
//
|
|
// The lift stays underneath it. Border plus a soft shadow is the iOS
|
|
// and Revolut shape; it is not the borderless Airbnb one, and that is
|
|
// the cost of a white page.
|
|
foregroundDecoration: BoxDecoration(
|
|
borderRadius: radius,
|
|
border: Border.all(color: _edge),
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: radius,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
for (var i = 0; i < children.length; i++) ...[
|
|
if (i > 0) Container(height: 1, color: _rule),
|
|
children[i],
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
final railed = rail
|
|
? Container(
|
|
decoration: BoxDecoration(
|
|
color: DmColors.brand,
|
|
borderRadius: radius,
|
|
boxShadow: _lifts ? DmShadow.cardRaised : null,
|
|
),
|
|
padding: const EdgeInsets.only(left: 3),
|
|
child: card,
|
|
)
|
|
: card;
|
|
|
|
if (onTap == null) return railed;
|
|
return Semantics(
|
|
button: true,
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTap: onTap,
|
|
child: railed,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// One padded cell inside a [DmCard] — the design's `.p`.
|
|
class DmCardCell extends StatelessWidget {
|
|
const DmCardCell({
|
|
super.key,
|
|
required this.child,
|
|
this.padding = const EdgeInsets.fromLTRB(16, 15, 16, 15),
|
|
this.background,
|
|
this.onTap,
|
|
});
|
|
|
|
final Widget child;
|
|
final EdgeInsets padding;
|
|
|
|
/// Tints this cell alone — a wash strip at the head of a plain card.
|
|
final Color? background;
|
|
|
|
final VoidCallback? onTap;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final body = Padding(padding: padding, child: child);
|
|
final painted = background == null
|
|
? body
|
|
: ColoredBox(color: background!, child: body);
|
|
|
|
if (onTap == null) return painted;
|
|
return Material(
|
|
color: background ?? Colors.transparent,
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
splashColor: DmColors.brandSoft,
|
|
highlightColor: DmColors.brandSoft.withValues(alpha: 0.6),
|
|
child: body,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The common cell: a glyph or tile, a title with an optional second line, and
|
|
/// something on the right — a value, a chevron, an inline action.
|
|
class DmCardRow extends StatelessWidget {
|
|
const DmCardRow({
|
|
super.key,
|
|
required this.title,
|
|
this.eyebrow,
|
|
this.subtitle,
|
|
this.leading,
|
|
this.trailing,
|
|
this.tone = DmCardTone.plain,
|
|
this.onTap,
|
|
this.titleStyle,
|
|
this.subtitleStyle,
|
|
this.background,
|
|
this.crossAlign = CrossAxisAlignment.center,
|
|
});
|
|
|
|
final String title;
|
|
|
|
/// A micro-label above the title — "COLLECTING FROM".
|
|
final String? eyebrow;
|
|
final String? subtitle;
|
|
final Widget? leading;
|
|
final Widget? trailing;
|
|
|
|
/// The tone of the *card this row sits in*, so the row can pick text colours
|
|
/// that survive a crimson field.
|
|
final DmCardTone tone;
|
|
|
|
final VoidCallback? onTap;
|
|
final TextStyle? titleStyle;
|
|
final TextStyle? subtitleStyle;
|
|
final Color? background;
|
|
final CrossAxisAlignment crossAlign;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final onFill = tone == DmCardTone.fill;
|
|
final titleColor = onFill ? Colors.white : DmColors.ink;
|
|
final subColor = switch (tone) {
|
|
DmCardTone.fill => DmColors.onBrand2,
|
|
DmCardTone.wash => DmColors.brandPress,
|
|
_ => DmColors.ink2,
|
|
};
|
|
final eyebrowColor = switch (tone) {
|
|
DmCardTone.fill => DmColors.onBrand3,
|
|
DmCardTone.wash => DmColors.brand,
|
|
_ => DmColors.ink3,
|
|
};
|
|
|
|
return DmCardCell(
|
|
background: background,
|
|
onTap: onTap,
|
|
child: Row(
|
|
crossAxisAlignment: crossAlign,
|
|
children: [
|
|
if (leading != null) ...[leading!, const SizedBox(width: 12)],
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (eyebrow != null) ...[
|
|
Text(
|
|
eyebrow!.toUpperCase(),
|
|
style: DmText.eyebrow.copyWith(color: eyebrowColor),
|
|
),
|
|
const SizedBox(height: 3),
|
|
],
|
|
Text(
|
|
title,
|
|
style: (titleStyle ?? DmText.cardTitle).copyWith(
|
|
color: titleStyle?.color ?? titleColor,
|
|
),
|
|
),
|
|
if (subtitle != null) ...[
|
|
const SizedBox(height: 3),
|
|
Text(
|
|
subtitle!,
|
|
style: (subtitleStyle ?? DmText.small).copyWith(
|
|
color: subtitleStyle?.color ?? subColor,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
if (trailing != null) ...[const SizedBox(width: 12), trailing!],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// How a [DmTile] is filled.
|
|
enum DmTileTone { sunken, wash, brand, onFill }
|
|
|
|
/// The 38pt rounded square that holds a glyph or a step number.
|
|
///
|
|
/// Square, not a circle: the cards, fields and buttons around it are all cut
|
|
/// with the same small radius, and a disc in the middle of that is the one
|
|
/// shape that has to be explained.
|
|
class DmTile extends StatelessWidget {
|
|
const DmTile({
|
|
super.key,
|
|
this.icon,
|
|
this.label,
|
|
this.tone = DmTileTone.sunken,
|
|
this.size = 38,
|
|
}) : assert(icon != null || label != null, 'a tile shows a glyph or a number');
|
|
|
|
final IconData? icon;
|
|
|
|
/// A step number, for the "how it works" sequence.
|
|
final String? label;
|
|
final DmTileTone tone;
|
|
final double size;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final (bg, fg) = switch (tone) {
|
|
DmTileTone.sunken => (DmColors.surfaceAlt, DmColors.ink),
|
|
DmTileTone.wash => (DmColors.brandSoft, DmColors.brand),
|
|
DmTileTone.brand => (DmColors.brand, Colors.white),
|
|
DmTileTone.onFill => (DmColors.onBrandRaise, Colors.white),
|
|
};
|
|
|
|
return Container(
|
|
width: size,
|
|
height: size,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: bg,
|
|
borderRadius: DmRadius.all(DmRadius.xs),
|
|
),
|
|
child: icon != null
|
|
? Icon(icon, size: size * 0.47, color: fg)
|
|
: Text(
|
|
label!,
|
|
style: DmText.label.copyWith(
|
|
fontSize: 12.5,
|
|
fontWeight: FontWeight.w800,
|
|
fontVariations: const [FontVariation('wght', 800)],
|
|
color: fg,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A figure and its caption, sat in its own bordered box. Used in pairs.
|
|
class DmMetric extends StatelessWidget {
|
|
const DmMetric({
|
|
super.key,
|
|
required this.figure,
|
|
required this.caption,
|
|
this.tone = DmCardTone.plain,
|
|
});
|
|
|
|
final String figure;
|
|
final String caption;
|
|
final DmCardTone tone;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final (bg, edge, figureColor, captionColor) = switch (tone) {
|
|
DmCardTone.fill => (
|
|
DmColors.brand,
|
|
DmColors.brand,
|
|
Colors.white,
|
|
DmColors.onBrand3,
|
|
),
|
|
DmCardTone.wash => (
|
|
DmColors.brandSoft,
|
|
DmColors.brandLine,
|
|
DmColors.brand,
|
|
DmColors.brandPress,
|
|
),
|
|
_ => (DmColors.surface, DmColors.border, DmColors.ink, DmColors.ink3),
|
|
};
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.fromLTRB(15, 14, 15, 14),
|
|
decoration: BoxDecoration(
|
|
color: bg,
|
|
border: Border.all(color: edge),
|
|
borderRadius: DmRadius.all(DmRadius.md),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
figure,
|
|
style: DmText.display.copyWith(fontSize: 25, height: 1, color: figureColor),
|
|
),
|
|
const SizedBox(height: 5),
|
|
Text(
|
|
caption,
|
|
style: DmText.value.copyWith(fontSize: 12.5, color: captionColor),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A label and its figure on one baseline — the fare breakdown, the build
|
|
/// panel. The label may wrap; the figure never does.
|
|
class DmFareLine extends StatelessWidget {
|
|
const DmFareLine({
|
|
super.key,
|
|
required this.label,
|
|
required this.value,
|
|
this.strong = false,
|
|
this.mono = true,
|
|
});
|
|
|
|
final String label;
|
|
final String value;
|
|
|
|
/// The total, rather than one of the lines that make it up.
|
|
final bool strong;
|
|
|
|
/// Figures are mono by default — they are meant to be compared down a column.
|
|
final bool mono;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 5),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.baseline,
|
|
textBaseline: TextBaseline.alphabetic,
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
label,
|
|
style: DmText.small.copyWith(
|
|
fontWeight: strong ? FontWeight.w600 : FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Text(
|
|
value,
|
|
maxLines: 1,
|
|
style: mono
|
|
? DmText.mono.copyWith(
|
|
fontSize: strong ? 15 : 13.5,
|
|
color: strong ? DmColors.brand : DmColors.ink,
|
|
)
|
|
: DmText.value.copyWith(
|
|
color: strong ? DmColors.brand : DmColors.ink,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The uppercase micro-label that heads a group of cards.
|
|
class DmMicroHead extends StatelessWidget {
|
|
const DmMicroHead(
|
|
this.text, {
|
|
super.key,
|
|
this.brand = true,
|
|
this.top = 20,
|
|
this.bottom = 10,
|
|
this.trailing,
|
|
});
|
|
|
|
final String text;
|
|
|
|
/// Crimson heads brand content; neutral heads everything else.
|
|
final bool brand;
|
|
final double top;
|
|
final double bottom;
|
|
final Widget? trailing;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: EdgeInsets.only(top: top, bottom: bottom),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
text.toUpperCase(),
|
|
style: DmText.eyebrow.copyWith(
|
|
color: brand ? DmColors.brand : DmColors.ink3,
|
|
),
|
|
),
|
|
),
|
|
?trailing,
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|