// 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 Function(BuildContext context, Set states); typedef ButtonStatePropertyDelegate = T Function(BuildContext context, Set states, T value); /// contract every button style implements — state aware getters for each prop. abstract class AbstractButtonStyle { ButtonStateProperty get decoration; ButtonStateProperty get mouseCursor; ButtonStateProperty get padding; ButtonStateProperty get textStyle; ButtonStateProperty get iconTheme; ButtonStateProperty 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; @override final ButtonStateProperty mouseCursor; @override final ButtonStateProperty textStyle; @override final ButtonStateProperty iconTheme; @override final ButtonStateProperty 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 get padding => _variancePadding; EdgeInsets _variancePadding(BuildContext context, Set 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 get decoration { if (shape == ButtonShape.circle) { return _resolveCircleDecoration; } return variance.decoration; } Decoration _resolveCircleDecoration( BuildContext context, Set 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 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 get padding => _resolvePadding; EdgeInsetsGeometry _resolvePadding( BuildContext context, Set states, ) { if (paddingOverride != null) return paddingOverride!; return resolveControlPadding( GarageTheme.of(context), density: density, includesBorder: includesBorder, squarePadding: squarePadding, sizeScale: size.scale, ); } @override ButtonStateProperty get textStyle { if (size == ButtonSize.normal) { return variance.textStyle; } return _resolveTextStyle; } TextStyle _resolveTextStyle(BuildContext context, Set 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 get iconTheme { if (size == ButtonSize.normal) { return variance.iconTheme; } return _resolveIconTheme; } IconThemeData _resolveIconTheme( BuildContext context, Set 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 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, ButtonStatePropertyDelegate? mouseCursor, ButtonStatePropertyDelegate? padding, ButtonStatePropertyDelegate? textStyle, ButtonStatePropertyDelegate? iconTheme, ButtonStatePropertyDelegate? 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; final ButtonStatePropertyDelegate? _mouseCursor; final ButtonStatePropertyDelegate? _padding; final ButtonStatePropertyDelegate? _textStyle; final ButtonStatePropertyDelegate? _iconTheme; final ButtonStatePropertyDelegate? _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 get iconTheme { if (_iconTheme == null) return _delegate.iconTheme; return (context, states) => _iconTheme(context, states, _delegate.iconTheme(context, states)); } @override ButtonStateProperty get textStyle { if (_textStyle == null) return _delegate.textStyle; return (context, states) => _textStyle(context, states, _delegate.textStyle(context, states)); } @override ButtonStateProperty get padding { if (_padding == null) return _delegate.padding; return (context, states) => _padding(context, states, _delegate.padding(context, states)); } @override ButtonStateProperty get mouseCursor { if (_mouseCursor == null) return _delegate.mouseCursor; return (context, states) => _mouseCursor(context, states, _delegate.mouseCursor(context, states)); } @override ButtonStateProperty get decoration { if (_decoration == null) return _delegate.decoration; return (context, states) => _decoration(context, states, _delegate.decoration(context, states)); } @override ButtonStateProperty 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 states) { return EdgeInsets.zero; } MouseCursor _buttonMouseCursor(BuildContext context, Set states) { return states.contains(WidgetState.disabled) ? SystemMouseCursors.basic : SystemMouseCursors.click; } // PRIMARY Decoration _buttonPrimaryDecoration( BuildContext context, Set 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 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 states, ) { var themeData = GarageTheme.of(context); return IconThemeData( color: themeData.colorScheme.primaryForeground, size: themeData.iconTheme.small.size, ); } // SECONDARY Decoration _buttonSecondaryDecoration( BuildContext context, Set 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 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 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 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 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 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 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 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 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 states, ) { var themeData = GarageTheme.of(context); return BoxDecoration(borderRadius: BorderRadius.circular(themeData.radiusMd)); } TextStyle _buttonLinkTextStyle(BuildContext context, Set 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 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 states, ) { var themeData = GarageTheme.of(context); return BoxDecoration(borderRadius: BorderRadius.circular(themeData.radiusMd)); } TextStyle _buttonTextTextStyle(BuildContext context, Set 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 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 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 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 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(); } /// 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? onHover; final ValueChanged? 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