Files
ImBenjiandClaude Opus 5.5 572c09039f Rewrite the garage_ui style guide, add a ghost destructive button
The old guide had drifted a long way from the package. The new one is
built from blender mode in Arcs & Angles and the Garage hub, and leads with
letting the theme, density and colour scheme do the talking.

ghostDestructive is for remove/revoke/delete in a row, so apps stop hand
colouring ghost buttons red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
2026-09-23 19:51:45 +01:00

2552 lines
79 KiB
Dart

// GarageUI button family — hand rolled replacement for shadcn's button.dart.
//
// this replicates shadcn_flutter 0.0.52 control/button.dart (+ clickable state
// machinery + focus_outline) as close to pixel identical as i could get it.
// colours/typography/radii all come from the SAME source shadcn reads:
// GarageTheme.of(context) (imported with the `shad` prefix below), so the look matches.
//
// only the widgets the app actually uses live here. some rarely used shadcn
// params/variants were dropped — see the report / notes near the bottom.
import "dart:math";
import "package:flutter/scheduler.dart";
import "package:flutter/services.dart";
import "package:flutter/widgets.dart";
import "package:garage_ui/theme/garage_theme.dart";
import "package:garage_ui/theme/support.dart";
// shadcn multiplies the existing alpha by a factor (not withOpacity which
// replaces it). keep that behaviour so faded states look the same.
Color _scaleAlpha(Color c, double factor) {
return c.withValues(alpha: c.a * factor);
}
bool _isMobile(TargetPlatform platform) {
return platform == TargetPlatform.iOS ||
platform == TargetPlatform.android ||
platform == TargetPlatform.fuchsia;
}
// ---------------------------------------------------------------------------
// size / density / shape
// ---------------------------------------------------------------------------
/// relative scale factor for a button (text, icon, padding all scale by this).
class ButtonSize {
final double scale;
const ButtonSize(this.scale);
static const ButtonSize normal = ButtonSize(1);
static const ButtonSize xSmall = ButtonSize(1 / 2);
static const ButtonSize small = ButtonSize(3 / 4);
static const ButtonSize large = ButtonSize(2);
static const ButtonSize xLarge = ButtonSize(3);
}
/// Collapses padding to its smallest side, so an icon-only control comes out
/// square instead of inheriting the asymmetric control padding.
EdgeInsets _squarePadding(EdgeInsets padding) {
return EdgeInsets.all(
min(padding.top, min(padding.bottom, min(padding.left, padding.right))),
);
}
/// The single padding resolution every control in the family goes through —
/// buttons, icon buttons, and the select trigger. Keep it that way: the reason
/// four different control heights existed was three places each doing their own
/// version of this maths.
///
/// A null [density] means "follow the theme", which is what everything should
/// pass unless a call site deliberately wants to break out of the app density.
EdgeInsets resolveControlPadding(
ThemeData theme, {
ControlDensity? density,
bool includesBorder = false,
bool squarePadding = false,
double sizeScale = 1.0,
}) {
final resolved = density ?? ControlDensity.of(theme);
final base = squarePadding
? resolved.resolveIcon(theme)
: resolved.resolve(theme);
final scaled = base * sizeScale;
if (!includesBorder) return scaled;
// Border dimensions are added by Container after its explicit padding.
// Remove the unscaled stroke from each side after applying ButtonSize so
// every density/size keeps the same intended outer dimensions.
final border = resolved.tokens(theme).controlBorderWidth;
return EdgeInsets.fromLTRB(
max(0.0, scaled.left - border),
max(0.0, scaled.top - border),
max(0.0, scaled.right - border),
max(0.0, scaled.bottom - border),
);
}
/// rectangle (rounded corners) or a full circle.
enum ButtonShape { rectangle, circle }
// ---------------------------------------------------------------------------
// style abstraction
// ---------------------------------------------------------------------------
typedef ButtonStateProperty<T> =
T Function(BuildContext context, Set<WidgetState> states);
typedef ButtonStatePropertyDelegate<T> =
T Function(BuildContext context, Set<WidgetState> states, T value);
/// contract every button style implements — state aware getters for each prop.
abstract class AbstractButtonStyle {
ButtonStateProperty<Decoration> get decoration;
ButtonStateProperty<MouseCursor> get mouseCursor;
ButtonStateProperty<EdgeInsetsGeometry> get padding;
ButtonStateProperty<TextStyle> get textStyle;
ButtonStateProperty<IconThemeData> get iconTheme;
ButtonStateProperty<EdgeInsetsGeometry> get margin;
/// null = follow the theme's [ControlDensity].
ControlDensity? get density;
bool get squarePadding;
bool get includesBorder;
}
/// concrete style variant — holds the actual state property functions.
class ButtonVariance implements AbstractButtonStyle {
// a bare variance has no opinion on density, so it follows the theme. it used
// to claim ControlDensity.normal, which nothing read (ButtonStyle overrides
// padding wholesale) but was a nasty lie if you ever looked at it.
@override
ControlDensity? get density => null;
@override
bool get squarePadding => false;
@override
final bool includesBorder;
static const AbstractButtonStyle primary = ButtonVariance(
decoration: _buttonPrimaryDecoration,
mouseCursor: _buttonMouseCursor,
textStyle: _buttonPrimaryTextStyle,
iconTheme: _buttonPrimaryIconTheme,
margin: _buttonZeroMargin,
includesBorder: true,
);
static const AbstractButtonStyle secondary = ButtonVariance(
decoration: _buttonSecondaryDecoration,
mouseCursor: _buttonMouseCursor,
textStyle: _buttonSecondaryTextStyle,
iconTheme: _buttonSecondaryIconTheme,
margin: _buttonZeroMargin,
includesBorder: true,
);
static const AbstractButtonStyle outline = ButtonVariance(
decoration: _buttonOutlineDecoration,
mouseCursor: _buttonMouseCursor,
textStyle: _buttonOutlineTextStyle,
iconTheme: _buttonOutlineIconTheme,
margin: _buttonZeroMargin,
includesBorder: true,
);
static const AbstractButtonStyle ghost = ButtonVariance(
decoration: _buttonGhostDecoration,
mouseCursor: _buttonMouseCursor,
textStyle: _buttonGhostTextStyle,
iconTheme: _buttonGhostIconTheme,
margin: _buttonZeroMargin,
);
static const AbstractButtonStyle link = ButtonVariance(
decoration: _buttonLinkDecoration,
mouseCursor: _buttonMouseCursor,
textStyle: _buttonLinkTextStyle,
iconTheme: _buttonLinkIconTheme,
margin: _buttonZeroMargin,
);
static const AbstractButtonStyle text = ButtonVariance(
decoration: _buttonTextDecoration,
mouseCursor: _buttonMouseCursor,
textStyle: _buttonTextTextStyle,
iconTheme: _buttonTextIconTheme,
margin: _buttonZeroMargin,
);
static const AbstractButtonStyle destructive = ButtonVariance(
decoration: _buttonDestructiveDecoration,
mouseCursor: _buttonMouseCursor,
textStyle: _buttonDestructiveTextStyle,
iconTheme: _buttonDestructiveIconTheme,
margin: _buttonZeroMargin,
);
// ghost, but for the remove/revoke/delete kind of action that doesnt deserve
// a filled red slab. before this existed every call site hand coloured a
// ghost button's text + icon, which is exactly the override we dont want
static const AbstractButtonStyle ghostDestructive = ButtonVariance(
decoration: _buttonGhostDestructiveDecoration,
mouseCursor: _buttonMouseCursor,
textStyle: _buttonGhostDestructiveTextStyle,
iconTheme: _buttonGhostDestructiveIconTheme,
margin: _buttonZeroMargin,
);
@override
final ButtonStateProperty<Decoration> decoration;
@override
final ButtonStateProperty<MouseCursor> mouseCursor;
@override
final ButtonStateProperty<TextStyle> textStyle;
@override
final ButtonStateProperty<IconThemeData> iconTheme;
@override
final ButtonStateProperty<EdgeInsetsGeometry> margin;
// padding is NOT a per-variance field. it used to be, and every variance was
// handed the same _buttonPadding function that ignored the border inset — so
// a bare `Button(style: ButtonVariance.outline)` came out 2px bigger on both
// axes than the identical `Button.outline`. it goes through the shared
// resolution now, same as ButtonStyle.
@override
ButtonStateProperty<EdgeInsetsGeometry> get padding => _variancePadding;
EdgeInsets _variancePadding(BuildContext context, Set<WidgetState> states) {
return resolveControlPadding(
GarageTheme.of(context),
includesBorder: includesBorder,
);
}
const ButtonVariance({
required this.decoration,
required this.mouseCursor,
required this.textStyle,
required this.iconTheme,
required this.margin,
this.includesBorder = false,
});
}
/// composable style = a variance + size + density + shape modifiers.
class ButtonStyle implements AbstractButtonStyle {
final AbstractButtonStyle variance;
final ButtonSize size;
@override
final ControlDensity? density;
final ButtonShape shape;
// collapse padding to its smallest side so an icon-only control is square.
@override
final bool squarePadding;
@override
bool get includesBorder => variance.includesBorder;
// hard padding override - wins over the density. [IconButton] uses this to
// zero the padding, because it sizes its own box to the control height
// instead and just centres the icon in it.
final EdgeInsetsGeometry? paddingOverride;
const ButtonStyle({
required this.variance,
this.size = ButtonSize.normal,
this.density,
this.shape = ButtonShape.rectangle,
this.squarePadding = false,
this.paddingOverride,
});
const ButtonStyle.primary({
this.size = ButtonSize.normal,
this.density,
this.shape = ButtonShape.rectangle,
this.squarePadding = false,
this.paddingOverride,
}) : variance = ButtonVariance.primary;
const ButtonStyle.secondary({
this.size = ButtonSize.normal,
this.density,
this.shape = ButtonShape.rectangle,
this.squarePadding = false,
this.paddingOverride,
}) : variance = ButtonVariance.secondary;
const ButtonStyle.outline({
this.size = ButtonSize.normal,
this.density,
this.shape = ButtonShape.rectangle,
this.squarePadding = false,
this.paddingOverride,
}) : variance = ButtonVariance.outline;
const ButtonStyle.ghost({
this.size = ButtonSize.normal,
this.density,
this.shape = ButtonShape.rectangle,
this.squarePadding = false,
this.paddingOverride,
}) : variance = ButtonVariance.ghost;
const ButtonStyle.link({
this.size = ButtonSize.normal,
this.density,
this.shape = ButtonShape.rectangle,
this.squarePadding = false,
this.paddingOverride,
}) : variance = ButtonVariance.link;
const ButtonStyle.text({
this.size = ButtonSize.normal,
this.density,
this.shape = ButtonShape.rectangle,
this.squarePadding = false,
this.paddingOverride,
}) : variance = ButtonVariance.text;
const ButtonStyle.destructive({
this.size = ButtonSize.normal,
this.density,
this.shape = ButtonShape.rectangle,
this.squarePadding = false,
this.paddingOverride,
}) : variance = ButtonVariance.destructive;
const ButtonStyle.ghostDestructive({
this.size = ButtonSize.normal,
this.density,
this.shape = ButtonShape.rectangle,
this.squarePadding = false,
this.paddingOverride,
}) : variance = ButtonVariance.ghostDestructive;
// icon flavours — same as above but default to icon density (square padding)
const ButtonStyle.primaryIcon({
this.size = ButtonSize.normal,
this.density,
this.shape = ButtonShape.rectangle,
this.squarePadding = true,
this.paddingOverride,
}) : variance = ButtonVariance.primary;
const ButtonStyle.secondaryIcon({
this.size = ButtonSize.normal,
this.density,
this.shape = ButtonShape.rectangle,
this.squarePadding = true,
this.paddingOverride,
}) : variance = ButtonVariance.secondary;
const ButtonStyle.outlineIcon({
this.size = ButtonSize.normal,
this.density,
this.shape = ButtonShape.rectangle,
this.squarePadding = true,
this.paddingOverride,
}) : variance = ButtonVariance.outline;
const ButtonStyle.ghostIcon({
this.size = ButtonSize.normal,
this.density,
this.shape = ButtonShape.rectangle,
this.squarePadding = true,
this.paddingOverride,
}) : variance = ButtonVariance.ghost;
const ButtonStyle.destructiveIcon({
this.size = ButtonSize.normal,
this.density,
this.shape = ButtonShape.rectangle,
this.squarePadding = true,
this.paddingOverride,
}) : variance = ButtonVariance.destructive;
const ButtonStyle.ghostDestructiveIcon({
this.size = ButtonSize.normal,
this.density,
this.shape = ButtonShape.rectangle,
this.squarePadding = true,
this.paddingOverride,
}) : variance = ButtonVariance.ghostDestructive;
@override
ButtonStateProperty<Decoration> get decoration {
if (shape == ButtonShape.circle) {
return _resolveCircleDecoration;
}
return variance.decoration;
}
Decoration _resolveCircleDecoration(
BuildContext context,
Set<WidgetState> states,
) {
var decoration = variance.decoration(context, states);
if (decoration is BoxDecoration) {
return BoxDecoration(
color: decoration.color,
image: decoration.image,
border: decoration.border,
borderRadius: null,
boxShadow: decoration.boxShadow,
gradient: decoration.gradient,
shape: BoxShape.circle,
backgroundBlendMode: decoration.backgroundBlendMode,
);
}
// only box decorations are used by our variances, but stay safe
return decoration;
}
@override
ButtonStateProperty<MouseCursor> get mouseCursor => variance.mouseCursor;
// padding comes from the density (+ size scale), not the variance - every
// variance shares the same control padding, only the density changes it.
@override
ButtonStateProperty<EdgeInsetsGeometry> get padding => _resolvePadding;
EdgeInsetsGeometry _resolvePadding(
BuildContext context,
Set<WidgetState> states,
) {
if (paddingOverride != null) return paddingOverride!;
return resolveControlPadding(
GarageTheme.of(context),
density: density,
includesBorder: includesBorder,
squarePadding: squarePadding,
sizeScale: size.scale,
);
}
@override
ButtonStateProperty<TextStyle> get textStyle {
if (size == ButtonSize.normal) {
return variance.textStyle;
}
return _resolveTextStyle;
}
TextStyle _resolveTextStyle(BuildContext context, Set<WidgetState> states) {
var fontSize = variance.textStyle(context, states).fontSize;
if (fontSize == null) {
final textStyle = DefaultTextStyle.of(context).style;
fontSize = textStyle.fontSize ?? 14;
}
return variance
.textStyle(context, states)
.copyWith(fontSize: fontSize * size.scale);
}
@override
ButtonStateProperty<IconThemeData> get iconTheme {
if (size == ButtonSize.normal) {
return variance.iconTheme;
}
return _resolveIconTheme;
}
IconThemeData _resolveIconTheme(
BuildContext context,
Set<WidgetState> states,
) {
var iconSize = variance.iconTheme(context, states).size;
iconSize ??= IconTheme.of(context).size ?? 24;
return variance
.iconTheme(context, states)
.copyWith(size: iconSize * size.scale);
}
@override
ButtonStateProperty<EdgeInsetsGeometry> get margin => variance.margin;
}
/// copyWith for styles — lets you layer state-aware overrides on top of a base
/// style (Toggle relies on this to dim its foreground on hover).
extension ButtonStyleExtension on AbstractButtonStyle {
AbstractButtonStyle copyWith({
ButtonStatePropertyDelegate<Decoration>? decoration,
ButtonStatePropertyDelegate<MouseCursor>? mouseCursor,
ButtonStatePropertyDelegate<EdgeInsetsGeometry>? padding,
ButtonStatePropertyDelegate<TextStyle>? textStyle,
ButtonStatePropertyDelegate<IconThemeData>? iconTheme,
ButtonStatePropertyDelegate<EdgeInsetsGeometry>? margin,
}) {
if (decoration == null &&
mouseCursor == null &&
padding == null &&
textStyle == null &&
iconTheme == null &&
margin == null) {
return this;
}
return _CopyWithButtonStyle(
this,
decoration,
mouseCursor,
padding,
textStyle,
iconTheme,
margin,
);
}
}
class _CopyWithButtonStyle implements AbstractButtonStyle {
final ButtonStatePropertyDelegate<Decoration>? _decoration;
final ButtonStatePropertyDelegate<MouseCursor>? _mouseCursor;
final ButtonStatePropertyDelegate<EdgeInsetsGeometry>? _padding;
final ButtonStatePropertyDelegate<TextStyle>? _textStyle;
final ButtonStatePropertyDelegate<IconThemeData>? _iconTheme;
final ButtonStatePropertyDelegate<EdgeInsetsGeometry>? _margin;
final AbstractButtonStyle _delegate;
const _CopyWithButtonStyle(
this._delegate,
this._decoration,
this._mouseCursor,
this._padding,
this._textStyle,
this._iconTheme,
this._margin,
);
@override
ControlDensity? get density => _delegate.density;
@override
bool get squarePadding => _delegate.squarePadding;
@override
bool get includesBorder => _delegate.includesBorder;
@override
ButtonStateProperty<IconThemeData> get iconTheme {
if (_iconTheme == null) return _delegate.iconTheme;
return (context, states) =>
_iconTheme(context, states, _delegate.iconTheme(context, states));
}
@override
ButtonStateProperty<TextStyle> get textStyle {
if (_textStyle == null) return _delegate.textStyle;
return (context, states) =>
_textStyle(context, states, _delegate.textStyle(context, states));
}
@override
ButtonStateProperty<EdgeInsetsGeometry> get padding {
if (_padding == null) return _delegate.padding;
return (context, states) =>
_padding(context, states, _delegate.padding(context, states));
}
@override
ButtonStateProperty<MouseCursor> get mouseCursor {
if (_mouseCursor == null) return _delegate.mouseCursor;
return (context, states) =>
_mouseCursor(context, states, _delegate.mouseCursor(context, states));
}
@override
ButtonStateProperty<Decoration> get decoration {
if (_decoration == null) return _delegate.decoration;
return (context, states) =>
_decoration(context, states, _delegate.decoration(context, states));
}
@override
ButtonStateProperty<EdgeInsetsGeometry> get margin {
if (_margin == null) return _delegate.margin;
return (context, states) =>
_margin(context, states, _delegate.margin(context, states));
}
}
// ---------------------------------------------------------------------------
// concrete state property functions (copied verbatim from shadcn)
// ---------------------------------------------------------------------------
EdgeInsets _buttonZeroMargin(BuildContext context, Set<WidgetState> states) {
return EdgeInsets.zero;
}
MouseCursor _buttonMouseCursor(BuildContext context, Set<WidgetState> states) {
return states.contains(WidgetState.disabled)
? SystemMouseCursors.basic
: SystemMouseCursors.click;
}
// PRIMARY
Decoration _buttonPrimaryDecoration(
BuildContext context,
Set<WidgetState> states,
) {
var themeData = GarageTheme.of(context);
if (states.contains(WidgetState.disabled)) {
return BoxDecoration(
color: themeData.colorScheme.mutedForeground,
border: Border.all(
color: themeData.colorScheme.controlBorder,
width: themeData.density.controlBorderWidth,
),
borderRadius: BorderRadius.circular(themeData.radiusMd),
);
}
if (states.contains(WidgetState.hovered)) {
return BoxDecoration(
color: themeData.colorScheme.primaryHovered,
border: Border.all(
color: themeData.colorScheme.controlBorder,
width: themeData.density.controlBorderWidth,
),
borderRadius: BorderRadius.circular(themeData.radiusMd),
);
}
return BoxDecoration(
color: themeData.colorScheme.primary,
border: Border.all(
color: themeData.colorScheme.controlBorder,
width: themeData.density.controlBorderWidth,
),
borderRadius: BorderRadius.circular(themeData.radiusMd),
);
}
TextStyle _buttonPrimaryTextStyle(
BuildContext context,
Set<WidgetState> states,
) {
var themeData = GarageTheme.of(context);
// semiBold, where every other variant is medium. Primary is the one style
// that puts DARK text on a LIGHT fill, and that polarity reads thinner: a
// light ground bleeds into the strokes during antialiasing where a dark one
// bleeds out of them. Same weight either way, so the two looked mismatched
// sat next to each other. This is optical compensation, not emphasis - it
// buys back what the polarity takes, which is why it isn't applied to the
// variants that already sit light-on-dark.
return themeData.typography.small
.merge(themeData.typography.semiBold)
.copyWith(color: themeData.colorScheme.primaryForeground);
}
IconThemeData _buttonPrimaryIconTheme(
BuildContext context,
Set<WidgetState> states,
) {
var themeData = GarageTheme.of(context);
return IconThemeData(
color: themeData.colorScheme.primaryForeground,
size: themeData.iconTheme.small.size,
);
}
// SECONDARY
Decoration _buttonSecondaryDecoration(
BuildContext context,
Set<WidgetState> states,
) {
var themeData = GarageTheme.of(context);
final scheme = themeData.colorScheme;
if (states.contains(WidgetState.hovered)) {
return BoxDecoration(
color: scheme.secondaryHovered,
border: Border.all(
color: scheme.controlBorder,
width: themeData.density.controlBorderWidth,
),
borderRadius: BorderRadius.circular(themeData.radiusMd),
);
}
return BoxDecoration(
color: scheme.secondary,
border: Border.all(
color: scheme.controlBorder,
width: themeData.density.controlBorderWidth,
),
borderRadius: BorderRadius.circular(themeData.radiusMd),
);
}
TextStyle _buttonSecondaryTextStyle(
BuildContext context,
Set<WidgetState> states,
) {
var themeData = GarageTheme.of(context);
return themeData.typography.small.copyWith(
color: states.contains(WidgetState.disabled)
? themeData.colorScheme.mutedForeground
: themeData.colorScheme.secondaryForeground,
);
}
IconThemeData _buttonSecondaryIconTheme(
BuildContext context,
Set<WidgetState> states,
) {
var themeData = GarageTheme.of(context);
return IconThemeData(
color: states.contains(WidgetState.disabled)
? themeData.colorScheme.mutedForeground
: themeData.colorScheme.secondaryForeground,
size: themeData.iconTheme.small.size,
);
}
// OUTLINE
// same tokens TextField's own decoration uses (controlFill/Hovered +
// controlBorder + radiusMd) - the explorer's search field + add button are the
// reference look or this, and outline is the "standard" button/field border
// style app-wide, so they read as one family instead of each getting styled
// by hand per call site.
Decoration _buttonOutlineDecoration(
BuildContext context,
Set<WidgetState> states,
) {
var themeData = GarageTheme.of(context);
final scheme = themeData.colorScheme;
if (states.contains(WidgetState.disabled)) {
return BoxDecoration(
color: scheme.controlFill,
// no strokeAlign override - defaults to inside, same as TextField's own
// border. Center-aligned (the old value) paints half the stroke
// outside the box, which is what made this read a pixel bigger than
// an equivalent field on every side.
border: Border.all(
color: scheme.controlBorder,
width: themeData.density.controlBorderWidth,
),
borderRadius: BorderRadius.circular(themeData.radiusMd),
);
}
if (states.contains(WidgetState.hovered)) {
return BoxDecoration(
color: scheme.controlFillHovered,
// no strokeAlign override - defaults to inside, same as TextField's own
// border. Center-aligned (the old value) paints half the stroke
// outside the box, which is what made this read a pixel bigger than
// an equivalent field on every side.
border: Border.all(
color: scheme.controlBorder,
width: themeData.density.controlBorderWidth,
),
borderRadius: BorderRadius.circular(themeData.radiusMd),
);
}
return BoxDecoration(
color: scheme.controlFill,
border: Border.all(
color: scheme.controlBorder,
width: themeData.density.controlBorderWidth,
),
borderRadius: BorderRadius.circular(themeData.radiusMd),
);
}
TextStyle _buttonOutlineTextStyle(
BuildContext context,
Set<WidgetState> states,
) {
var themeData = GarageTheme.of(context);
return themeData.typography.small.copyWith(
color: states.contains(WidgetState.disabled)
? themeData.colorScheme.mutedForeground
: themeData.colorScheme.foreground,
);
}
// muted, not foreground - the explorer's tool button is the reference for
// this style and its icon sat at mutedForeground.
IconThemeData _buttonOutlineIconTheme(
BuildContext context,
Set<WidgetState> states,
) {
var themeData = GarageTheme.of(context);
return IconThemeData(
color: themeData.colorScheme.mutedForeground,
size: themeData.iconTheme.small.size,
);
}
// GHOST
/// Ghost TINTS what its standing on, rather than painting a colour over it.
///
/// It used to fill with `muted` at 0.8 alpha - near enough opaque, and picked
/// against one surface. Put the same button on a different one (the properties
/// actions band, which is itself `muted`; a panel; a rail) and the fill either
/// vanished into the surface or sat on it as a slab, which is the opposite of
/// what ghost means.
///
/// A low-alpha wash of the foreground works anywhere: on a dark surface it
/// lifts, on a light one it darkens, and it always reads as the SAME surface
/// with a highlight rather than a different colour laid on top.
Decoration _buttonGhostDecoration(
BuildContext context,
Set<WidgetState> states,
) {
final themeData = GarageTheme.of(context);
final radius = BorderRadius.circular(themeData.radiusMd);
final tint = themeData.colorScheme.foreground;
if (states.contains(WidgetState.disabled)) {
return BoxDecoration(color: const Color(0x00000000), borderRadius: radius);
}
if (states.contains(WidgetState.pressed)) {
return BoxDecoration(
color: tint.withValues(alpha: 0.12),
borderRadius: radius,
);
}
if (states.contains(WidgetState.hovered)) {
return BoxDecoration(
color: tint.withValues(alpha: 0.07),
borderRadius: radius,
);
}
return BoxDecoration(color: const Color(0x00000000), borderRadius: radius);
}
TextStyle _buttonGhostTextStyle(BuildContext context, Set<WidgetState> states) {
var themeData = GarageTheme.of(context);
return themeData.typography.small.copyWith(
color: states.contains(WidgetState.disabled)
? themeData.colorScheme.mutedForeground
: themeData.colorScheme.foreground,
);
}
IconThemeData _buttonGhostIconTheme(
BuildContext context,
Set<WidgetState> states,
) {
var themeData = GarageTheme.of(context);
return IconThemeData(
color: states.contains(WidgetState.disabled)
? themeData.colorScheme.mutedForeground
: themeData.colorScheme.foreground,
size: themeData.iconTheme.small.size,
);
}
// LINK
Decoration _buttonLinkDecoration(
BuildContext context,
Set<WidgetState> states,
) {
var themeData = GarageTheme.of(context);
return BoxDecoration(borderRadius: BorderRadius.circular(themeData.radiusMd));
}
TextStyle _buttonLinkTextStyle(BuildContext context, Set<WidgetState> states) {
var themeData = GarageTheme.of(context);
return themeData.typography.small.copyWith(
color: states.contains(WidgetState.disabled)
? themeData.colorScheme.mutedForeground
: themeData.colorScheme.foreground,
decoration: states.contains(WidgetState.hovered)
? TextDecoration.underline
: TextDecoration.none,
);
}
IconThemeData _buttonLinkIconTheme(
BuildContext context,
Set<WidgetState> states,
) {
var themeData = GarageTheme.of(context);
return IconThemeData(
color: states.contains(WidgetState.disabled)
? themeData.colorScheme.mutedForeground
: themeData.colorScheme.foreground,
size: themeData.iconTheme.small.size,
);
}
// TEXT
Decoration _buttonTextDecoration(
BuildContext context,
Set<WidgetState> states,
) {
var themeData = GarageTheme.of(context);
return BoxDecoration(borderRadius: BorderRadius.circular(themeData.radiusMd));
}
TextStyle _buttonTextTextStyle(BuildContext context, Set<WidgetState> states) {
var themeData = GarageTheme.of(context);
return themeData.typography.small.copyWith(
color: states.contains(WidgetState.hovered)
? themeData.colorScheme.primary
: themeData.colorScheme.mutedForeground,
);
}
IconThemeData _buttonTextIconTheme(
BuildContext context,
Set<WidgetState> states,
) {
var themeData = GarageTheme.of(context);
return IconThemeData(
color: states.contains(WidgetState.hovered)
? themeData.colorScheme.primary
: themeData.colorScheme.mutedForeground,
size: themeData.iconTheme.small.size,
);
}
// DESTRUCTIVE
Decoration _buttonDestructiveDecoration(
BuildContext context,
Set<WidgetState> states,
) {
var themeData = GarageTheme.of(context);
if (states.contains(WidgetState.disabled)) {
return BoxDecoration(
color: themeData.colorScheme.primaryForeground,
borderRadius: BorderRadius.circular(themeData.radiusMd),
);
}
if (states.contains(WidgetState.hovered)) {
return BoxDecoration(
color: _scaleAlpha(themeData.colorScheme.destructive, 0.8),
borderRadius: BorderRadius.circular(themeData.radiusMd),
);
}
return BoxDecoration(
color: _scaleAlpha(themeData.colorScheme.destructive, 0.5),
borderRadius: BorderRadius.circular(themeData.radiusMd),
);
}
TextStyle _buttonDestructiveTextStyle(
BuildContext context,
Set<WidgetState> states,
) {
var themeData = GarageTheme.of(context);
return themeData.typography.small.copyWith(
// yeah ik, its straight up white regardless of light or dark mode
color: states.contains(WidgetState.disabled)
? themeData.colorScheme.mutedForeground
: const Color(0xFFFFFFFF),
);
}
IconThemeData _buttonDestructiveIconTheme(
BuildContext context,
Set<WidgetState> states,
) {
var themeData = GarageTheme.of(context);
return IconThemeData(
color: states.contains(WidgetState.disabled)
? themeData.colorScheme.mutedForeground
: const Color(0xFFFFFFFF),
size: themeData.iconTheme.small.size,
);
}
// GHOST DESTRUCTIVE
/// Ghost with the destructive colour on the label and icon. Same wash as ghost
/// on hover, just tinted with `destructive` instead of the foreground so the
/// highlight agrees with the text.
Decoration _buttonGhostDestructiveDecoration(
BuildContext context,
Set<WidgetState> states,
) {
final themeData = GarageTheme.of(context);
final radius = BorderRadius.circular(themeData.radiusMd);
final tint = themeData.colorScheme.destructive;
if (states.contains(WidgetState.disabled)) {
return BoxDecoration(color: const Color(0x00000000), borderRadius: radius);
}
if (states.contains(WidgetState.pressed)) {
return BoxDecoration(color: tint.withValues(alpha: 0.2), borderRadius: radius);
}
if (states.contains(WidgetState.hovered)) {
return BoxDecoration(color: tint.withValues(alpha: 0.12), borderRadius: radius);
}
return BoxDecoration(color: const Color(0x00000000), borderRadius: radius);
}
TextStyle _buttonGhostDestructiveTextStyle(
BuildContext context,
Set<WidgetState> states,
) {
var themeData = GarageTheme.of(context);
return themeData.typography.small.copyWith(
color: states.contains(WidgetState.disabled)
? themeData.colorScheme.mutedForeground
: themeData.colorScheme.destructive,
);
}
IconThemeData _buttonGhostDestructiveIconTheme(
BuildContext context,
Set<WidgetState> states,
) {
var themeData = GarageTheme.of(context);
return IconThemeData(
color: states.contains(WidgetState.disabled)
? themeData.colorScheme.mutedForeground
: themeData.colorScheme.destructive,
size: themeData.iconTheme.small.size,
);
}
// ---------------------------------------------------------------------------
// button group border merging
// ---------------------------------------------------------------------------
// corner radius multipliers so adjacent buttons in a group look joined.
class ButtonGroupCorners {
final double topStart;
final double topEnd;
final double bottomStart;
final double bottomEnd;
const ButtonGroupCorners(
this.topStart,
this.topEnd,
this.bottomStart,
this.bottomEnd,
);
static ButtonGroupCorners horizontalIndex(int i, int len) {
if (len <= 1) return const ButtonGroupCorners(1, 1, 1, 1);
if (i == 0) return const ButtonGroupCorners(1, 0, 1, 0); // start
if (i == len - 1) return const ButtonGroupCorners(0, 1, 0, 1); // end
return const ButtonGroupCorners(0, 0, 0, 0);
}
static ButtonGroupCorners verticalIndex(int i, int len) {
if (len <= 1) return const ButtonGroupCorners(1, 1, 1, 1);
if (i == 0) return const ButtonGroupCorners(1, 1, 0, 0); // top
if (i == len - 1) return const ButtonGroupCorners(0, 0, 1, 1); // bottom
return const ButtonGroupCorners(0, 0, 0, 0);
}
/// Both groups' say on a corner, for a group nested in another group. The
/// factors are 0-or-1 keeps, so "round only where BOTH agree" is a product:
/// a field that is bottom row of a vertical group AND start of a horizontal
/// one keeps its bottom-start corner and nothing else.
ButtonGroupCorners merge(ButtonGroupCorners other) => ButtonGroupCorners(
topStart * other.topStart,
topEnd * other.topEnd,
bottomStart * other.bottomStart,
bottomEnd * other.bottomEnd,
);
BorderRadius applyTo(
BorderRadiusGeometry radius,
TextDirection textDirection,
) {
final ltr = textDirection == TextDirection.ltr;
final tl = ltr ? topStart : topEnd;
final tr = ltr ? topEnd : topStart;
final bl = ltr ? bottomStart : bottomEnd;
final br = ltr ? bottomEnd : bottomStart;
final r = radius.resolve(textDirection);
return BorderRadius.only(
topLeft: Radius.elliptical(r.topLeft.x * tl, r.topLeft.y * tl),
topRight: Radius.elliptical(r.topRight.x * tr, r.topRight.y * tr),
bottomLeft: Radius.elliptical(r.bottomLeft.x * bl, r.bottomLeft.y * bl),
bottomRight: Radius.elliptical(
r.bottomRight.x * br,
r.bottomRight.y * br,
),
);
}
}
class ButtonGroupScope extends InheritedWidget {
final ButtonGroupCorners corners;
/// Edges a preceding member of the group already painted. Stored as flags
/// rather than worked out from an axis and an index, because a group nested
/// in another group can be told to drop BOTH - the password row's field
/// gives up its top edge to the email above it and its end edge to the eye
/// beside it, and one axis-and-index pair cant say that.
final bool hideTop;
final bool hideStart;
const ButtonGroupScope({
super.key,
required this.corners,
this.hideTop = false,
this.hideStart = false,
required super.child,
});
static ButtonGroupScope? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<ButtonGroupScope>();
}
/// Removes the edges already painted by a preceding group member, so
/// neighbours share one stroke instead of stacking two.
Border mergedBorder(Border border, TextDirection textDirection) {
final ltr = textDirection == TextDirection.ltr;
return Border(
top: hideTop ? BorderSide.none : border.top,
right: (hideStart && !ltr) ? BorderSide.none : border.right,
bottom: border.bottom,
left: (hideStart && ltr) ? BorderSide.none : border.left,
);
}
@override
bool updateShouldNotify(ButtonGroupScope oldWidget) {
return oldWidget.corners != corners ||
oldWidget.hideTop != hideTop ||
oldWidget.hideStart != hideStart;
}
}
// ---------------------------------------------------------------------------
// the base Button (clickable machinery lives in its state)
// ---------------------------------------------------------------------------
/// the foundational interactive button. wraps content in state aware
/// decoration/padding/text/icon styling with hover + press + focus handling.
class Button extends StatefulWidget {
final bool? enabled;
final bool disableTransition;
final Widget? leading;
final Widget? trailing;
final double? leadingGap;
final double? trailingGap;
final Widget child;
final VoidCallback? onPressed;
final FocusNode? focusNode;
final AlignmentGeometry? alignment;
final AbstractButtonStyle style;
final ValueChanged<bool>? onHover;
final ValueChanged<bool>? onFocus;
final bool? enableFeedback;
final GestureTapDownCallback? onTapDown;
final GestureTapUpCallback? onTapUp;
final GestureTapCancelCallback? onTapCancel;
final GestureTapDownCallback? onSecondaryTapDown;
final GestureTapUpCallback? onSecondaryTapUp;
final GestureTapCancelCallback? onSecondaryTapCancel;
final GestureTapDownCallback? onTertiaryTapDown;
final GestureTapUpCallback? onTertiaryTapUp;
final GestureTapCancelCallback? onTertiaryTapCancel;
final GestureLongPressStartCallback? onLongPressStart;
final GestureLongPressUpCallback? onLongPressUp;
final GestureLongPressMoveUpdateCallback? onLongPressMoveUpdate;
final GestureLongPressEndCallback? onLongPressEnd;
final GestureLongPressUpCallback? onSecondaryLongPress;
final GestureLongPressUpCallback? onTertiaryLongPress;
final bool disableHoverEffect;
final WidgetStatesController? statesController;
final AlignmentGeometry? marginAlignment;
final bool disableFocusOutline;
/// screen reader label. Only set this when the button has no text child to
/// read — an icon-only button, basically. If you set it on a button that
/// does have a Text child you get both read out, which is just noise.
final String? semanticLabel;
/// when non-null the button announces as a toggle in the given state rather
/// than as a plain button. Toggle passes this through.
final bool? semanticToggled;
/// when non-null the button announces as one option of a segmented control
/// (a mutually exclusive group) in the given state. Use this for two-or-more
/// buttons that are modes of a single setting; use [semanticToggled] for a
/// standalone on/off.
final bool? semanticSelected;
const Button({
super.key,
this.statesController,
this.leading,
this.trailing,
this.leadingGap,
this.trailingGap,
required this.child,
this.onPressed,
this.focusNode,
this.alignment,
required this.style,
this.enabled,
this.disableTransition = false,
this.onFocus,
this.onHover,
this.disableHoverEffect = false,
this.enableFeedback,
this.onTapDown,
this.onTapUp,
this.onTapCancel,
this.onSecondaryTapDown,
this.onSecondaryTapUp,
this.onSecondaryTapCancel,
this.onTertiaryTapDown,
this.onTertiaryTapUp,
this.onTertiaryTapCancel,
this.onLongPressStart,
this.onLongPressUp,
this.onLongPressMoveUpdate,
this.onLongPressEnd,
this.onSecondaryLongPress,
this.onTertiaryLongPress,
this.semanticLabel,
this.semanticToggled,
this.semanticSelected,
this.marginAlignment,
this.disableFocusOutline = false,
});
const Button.primary({
super.key,
this.statesController,
this.leading,
this.trailing,
this.leadingGap,
this.trailingGap,
required this.child,
this.onPressed,
this.focusNode,
this.alignment,
this.enabled,
this.style = const ButtonStyle.primary(),
this.disableTransition = false,
this.onFocus,
this.onHover,
this.disableHoverEffect = false,
this.enableFeedback,
this.onTapDown,
this.onTapUp,
this.onTapCancel,
this.onSecondaryTapDown,
this.onSecondaryTapUp,
this.onSecondaryTapCancel,
this.onTertiaryTapDown,
this.onTertiaryTapUp,
this.onTertiaryTapCancel,
this.onLongPressStart,
this.onLongPressUp,
this.onLongPressMoveUpdate,
this.onLongPressEnd,
this.onSecondaryLongPress,
this.onTertiaryLongPress,
this.semanticLabel,
this.semanticToggled,
this.semanticSelected,
this.marginAlignment,
this.disableFocusOutline = false,
});
const Button.secondary({
super.key,
this.statesController,
this.leading,
this.trailing,
this.leadingGap,
this.trailingGap,
required this.child,
this.onPressed,
this.focusNode,
this.alignment,
this.enabled,
this.style = const ButtonStyle.secondary(),
this.disableTransition = false,
this.onFocus,
this.onHover,
this.disableHoverEffect = false,
this.enableFeedback,
this.onTapDown,
this.onTapUp,
this.onTapCancel,
this.onSecondaryTapDown,
this.onSecondaryTapUp,
this.onSecondaryTapCancel,
this.onTertiaryTapDown,
this.onTertiaryTapUp,
this.onTertiaryTapCancel,
this.onLongPressStart,
this.onLongPressUp,
this.onLongPressMoveUpdate,
this.onLongPressEnd,
this.onSecondaryLongPress,
this.onTertiaryLongPress,
this.semanticLabel,
this.semanticToggled,
this.semanticSelected,
this.marginAlignment,
this.disableFocusOutline = false,
});
const Button.outline({
super.key,
this.statesController,
this.leading,
this.trailing,
this.leadingGap,
this.trailingGap,
required this.child,
this.onPressed,
this.focusNode,
this.alignment,
this.enabled,
this.style = const ButtonStyle.outline(),
this.disableTransition = false,
this.onFocus,
this.onHover,
this.disableHoverEffect = false,
this.enableFeedback,
this.onTapDown,
this.onTapUp,
this.onTapCancel,
this.onSecondaryTapDown,
this.onSecondaryTapUp,
this.onSecondaryTapCancel,
this.onTertiaryTapDown,
this.onTertiaryTapUp,
this.onTertiaryTapCancel,
this.onLongPressStart,
this.onLongPressUp,
this.onLongPressMoveUpdate,
this.onLongPressEnd,
this.onSecondaryLongPress,
this.onTertiaryLongPress,
this.semanticLabel,
this.semanticToggled,
this.semanticSelected,
this.marginAlignment,
this.disableFocusOutline = false,
});
const Button.ghost({
super.key,
this.statesController,
this.leading,
this.trailing,
this.leadingGap,
this.trailingGap,
required this.child,
this.onPressed,
this.focusNode,
this.alignment,
this.enabled,
this.style = const ButtonStyle.ghost(),
this.disableTransition = false,
this.onFocus,
this.onHover,
this.disableHoverEffect = false,
this.enableFeedback,
this.onTapDown,
this.onTapUp,
this.onTapCancel,
this.onSecondaryTapDown,
this.onSecondaryTapUp,
this.onSecondaryTapCancel,
this.onTertiaryTapDown,
this.onTertiaryTapUp,
this.onTertiaryTapCancel,
this.onLongPressStart,
this.onLongPressUp,
this.onLongPressMoveUpdate,
this.onLongPressEnd,
this.onSecondaryLongPress,
this.onTertiaryLongPress,
this.semanticLabel,
this.semanticToggled,
this.semanticSelected,
this.marginAlignment,
this.disableFocusOutline = false,
});
const Button.destructive({
super.key,
this.statesController,
this.leading,
this.trailing,
this.leadingGap,
this.trailingGap,
required this.child,
this.onPressed,
this.focusNode,
this.alignment,
this.enabled,
this.style = const ButtonStyle.destructive(),
this.disableTransition = false,
this.onFocus,
this.onHover,
this.disableHoverEffect = false,
this.enableFeedback,
this.onTapDown,
this.onTapUp,
this.onTapCancel,
this.onSecondaryTapDown,
this.onSecondaryTapUp,
this.onSecondaryTapCancel,
this.onTertiaryTapDown,
this.onTertiaryTapUp,
this.onTertiaryTapCancel,
this.onLongPressStart,
this.onLongPressUp,
this.onLongPressMoveUpdate,
this.onLongPressEnd,
this.onSecondaryLongPress,
this.onTertiaryLongPress,
this.semanticLabel,
this.semanticToggled,
this.semanticSelected,
this.marginAlignment,
this.disableFocusOutline = false,
});
const Button.ghostDestructive({
super.key,
this.statesController,
this.leading,
this.trailing,
this.leadingGap,
this.trailingGap,
required this.child,
this.onPressed,
this.focusNode,
this.alignment,
this.enabled,
this.style = const ButtonStyle.ghostDestructive(),
this.disableTransition = false,
this.onFocus,
this.onHover,
this.disableHoverEffect = false,
this.enableFeedback,
this.onTapDown,
this.onTapUp,
this.onTapCancel,
this.onSecondaryTapDown,
this.onSecondaryTapUp,
this.onSecondaryTapCancel,
this.onTertiaryTapDown,
this.onTertiaryTapUp,
this.onTertiaryTapCancel,
this.onLongPressStart,
this.onLongPressUp,
this.onLongPressMoveUpdate,
this.onLongPressEnd,
this.onSecondaryLongPress,
this.onTertiaryLongPress,
this.semanticLabel,
this.semanticToggled,
this.semanticSelected,
this.marginAlignment,
this.disableFocusOutline = false,
});
@override
State<Button> createState() => _ButtonState();
}
class _ButtonState extends State<Button> {
late FocusNode _focusNode;
late WidgetStatesController _controller;
bool _ownsFocusNode = false;
bool _ownsController = false;
bool get _shouldEnableFeedback {
final theme = GarageTheme.of(context);
if (theme.enableFeedback != null) return theme.enableFeedback!;
return _isMobile(theme.platform);
}
bool get _enabled => widget.enabled ?? widget.onPressed != null;
@override
void initState() {
super.initState();
_focusNode = widget.focusNode ?? (FocusNode()..debugLabel = "Button");
_ownsFocusNode = widget.focusNode == null;
_controller = widget.statesController ?? WidgetStatesController();
_ownsController = widget.statesController == null;
_controller.update(WidgetState.disabled, !_enabled);
}
@override
void didUpdateWidget(covariant Button oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.statesController != oldWidget.statesController) {
if (_ownsController) _controller.dispose();
_controller = widget.statesController ?? WidgetStatesController();
_ownsController = widget.statesController == null;
}
if (widget.focusNode != oldWidget.focusNode) {
if (_ownsFocusNode) _focusNode.dispose();
_focusNode = widget.focusNode ?? (FocusNode()..debugLabel = "Button");
_ownsFocusNode = widget.focusNode == null;
}
_controller.update(WidgetState.disabled, !_enabled);
if (widget.disableHoverEffect) {
_controller.update(WidgetState.hovered, false);
}
}
@override
void dispose() {
if (_ownsController) _controller.dispose();
if (_ownsFocusNode) _focusNode.dispose();
super.dispose();
}
// updating states while a build is in flight would assert, so defer to the
// next frame in that case (same guard shadcn's Clickable uses).
void _updateState(WidgetState state, bool value) {
if (!mounted) return;
if (SchedulerBinding.instance.schedulerPhase ==
SchedulerPhase.persistentCallbacks) {
SchedulerBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_controller.update(state, value);
});
return;
}
_controller.update(state, value);
}
void _onPressed() {
if (!_enabled || widget.onPressed == null) return;
widget.onPressed!();
final feedback = widget.enableFeedback ?? _shouldEnableFeedback;
if (feedback && _isMobile(GarageTheme.of(context).platform)) {
try {
SystemSound.play(SystemSoundType.click);
} catch (e, st) {
// dont let a feedback hiccup swallow the tap — but do log it
debugPrint("ana button feedback failed: $e\n$st");
}
}
}
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
// controlGap, not baseGap - this is the leading/trailing icon to label gap
// inside the button, so it matches what TextField uses.
final densityGap = theme.density.controlGap;
final enableFeedback = widget.enableFeedback ?? _shouldEnableFeedback;
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
final states = _controller.value;
final style = widget.style;
Decoration decoration = style.decoration(context, states);
final group = ButtonGroupScope.maybeOf(context);
if (group != null &&
decoration is BoxDecoration &&
decoration.shape != BoxShape.circle) {
final merged = group.corners.applyTo(
decoration.borderRadius ?? BorderRadius.zero,
Directionality.of(context),
);
decoration = decoration.copyWith(borderRadius: merged);
if (decoration.border case final Border border) {
decoration = decoration.copyWith(
border: group.mergedBorder(border, Directionality.of(context)),
);
}
}
final padding = style
.padding(context, states)
.resolve(Directionality.of(context));
final textStyle = style.textStyle(context, states);
final iconTheme = style.iconTheme(context, states);
final cursor = style.mouseCursor(context, states);
BorderRadiusGeometry borderRadius;
BoxShape shape = BoxShape.rectangle;
if (decoration is BoxDecoration) {
borderRadius = decoration.borderRadius ?? theme.borderRadiusMd;
shape = decoration.shape;
} else {
borderRadius = theme.borderRadiusMd;
}
// The compact control type is optically a pixel high when vertically
// centred in its natural padding. Nudge direct text labels down without
// affecting icon-only buttons or caller-composed layouts.
final textOffset = theme.density.controlTextOffset;
Widget content = widget.child is Text && textOffset != 0
? Transform.translate(
offset: Offset(0, textOffset),
child: widget.child,
)
: widget.child;
if (widget.alignment != null) {
content = Align(
alignment: widget.alignment!,
widthFactor: 1,
heightFactor: 1,
child: content,
);
}
if (widget.leading != null || widget.trailing != null) {
// exactly one side occupied, and the caller wants centred content:
// the empty side gets an invisible copy to balance the row.
//
// squarePadding means the child is an icon rather than a label, so
// there is no label to sit off centre - balancing one there just
// hangs 15px of nothing off the side. thats how the snap targets
// button (ruler + chevron) came out 52px wide for 38px of content.
final bool balance =
widget.alignment != null &&
!style.squarePadding &&
(widget.leading == null) != (widget.trailing == null);
final bool mirrorLeading = balance && widget.leading == null;
final bool mirrorTrailing = balance && widget.trailing == null;
content = IntrinsicWidth(
child: IntrinsicHeight(
child: Row(
mainAxisSize: MainAxisSize.min,
// Keep the label at its own line-box height. Stretching it to
// a taller leading/trailing icon changes the text's vertical
// geometry and defeats the label-only optical offset above.
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// A label in Expanded centres in the space LEFT OVER after
// the icon, not across the button - so one icon pushed the
// text off centre by half its own width plus the gap (7.5px
// at compact). An invisible, inert copy of that icon on the
// empty side makes the row symmetric, so the label lands on
// the real centre.
//
// Only when the caller asked for an alignment: a button with
// no alignment shrink-wraps its content and has no centre to
// miss, and padding it out would just make it wider.
// A plain spacer, not a copy of the widget: the leading and
// trailing slots stay pinned to their own edges, and
// reserving the same width on the empty side is what puts
// the label on the button's real centre. A duplicated widget
// would sit in the tree twice - which is exactly how the
// same trick broke a test that looked for one icon.
if (mirrorLeading) ...[
SizedBox(width: theme.iconTheme.small.size),
SizedBox(width: widget.trailingGap ?? densityGap),
],
if (widget.leading != null) widget.leading!,
if (widget.leading != null)
SizedBox(width: widget.leadingGap ?? densityGap),
Expanded(child: content),
if (widget.trailing != null)
SizedBox(width: widget.trailingGap ?? densityGap),
if (widget.trailing != null) widget.trailing!,
if (mirrorTrailing) ...[
SizedBox(width: widget.leadingGap ?? densityGap),
SizedBox(width: theme.iconTheme.small.size),
],
],
),
),
);
}
// plain Container, not AnimatedContainer - no colour easing on
// hover. The reference tool button was a plain Container and
// TextField is instant too, so an eased button hover was the odd
// one out. (widget.disableTransition is now redundant here.)
Widget container = Container(
decoration: decoration,
padding: padding,
child: content,
);
// press feedback (scale to 95%) only fires on platforms where feedback
// is on — desktop/web leaves this a no-op, matching shadcn.
if (enableFeedback) {
container = AnimatedScale(
duration: const Duration(milliseconds: 50),
scale: states.contains(WidgetState.pressed) ? 0.95 : 1.0,
child: container,
);
}
Widget core = GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: widget.onPressed != null ? _onPressed : null,
onSecondaryTapDown: widget.onSecondaryTapDown,
onSecondaryTapUp: widget.onSecondaryTapUp,
onSecondaryTapCancel: widget.onSecondaryTapCancel,
onTertiaryTapDown: widget.onTertiaryTapDown,
onTertiaryTapUp: widget.onTertiaryTapUp,
onTertiaryTapCancel: widget.onTertiaryTapCancel,
onLongPressStart: widget.onLongPressStart,
onLongPressUp: widget.onLongPressUp,
onLongPressMoveUpdate: widget.onLongPressMoveUpdate,
onLongPressEnd: widget.onLongPressEnd,
onSecondaryLongPress: widget.onSecondaryLongPress,
onTertiaryLongPress: widget.onTertiaryLongPress,
onTapDown: widget.onPressed != null
? (details) {
if (enableFeedback) {
_updateState(WidgetState.hovered, true);
}
_updateState(WidgetState.pressed, true);
widget.onTapDown?.call(details);
}
: widget.onTapDown,
onTapUp: widget.onPressed != null
? (details) {
if (enableFeedback) {
_updateState(WidgetState.hovered, false);
}
_updateState(WidgetState.pressed, false);
widget.onTapUp?.call(details);
}
: widget.onTapUp,
onTapCancel: widget.onPressed != null
? () {
if (enableFeedback) {
_updateState(WidgetState.hovered, false);
}
_updateState(WidgetState.pressed, false);
widget.onTapCancel?.call();
}
: widget.onTapCancel,
child: FocusableActionDetector(
enabled: _enabled,
focusNode: _focusNode,
mouseCursor: cursor,
shortcuts: {
LogicalKeySet(LogicalKeyboardKey.enter): const ActivateIntent(),
LogicalKeySet(LogicalKeyboardKey.space): const ActivateIntent(),
},
actions: {
ActivateIntent: CallbackAction<ActivateIntent>(
onInvoke: (e) {
_onPressed();
return null;
},
),
},
onShowHoverHighlight: (value) {
_updateState(
WidgetState.hovered,
value && !widget.disableHoverEffect,
);
widget.onHover?.call(value);
},
onShowFocusHighlight: (value) {
_updateState(WidgetState.focused, value);
widget.onFocus?.call(value);
},
child: DefaultTextStyle.merge(
style: textStyle,
// A button label is one line. Without this it wraps: a leading
// icon takes width off the label's allowance, Expanded forces
// the label to absorb the shortfall, and Text resolves that by
// breaking - "Use" on line one and "passkey" on a second line
// the button's height hides. It reads as a truncation but never
// was one, which makes it very hard to chase from a screenshot.
//
// Overflowing visibly is the better failure: a label that runs
// past its button is a bug you can see, not one that silently
// eats half the word.
maxLines: 1,
softWrap: false,
overflow: TextOverflow.visible,
child: IconTheme.merge(data: iconTheme, child: container),
),
),
);
// one semantics node for the whole button. MergeSemantics folds the
// label Text (and any leading/trailing icon that carries a label) into
// it, so a screen reader reads the button once instead of walking its
// internal layout. This is the single choke point for every button
// variant in the app - Primary/Secondary/Outline/Ghost/Destructive/
// Icon/Toggle all funnel through here, so don't add Semantics in those.
return MergeSemantics(
child: Semantics(
container: true,
button:
widget.semanticToggled == null &&
widget.semanticSelected == null,
toggled: widget.semanticToggled,
selected: widget.semanticSelected,
inMutuallyExclusiveGroup: widget.semanticSelected != null
? true
: null,
enabled: _enabled,
label: widget.semanticLabel,
child: _FocusRing(
focused:
states.contains(WidgetState.focused) &&
!widget.disableFocusOutline,
borderRadius: borderRadius,
shape: shape,
child: core,
),
),
);
},
);
}
}
// focus outline — mirrors shadcn's FocusOutline (a 3px ring, ring colour at
// half alpha, offset 3px outside the bounds, animated in/out).
class _FocusRing extends StatelessWidget {
final bool focused;
final BorderRadiusGeometry borderRadius;
final BoxShape shape;
final Widget child;
const _FocusRing({
required this.focused,
required this.borderRadius,
required this.shape,
required this.child,
});
BorderRadius _adjusted(TextDirection textDirection, double align) {
final resolved = borderRadius.resolve(textDirection);
return BorderRadius.only(
topLeft: resolved.topLeft + Radius.circular(align),
topRight: resolved.topRight + Radius.circular(align),
bottomLeft: resolved.bottomLeft + Radius.circular(align),
bottomRight: resolved.bottomRight + Radius.circular(align),
);
}
@override
Widget build(BuildContext context) {
const align = 3.0;
const offset = -align;
final textDirection = Directionality.of(context);
final ringColor = _scaleAlpha(
GarageTheme.of(context).colorScheme.ring,
0.5,
);
return Stack(
clipBehavior: Clip.none,
fit: StackFit.passthrough,
children: [
child,
TweenAnimationBuilder<double>(
tween: Tween<double>(end: focused ? 1.0 : 0.0),
duration: kDefaultDuration,
builder: (context, value, _) {
return Positioned(
top: offset * value,
right: offset * value,
bottom: offset * value,
left: offset * value,
child: IgnorePointer(
child: Container(
decoration: BoxDecoration(
borderRadius: shape != BoxShape.circle
? _adjusted(textDirection, align)
: null,
shape: shape,
border: Border.all(
color: ringColor,
width: 3.0,
).scale(value),
),
),
),
);
},
),
],
);
}
}
// ---------------------------------------------------------------------------
// convenience variant widgets
// ---------------------------------------------------------------------------
/// primary button (filled, prominent).
class PrimaryButton extends StatelessWidget {
final Widget child;
final VoidCallback? onPressed;
final bool? enabled;
final Widget? leading;
final Widget? trailing;
final AlignmentGeometry? alignment;
final ButtonSize size;
final ControlDensity? density;
final ButtonShape shape;
final FocusNode? focusNode;
final bool disableTransition;
final ValueChanged<bool>? onHover;
final ValueChanged<bool>? onFocus;
final bool? enableFeedback;
final String? semanticLabel;
const PrimaryButton({
super.key,
required this.child,
this.onPressed,
this.enabled,
this.leading,
this.trailing,
this.alignment,
this.size = ButtonSize.normal,
this.density,
this.shape = ButtonShape.rectangle,
this.focusNode,
this.disableTransition = false,
this.onHover,
this.onFocus,
this.enableFeedback,
this.semanticLabel,
});
@override
Widget build(BuildContext context) {
return Button(
onPressed: onPressed,
enabled: enabled,
leading: leading,
trailing: trailing,
alignment: alignment,
style: ButtonStyle.primary(size: size, density: density, shape: shape),
focusNode: focusNode,
disableTransition: disableTransition,
onHover: onHover,
onFocus: onFocus,
enableFeedback: enableFeedback,
semanticLabel: semanticLabel,
child: child,
);
}
}
/// secondary button (muted background).
class SecondaryButton extends StatelessWidget {
final Widget child;
final VoidCallback? onPressed;
final bool? enabled;
final Widget? leading;
final Widget? trailing;
final AlignmentGeometry? alignment;
final ButtonSize size;
final ControlDensity? density;
final ButtonShape shape;
final FocusNode? focusNode;
final bool disableTransition;
final ValueChanged<bool>? onHover;
final ValueChanged<bool>? onFocus;
final bool? enableFeedback;
final String? semanticLabel;
const SecondaryButton({
super.key,
required this.child,
this.onPressed,
this.enabled,
this.leading,
this.trailing,
this.alignment,
this.size = ButtonSize.normal,
this.density,
this.shape = ButtonShape.rectangle,
this.focusNode,
this.disableTransition = false,
this.onHover,
this.onFocus,
this.enableFeedback,
this.semanticLabel,
});
@override
Widget build(BuildContext context) {
return Button(
onPressed: onPressed,
enabled: enabled,
leading: leading,
trailing: trailing,
alignment: alignment,
style: ButtonStyle.secondary(size: size, density: density, shape: shape),
focusNode: focusNode,
disableTransition: disableTransition,
onHover: onHover,
onFocus: onFocus,
enableFeedback: enableFeedback,
semanticLabel: semanticLabel,
child: child,
);
}
}
/// outline button (border, transparent-ish fill).
class OutlineButton extends StatelessWidget {
final Widget child;
final VoidCallback? onPressed;
final bool? enabled;
final Widget? leading;
final Widget? trailing;
final AlignmentGeometry? alignment;
final ButtonSize size;
final ControlDensity? density;
final ButtonShape shape;
final FocusNode? focusNode;
final bool disableTransition;
final ValueChanged<bool>? onHover;
final ValueChanged<bool>? onFocus;
final bool? enableFeedback;
final String? semanticLabel;
const OutlineButton({
super.key,
required this.child,
this.onPressed,
this.enabled,
this.leading,
this.trailing,
this.alignment,
this.size = ButtonSize.normal,
this.density,
this.shape = ButtonShape.rectangle,
this.focusNode,
this.disableTransition = false,
this.onHover,
this.onFocus,
this.enableFeedback,
this.semanticLabel,
});
@override
Widget build(BuildContext context) {
return Button(
onPressed: onPressed,
enabled: enabled,
leading: leading,
trailing: trailing,
alignment: alignment,
style: ButtonStyle.outline(size: size, density: density, shape: shape),
focusNode: focusNode,
disableTransition: disableTransition,
onHover: onHover,
onFocus: onFocus,
enableFeedback: enableFeedback,
semanticLabel: semanticLabel,
child: child,
);
}
}
/// ghost button (no fill until hovered).
class GhostButton extends StatelessWidget {
final Widget child;
final VoidCallback? onPressed;
final bool? enabled;
final Widget? leading;
final Widget? trailing;
final AlignmentGeometry? alignment;
final ButtonSize size;
final ControlDensity? density;
final ButtonShape shape;
final FocusNode? focusNode;
final bool disableTransition;
final ValueChanged<bool>? onHover;
final ValueChanged<bool>? onFocus;
final bool? enableFeedback;
final String? semanticLabel;
const GhostButton({
super.key,
required this.child,
this.onPressed,
this.enabled,
this.leading,
this.trailing,
this.alignment,
this.size = ButtonSize.normal,
this.density,
this.shape = ButtonShape.rectangle,
this.focusNode,
this.disableTransition = false,
this.onHover,
this.onFocus,
this.enableFeedback,
this.semanticLabel,
});
@override
Widget build(BuildContext context) {
return Button(
onPressed: onPressed,
enabled: enabled,
leading: leading,
trailing: trailing,
alignment: alignment,
style: ButtonStyle.ghost(size: size, density: density, shape: shape),
focusNode: focusNode,
disableTransition: disableTransition,
onHover: onHover,
onFocus: onFocus,
enableFeedback: enableFeedback,
semanticLabel: semanticLabel,
child: child,
);
}
}
/// destructive button (red, for delete/remove).
class DestructiveButton extends StatelessWidget {
final Widget child;
final VoidCallback? onPressed;
final bool? enabled;
final Widget? leading;
final Widget? trailing;
final AlignmentGeometry? alignment;
final ButtonSize size;
final ControlDensity? density;
final ButtonShape shape;
final FocusNode? focusNode;
final bool disableTransition;
final ValueChanged<bool>? onHover;
final ValueChanged<bool>? onFocus;
final bool? enableFeedback;
final String? semanticLabel;
const DestructiveButton({
super.key,
required this.child,
this.onPressed,
this.enabled,
this.leading,
this.trailing,
this.alignment,
this.size = ButtonSize.normal,
this.density,
this.shape = ButtonShape.rectangle,
this.focusNode,
this.disableTransition = false,
this.onHover,
this.onFocus,
this.enableFeedback,
this.semanticLabel,
});
@override
Widget build(BuildContext context) {
return Button(
onPressed: onPressed,
enabled: enabled,
leading: leading,
trailing: trailing,
alignment: alignment,
style: ButtonStyle.destructive(
size: size,
density: density,
shape: shape,
),
focusNode: focusNode,
disableTransition: disableTransition,
onHover: onHover,
onFocus: onFocus,
enableFeedback: enableFeedback,
semanticLabel: semanticLabel,
child: child,
);
}
}
/// icon-only button. defaults to square (icon) density. use the named
/// constructors for a specific variance (.primary/.secondary/.outline/etc).
class IconButton extends StatelessWidget {
final Widget icon;
final VoidCallback? onPressed;
final bool? enabled;
final Widget? leading;
final Widget? trailing;
final AlignmentGeometry? alignment;
final ButtonSize size;
final ControlDensity? density;
final ButtonShape shape;
final FocusNode? focusNode;
final bool disableTransition;
final ValueChanged<bool>? onHover;
final ValueChanged<bool>? onFocus;
final bool? enableFeedback;
final String? semanticLabel;
final AbstractButtonStyle variance;
const IconButton({
super.key,
required this.icon,
required this.variance,
this.onPressed,
this.enabled,
this.leading,
this.trailing,
this.alignment,
this.size = ButtonSize.normal,
this.density,
this.shape = ButtonShape.rectangle,
this.focusNode,
this.disableTransition = false,
this.onHover,
this.onFocus,
this.enableFeedback,
this.semanticLabel,
});
const IconButton.primary({
super.key,
required this.icon,
this.onPressed,
this.enabled,
this.leading,
this.trailing,
this.alignment,
this.size = ButtonSize.normal,
this.focusNode,
this.disableTransition = false,
this.onHover,
this.onFocus,
this.enableFeedback,
this.semanticLabel,
this.variance = ButtonVariance.primary,
this.density,
this.shape = ButtonShape.rectangle,
});
const IconButton.secondary({
super.key,
required this.icon,
this.onPressed,
this.enabled,
this.leading,
this.trailing,
this.alignment,
this.size = ButtonSize.normal,
this.focusNode,
this.disableTransition = false,
this.onHover,
this.onFocus,
this.enableFeedback,
this.semanticLabel,
this.variance = ButtonVariance.secondary,
this.density,
this.shape = ButtonShape.rectangle,
});
const IconButton.outline({
super.key,
required this.icon,
this.onPressed,
this.enabled,
this.leading,
this.trailing,
this.alignment,
this.size = ButtonSize.normal,
this.focusNode,
this.disableTransition = false,
this.onHover,
this.onFocus,
this.enableFeedback,
this.semanticLabel,
this.variance = ButtonVariance.outline,
this.density,
this.shape = ButtonShape.rectangle,
});
const IconButton.ghost({
super.key,
required this.icon,
this.onPressed,
this.enabled,
this.leading,
this.trailing,
this.alignment,
this.size = ButtonSize.normal,
this.focusNode,
this.disableTransition = false,
this.onHover,
this.onFocus,
this.enableFeedback,
this.semanticLabel,
this.variance = ButtonVariance.ghost,
this.density,
this.shape = ButtonShape.rectangle,
});
const IconButton.destructive({
super.key,
required this.icon,
this.onPressed,
this.enabled,
this.leading,
this.trailing,
this.alignment,
this.size = ButtonSize.normal,
this.focusNode,
this.disableTransition = false,
this.onHover,
this.onFocus,
this.enableFeedback,
this.semanticLabel,
this.variance = ButtonVariance.destructive,
this.density,
this.shape = ButtonShape.rectangle,
});
const IconButton.ghostDestructive({
super.key,
required this.icon,
this.onPressed,
this.enabled,
this.leading,
this.trailing,
this.alignment,
this.size = ButtonSize.normal,
this.focusNode,
this.disableTransition = false,
this.onHover,
this.onFocus,
this.enableFeedback,
this.semanticLabel,
this.variance = ButtonVariance.ghostDestructive,
this.density,
this.shape = ButtonShape.rectangle,
});
@override
Widget build(BuildContext context) {
return Button(
onPressed: onPressed,
enabled: enabled,
leading: leading,
trailing: trailing,
// centered by default - with compact density (zero padding) an
// unaligned icon lands top-left the moment anything stretches the box
// (e.g. an AspectRatio around it). the explorer tool button this style
// comes from wrapped its icon in a Center for exactly this reason.
alignment: alignment ?? Alignment.center,
style: ButtonStyle(
variance: variance,
size: size,
density: density,
shape: shape,
// an icon-only button is always square, whatever density its handed.
// the control padding is asymmetric (6 across, 2 down), so without
// this a 14px icon comes out a 28x20 rectangle.
squarePadding: true,
),
focusNode: focusNode,
disableTransition: disableTransition,
onHover: onHover,
onFocus: onFocus,
enableFeedback: enableFeedback,
semanticLabel: semanticLabel,
child: icon,
);
}
}
// ---------------------------------------------------------------------------
// toggle (a ghost button that flips to secondary when on)
// ---------------------------------------------------------------------------
/// a pressable on/off button. shows [ButtonStyle.secondary] while on, and the
/// given [style] (ghost by default) while off — matching shadcn's Toggle.
class Toggle extends StatefulWidget {
final bool value;
final ValueChanged<bool>? onChanged;
final Widget child;
final ButtonStyle style;
final bool? enabled;
final String? semanticLabel;
const Toggle({
super.key,
required this.value,
this.onChanged,
required this.child,
this.enabled,
this.semanticLabel,
this.style = const ButtonStyle.ghost(),
});
@override
State<Toggle> createState() => _ToggleState();
}
class _ToggleState extends State<Toggle> {
final WidgetStatesController statesController = WidgetStatesController();
@override
void initState() {
super.initState();
statesController.update(WidgetState.selected, widget.value);
}
@override
void didUpdateWidget(covariant Toggle oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.value != widget.value) {
statesController.update(WidgetState.selected, widget.value);
}
}
@override
void dispose() {
statesController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Button(
statesController: statesController,
enabled: widget.enabled,
style: widget.value
? ButtonStyle.secondary(
density: widget.style.density,
shape: widget.style.shape,
size: widget.style.size,
)
: widget.style.copyWith(
textStyle: (context, states, value) {
final theme = GarageTheme.of(context);
return value.copyWith(
color: states.contains(WidgetState.hovered)
? theme.colorScheme.mutedForeground
: null,
);
},
iconTheme: (context, states, value) {
final theme = GarageTheme.of(context);
return value.copyWith(
color: states.contains(WidgetState.hovered)
? theme.colorScheme.mutedForeground
: null,
);
},
),
onPressed: widget.onChanged != null
? () => widget.onChanged!(!widget.value)
: null,
semanticLabel: widget.semanticLabel,
semanticToggled: widget.value,
child: widget.child,
);
}
}
// ---------------------------------------------------------------------------
// button group — lays buttons out in a row/column with merged inner corners
// ---------------------------------------------------------------------------
/// arranges buttons in a row (default) or column, zeroing the touching corners
/// so they read as one connected control.
class ButtonGroup extends StatelessWidget {
final Axis direction;
final List<Widget> children;
/// Let the CROSS axis be governed by the parent instead of the children.
///
/// A horizontal group normally takes its height from its tallest button, a
/// vertical one its width from its widest; this drops that and fills the
/// parent instead. Only safe where the parent actually bounds that axis -
/// a horizontal group with `expands` inside a row that has no height sizes
/// its buttons to nothing, which is a pile of "Cannot hit test a render box
/// with no size" rather than a layout you can look at.
///
/// It has nothing to do with the main axis. For that, see [fill].
final bool expands;
/// Span the MAIN axis, with every child taking an equal share of it.
///
/// For segmented controls - Off / Optional / Required - which shrink to
/// their labels otherwise, so "Off" comes out a third the width of
/// "Required" and the rest of the field is empty. A segmented control that
/// doesnt span its field reads as three buttons that happen to be touching.
///
/// Buttons left align their label once theyre wider than it needs, so pass
/// `alignment: Alignment.center` on the children as well.
final bool fill;
const ButtonGroup({
super.key,
this.direction = Axis.horizontal,
this.expands = false,
this.fill = false,
required this.children,
});
const ButtonGroup.horizontal({
super.key,
this.expands = false,
this.fill = false,
required this.children,
}) : direction = Axis.horizontal;
const ButtonGroup.vertical({
super.key,
this.expands = false,
this.fill = false,
required this.children,
}) : direction = Axis.vertical;
@override
Widget build(BuildContext context) {
// an enclosing group, if this one is nested inside another. its say gets
// folded into every child's rather than shadowed - without this the inner
// group's scope simply hides the outer one and the seam between the two
// rows comes back as a doubled stroke with rounded corners in the middle.
final outer = ButtonGroupScope.maybeOf(context);
List<Widget> kids = List.from(children);
if (kids.length > 1) {
for (int i = 0; i < kids.length; i++) {
final own = direction == Axis.horizontal
? ButtonGroupCorners.horizontalIndex(i, kids.length)
: ButtonGroupCorners.verticalIndex(i, kids.length);
kids[i] = ButtonGroupScope(
corners: outer == null ? own : outer.corners.merge(own),
hideTop:
(direction == Axis.vertical && i > 0) ||
(outer?.hideTop ?? false),
hideStart:
(direction == Axis.horizontal && i > 0) ||
(outer?.hideStart ?? false),
child: kids[i],
);
}
}
if (fill) {
// wrapped HERE, after the corner-hiding wrappers, so each child keeps
// the seam treatment its position earned and only its share of the
// main axis changes.
for (var i = 0; i < kids.length; i++) {
kids[i] = Expanded(child: kids[i]);
}
}
Widget flex = Flex(
clipBehavior: Clip.none,
mainAxisSize: MainAxisSize.min,
direction: direction,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: kids,
);
if (!expands) {
flex = direction == Axis.horizontal
? IntrinsicHeight(child: flex)
: IntrinsicWidth(child: flex);
}
return flex;
}
}