Files
Thiru-tenext e2fe1b1771 A white page, and cards that still read on it
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`.
2026-09-30 11:35:30 +05:30

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,
],
),
);
}
}