import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../tokens.dart'; enum DmButtonKind { /// Crimson, with the glow. The one committing action on a screen. primary, /// White with a hairline edge. A real action that is not *the* action — /// "Share live tracking". outline, /// White with a crimson edge and crimson label. Brand-weighted, but not a /// commitment — used where an outline would read as too neutral. quiet, /// A crimson wash. Rare; secondary actions inside a wash card. soft, /// No surface at all. The line under a primary action. ghost, } /// The app's only button. /// /// `block: true` fills the width (the default in footers). The disabled primary /// does not fade the crimson — it goes to the sunken neutral, because a /// half-opacity red still reads as "press me" and this design uses exactly one /// red for exactly one meaning. /// /// ── Why the primary is lit (2026-09-22) ── /// /// It was a flat crimson rectangle at the field radius, which is what a button /// looks like when nobody has designed it. The BOOK sphere on Home is lit from /// the upper left; a committing action that is not lit the same way reads as a /// different material, and the app ends up with one crafted object and a row of /// plain boxes underneath it. /// /// So the primary now takes the sphere's treatment, dialled right down: the /// same directional gradient, a white rim along the top edge where the light /// would catch, and the crimson glow it already had. It also finally uses /// [DmRadius.book] — the token that exists for exactly this and was never /// wired up, leaving every CTA at the 12pt field corner. class DmButton extends StatefulWidget { const DmButton({ super.key, required this.label, this.onPressed, this.kind = DmButtonKind.primary, this.block = true, this.danger = false, this.busy = false, this.busyLabel, this.icon, this.iconLeading = false, this.height, }); final String label; final VoidCallback? onPressed; final DmButtonKind kind; final bool block; /// Renders the label in the danger colour (Cancel pickup). final bool danger; final bool busy; final String? busyLabel; /// Optional icon, e.g. an arrow on a forward action. final IconData? icon; /// Puts [icon] before the label. True for a pair of short actions — "Call", /// "Message" — where the glyph is what the eye lands on first. final bool iconLeading; /// Overrides [DmSpace.button] — for the half-width pair inside a card. final double? height; @override State createState() => _DmButtonState(); } class _DmButtonState extends State { bool _down = false; bool get _enabled => widget.onPressed != null && !widget.busy; @override Widget build(BuildContext context) { final Color background; final Color foreground; BoxBorder? border; List? shadow; Gradient? gradient; double height = widget.height ?? DmSpace.button; double radius = DmRadius.book; switch (widget.kind) { case DmButtonKind.primary: background = !_enabled ? 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. gradient = _enabled && !_down ? const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [DmColors.brandLit, DmColors.brand, DmColors.brandPress], stops: [0.0, 0.55, 1.0], ) : null; case DmButtonKind.outline: background = _down ? DmColors.surfaceAlt : DmColors.surface; foreground = widget.danger ? DmColors.danger : DmColors.ink; border = Border.all(color: DmColors.border); case DmButtonKind.quiet: background = _down ? DmColors.brandSoft : DmColors.surface; foreground = DmColors.brand; border = Border.all(color: DmColors.brandLine); case DmButtonKind.soft: background = _down ? DmColors.brandLine : DmColors.brandSoft; foreground = DmColors.brand; case DmButtonKind.ghost: background = Colors.transparent; foreground = widget.danger ? DmColors.brand : DmColors.ink2; height = widget.height ?? DmSpace.tap; radius = DmRadius.sm; } final content = Row( mainAxisSize: widget.block ? MainAxisSize.max : MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ if (widget.icon != null && widget.iconLeading && !widget.busy) ...[ Icon(widget.icon, size: 18, color: foreground), const SizedBox(width: 9), ], if (widget.busy) ...[ SizedBox( width: 16, height: 16, child: CircularProgressIndicator( strokeWidth: 2, strokeCap: StrokeCap.round, valueColor: AlwaysStoppedAnimation(foreground), ), ), const SizedBox(width: 10), ], Flexible( child: Text( widget.busy ? (widget.busyLabel ?? widget.label) : widget.label, style: (widget.kind == DmButtonKind.ghost ? DmText.textButton : DmText.button) .copyWith(color: foreground), textAlign: TextAlign.center, ), ), if (widget.icon != null && !widget.iconLeading && !widget.busy) ...[ const SizedBox(width: 9), Icon(widget.icon, size: 18, color: foreground), ], ], ); final filled = widget.kind == DmButtonKind.primary; // Presses translate rather than scale for anything with an edge: these // buttons sit on a hairline grid, and scaling a 1px border makes it // shimmer. The primary has no border, so it also takes a touch of scale — // which is what makes a filled surface feel pressed rather than nudged. final button = AnimatedScale( scale: _down && _enabled && filled ? 0.985 : 1, duration: DmMotion.fast, curve: DmMotion.ease, child: AnimatedSlide( offset: _down && _enabled ? const Offset(0, 0.018) : Offset.zero, duration: DmMotion.fast, curve: DmMotion.ease, child: AnimatedContainer( duration: DmMotion.fast, constraints: BoxConstraints(minHeight: height), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), decoration: BoxDecoration( color: gradient == null ? background : null, gradient: gradient, border: border, borderRadius: DmRadius.all(radius), boxShadow: shadow, ), // The rim the light catches. Drawn in the foreground so the gradient // cannot cover it, and only along the top — a full outline would // read as a border and flatten the fill back out. foregroundDecoration: gradient == null ? null : BoxDecoration( borderRadius: DmRadius.all(radius), border: const Border( top: BorderSide(color: Color(0x33FFFFFF), width: 1.2), ), ), alignment: Alignment.center, child: content, ), ), ); return Semantics( button: true, enabled: _enabled, label: widget.label, child: GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: _enabled ? (_) => setState(() => _down = true) : null, onTapUp: _enabled ? (_) => setState(() => _down = false) : null, onTapCancel: _enabled ? () => setState(() => _down = false) : null, onTap: _enabled ? () { // A primary action commits to something. Everything else is // navigation or a change of mind, and a medium impact for // those makes the one that matters feel ordinary. if (filled) { HapticFeedback.mediumImpact(); } else { HapticFeedback.selectionClick(); } widget.onPressed!(); } : null, child: widget.block ? SizedBox(width: double.infinity, child: button) : button, ), ); } } /// Half-width action inside a card — Call / Message on the rider row. class DmMiniButton extends StatelessWidget { const DmMiniButton({ super.key, required this.label, required this.icon, this.onPressed, this.filled = false, }); final String label; final IconData icon; final VoidCallback? onPressed; /// The one of the pair that is the expected action. final bool filled; @override Widget build(BuildContext context) { return Expanded( child: DmButton( label: label, icon: icon, iconLeading: true, height: 42, kind: filled ? DmButtonKind.primary : DmButtonKind.outline, onPressed: onPressed, ), ); } } /// Small quiet pill inside a card ("Change", when it needs a surface). class DmChipButton extends StatelessWidget { const DmChipButton({super.key, required this.label, this.onPressed, this.icon}); final String label; final VoidCallback? onPressed; final IconData? icon; @override Widget build(BuildContext context) { return Material( color: DmColors.surfaceAlt, borderRadius: DmRadius.all(DmRadius.xs), child: InkWell( onTap: onPressed, borderRadius: DmRadius.all(DmRadius.xs), child: Container( constraints: const BoxConstraints(minHeight: 34), padding: EdgeInsets.only( left: icon == null ? 12 : 9, right: 12, top: 6, bottom: 6, ), alignment: Alignment.center, child: Row( mainAxisSize: MainAxisSize.min, children: [ if (icon != null) ...[ Icon(icon, size: 14, color: DmColors.ink2), const SizedBox(width: 5), ], Text(label, style: DmText.label), ], ), ), ), ); } } /// Text-only action — the "Change" / "Edit" / "Copy" at the end of a card row. /// /// Its padding is negative-margined back out, so the label sits flush with the /// card's right edge while the tap target still clears 40pt. class DmTextAction extends StatelessWidget { const DmTextAction({ super.key, required this.label, this.onPressed, this.color = DmColors.brand, this.minHeight = 40, this.icon, }); final String label; final VoidCallback? onPressed; final Color color; final double minHeight; final IconData? icon; @override Widget build(BuildContext context) { return Semantics( button: true, child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: onPressed == null ? null : () { HapticFeedback.selectionClick(); onPressed!(); }, child: Container( constraints: BoxConstraints(minHeight: minHeight), padding: const EdgeInsets.symmetric(horizontal: 6), alignment: Alignment.center, child: Row( mainAxisSize: MainAxisSize.min, children: [ if (icon != null) ...[ Icon(icon, size: 14, color: color), const SizedBox(width: 6), ], // Flexible, because this sits at the end of rows whose other half // is an address or a window that has first claim on the width. Flexible( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: DmText.label.copyWith(fontSize: 13, color: color), ), ), ], ), ), ), ); } } /// The chrome affordance: a rounded square, not a disc. class DmIconButton extends StatelessWidget { const DmIconButton({ super.key, required this.icon, this.onPressed, this.background = DmColors.surface, this.foreground = DmColors.ink, this.border = DmColors.border, this.size = 40, this.radius = DmRadius.sm, this.tooltip, }); final IconData icon; final VoidCallback? onPressed; final Color background; final Color foreground; /// Transparent on the crimson header, where the fill carries the shape. final Color border; final double size; final double radius; final String? tooltip; @override Widget build(BuildContext context) { return Semantics( button: true, label: tooltip, child: SizedBox( width: DmSpace.tap, height: DmSpace.tap, child: Center( child: Material( color: background, borderRadius: DmRadius.all(radius), child: InkWell( borderRadius: DmRadius.all(radius), onTap: onPressed, child: Ink( width: size, height: size, decoration: BoxDecoration( borderRadius: DmRadius.all(radius), border: Border.all(color: border), ), child: Icon(icon, size: 19, color: foreground), ), ), ), ), ), ); } }