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