garage_auth, garage_entitlements, garage_iap and garage_ui, moved out of Garage-Services and Metro-Map-Maker into one public repo. MIT, one readme, docs under docs/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
2415 lines
75 KiB
Dart
2415 lines
75 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,
|
|
);
|
|
|
|
@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;
|
|
|
|
// 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;
|
|
|
|
@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,
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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,
|
|
});
|
|
|
|
@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,
|
|
});
|
|
|
|
@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;
|
|
}
|
|
}
|