diff --git a/optimus/lib/optimus.dart b/optimus/lib/optimus.dart index 07a465fa..bc9dc17e 100644 --- a/optimus/lib/optimus.dart +++ b/optimus/lib/optimus.dart @@ -16,6 +16,7 @@ export 'src/button/button_variant.dart'; export 'src/button/dropdown.dart'; export 'src/button/icon.dart'; export 'src/button/split.dart'; +export 'src/button/toggle.dart'; export 'src/card.dart'; export 'src/chat/bubble.dart'; export 'src/chat/chat.dart'; diff --git a/optimus/lib/src/badge/badge.dart b/optimus/lib/src/badge/badge.dart index 664f5f10..374d378f 100644 --- a/optimus/lib/src/badge/badge.dart +++ b/optimus/lib/src/badge/badge.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:optimus/src/badge/badge_variant.dart'; import 'package:optimus/src/badge/base_badge.dart'; -import 'package:optimus/src/button/button_variant.dart'; import 'package:optimus/src/theme/theme.dart'; /// Badges are meant to give a subtle feedback about some state change. @@ -18,9 +17,8 @@ class OptimusBadge extends StatelessWidget { /// Text of the badge. If empty, badge will be represented as a simple dot. final String text; - /// Whether to use the outline. Intended to be enabled when the badge is used - /// for example on top of the [OptimusButtonVariant.ghost]. Outlined version - /// could be more accessible, depending on the underlying component. + /// Whether to use the outline. Outlined version could be more accessible, + /// depending on the underlying component. final bool outline; /// Define how to display the overflowing text. Defaults to diff --git a/optimus/lib/src/button/base_button.dart b/optimus/lib/src/button/base_button.dart index ce6c99cc..c5f06894 100644 --- a/optimus/lib/src/button/base_button.dart +++ b/optimus/lib/src/button/base_button.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:optimus/optimus.dart'; +import 'package:optimus/src/button/base_button_variant.dart'; import 'package:optimus/src/button/common.dart'; class BaseButton extends StatefulWidget { @@ -7,24 +8,28 @@ class BaseButton extends StatefulWidget { super.key, this.onPressed, required this.child, + this.isLoading = false, this.minWidth, this.leadingIcon, this.trailingIcon, this.badgeLabel, this.size = OptimusWidgetSize.large, - this.variant = OptimusButtonVariant.primary, + this.variant = BaseButtonVariant.primary, this.borderRadius, + this.padding, }); final VoidCallback? onPressed; - final Widget child; + final Widget? child; + final bool isLoading; final double? minWidth; final IconData? leadingIcon; final IconData? trailingIcon; final String? badgeLabel; final OptimusWidgetSize size; - final OptimusButtonVariant variant; + final BaseButtonVariant variant; final BorderRadius? borderRadius; + final EdgeInsets? padding; @override State createState() => _BaseButtonState(); @@ -40,69 +45,77 @@ class _BaseButtonState extends State with ThemeGetter { super.dispose(); } + EdgeInsets get _padding => + widget.padding ?? + EdgeInsets.symmetric( + vertical: widget.size.getVerticalPadding(tokens), + horizontal: widget.size.getHorizontalPadding(tokens), + ); + @override Widget build(BuildContext context) { final borderRadius = widget.borderRadius ?? BorderRadius.all(tokens.borderRadius100); - return TextButton( - style: ButtonStyle( - minimumSize: MaterialStateProperty.all( - Size(widget.minWidth ?? 0, widget.size.getValue(tokens)), - ), - maximumSize: MaterialStateProperty.all( - Size(double.infinity, widget.size.getValue(tokens)), - ), - padding: MaterialStateProperty.all( - EdgeInsets.symmetric( - vertical: widget.size.getVerticalPadding(tokens), - horizontal: widget.size.getHorizontalPadding(tokens), + return IgnorePointer( + ignoring: widget.isLoading, + child: TextButton( + style: ButtonStyle( + minimumSize: MaterialStateProperty.all( + Size(widget.minWidth ?? 0, widget.size.getValue(tokens)), ), - ), - shape: MaterialStateProperty.resolveWith( - (states) { - final color = widget.variant.borderColor( + maximumSize: MaterialStateProperty.all( + Size(double.infinity, widget.size.getValue(tokens)), + ), + padding: MaterialStateProperty.all( + _padding, + ), + shape: MaterialStateProperty.resolveWith( + (states) { + final color = widget.variant.getBorderColor( + tokens, + isEnabled: !_statesController.value.isDisabled, + isPressed: _statesController.value.isPressed, + isHovered: _statesController.value.isHovered, + ); + + return RoundedRectangleBorder( + borderRadius: borderRadius, + side: color != null + ? BorderSide(color: color, width: tokens.borderWidth150) + : BorderSide.none, + ); + }, + ), + animationDuration: buttonAnimationDuration, + elevation: MaterialStateProperty.all(0), + visualDensity: VisualDensity.standard, + splashFactory: NoSplash.splashFactory, + backgroundColor: MaterialStateProperty.resolveWith( + (states) => widget.variant.getBackgroundColor( tokens, isEnabled: !_statesController.value.isDisabled, isPressed: _statesController.value.isPressed, isHovered: _statesController.value.isHovered, - ); - - return RoundedRectangleBorder( - borderRadius: borderRadius, - side: color != null - ? BorderSide(color: color, width: tokens.borderWidth150) - : BorderSide.none, - ); - }, - ), - animationDuration: buttonAnimationDuration, - elevation: MaterialStateProperty.all(0), - visualDensity: VisualDensity.standard, - splashFactory: NoSplash.splashFactory, - backgroundColor: MaterialStateProperty.resolveWith( - (states) => widget.variant.backgroundColor( - tokens, - isEnabled: !_statesController.value.isDisabled, - isPressed: _statesController.value.isPressed, - isHovered: _statesController.value.isHovered, + ), ), + overlayColor: const MaterialStatePropertyAll(Colors.transparent), ), - overlayColor: const MaterialStatePropertyAll(Colors.transparent), - ), - statesController: _statesController, - onPressed: widget.onPressed, - child: _ButtonContent( - onPressed: widget.onPressed, - size: widget.size, - variant: widget.variant, - borderRadius: borderRadius, statesController: _statesController, - badgeLabel: widget.badgeLabel, - leadingIcon: widget.leadingIcon, - minWidth: widget.minWidth, - trailingIcon: widget.trailingIcon, - child: widget.child, + onPressed: widget.onPressed, + child: _ButtonContent( + onPressed: widget.onPressed, + size: widget.size, + variant: widget.variant, + borderRadius: borderRadius, + statesController: _statesController, + badgeLabel: widget.badgeLabel, + leadingIcon: widget.leadingIcon, + minWidth: widget.minWidth, + trailingIcon: widget.trailingIcon, + isLoading: widget.isLoading, + child: widget.child, + ), ), ); } @@ -120,18 +133,20 @@ class _ButtonContent extends StatefulWidget { this.leadingIcon, this.minWidth, this.trailingIcon, + this.isLoading = false, }); final VoidCallback? onPressed; - final Widget child; + final Widget? child; final double? minWidth; final IconData? leadingIcon; final IconData? trailingIcon; final String? badgeLabel; final OptimusWidgetSize size; - final OptimusButtonVariant variant; + final BaseButtonVariant variant; final BorderRadius borderRadius; final MaterialStatesController statesController; + final bool isLoading; @override State<_ButtonContent> createState() => _ButtonContentState(); @@ -169,52 +184,76 @@ class _ButtonContentState extends State<_ButtonContent> with ThemeGetter { @override Widget build(BuildContext context) { - final leadingIcon = widget.leadingIcon; - final trailingIcon = widget.trailingIcon; final badgeLabel = widget.badgeLabel; + final insideHorizontalPadding = + widget.size.getInsideHorizontalPadding(tokens); - final foregroundColor = widget.variant.foregroundColor( + final foregroundColor = widget.variant.getForegroundColor( tokens, isEnabled: _isEnabled, isPressed: _isPressed, isHovered: _isHovered, ); - return AnimatedContainer( - duration: buttonAnimationDuration, - curve: buttonAnimationCurve, - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (leadingIcon != null) - Icon(widget.leadingIcon, size: _iconSize, color: foregroundColor), - Padding( - padding: EdgeInsets.symmetric(horizontal: tokens.spacing100), - child: DefaultTextStyle.merge( - style: _textStyle.copyWith(color: foregroundColor), - child: widget.child, - ), - ), - if (badgeLabel != null && badgeLabel.isNotEmpty) - _Badge( - label: badgeLabel, - color: widget.variant.badgeColor( - tokens, - isEnabled: _isEnabled, - isPressed: _isPressed, - isHovered: _isHovered, + return _LoaderStack( + isLoading: _isEnabled && widget.isLoading, + loaderWidget: _SpinningIcon(color: foregroundColor), + child: AnimatedContainer( + duration: buttonAnimationDuration, + curve: buttonAnimationCurve, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.leadingIcon case final leadingIcon?) + Padding( + padding: widget.child != null + ? EdgeInsets.only(right: insideHorizontalPadding) + : EdgeInsets.zero, + child: Icon( + leadingIcon, + size: _iconSize, + color: foregroundColor, + ), ), - textColor: widget.variant.badgeTextColor( - tokens, - isEnabled: _isEnabled, - isPressed: _isPressed, - isHovered: _isHovered, + if (widget.child case final child?) + IconTheme( + data: IconThemeData(color: foregroundColor, size: _iconSize), + child: DefaultTextStyle.merge( + style: _textStyle.copyWith(color: foregroundColor), + child: child, + ), ), - ), - if (trailingIcon != null) - Icon(widget.trailingIcon, size: _iconSize, color: foregroundColor), - ], + if (badgeLabel != null && badgeLabel.isNotEmpty) + Padding( + padding: EdgeInsets.only(left: insideHorizontalPadding), + child: _Badge( + label: badgeLabel, + color: widget.variant.getBadgeColor( + tokens, + isEnabled: _isEnabled, + isPressed: _isPressed, + isHovered: _isHovered, + ), + textColor: widget.variant.getBadgeTextColor( + tokens, + isEnabled: _isEnabled, + isPressed: _isPressed, + isHovered: _isHovered, + ), + ), + ), + if (widget.trailingIcon case final trailingIcon?) + Padding( + padding: EdgeInsets.only(left: insideHorizontalPadding), + child: Icon( + trailingIcon, + size: _iconSize, + color: foregroundColor, + ), + ), + ], + ), ), ); } @@ -257,6 +296,72 @@ class _Badge extends StatelessWidget { } } +class _LoaderStack extends StatelessWidget { + const _LoaderStack({ + required this.isLoading, + required this.loaderWidget, + this.child, + }); + + final bool isLoading; + final Widget loaderWidget; + final Widget? child; + + @override + Widget build(BuildContext context) => Stack( + alignment: Alignment.center, + children: [ + if (child case final child?) + Opacity( + opacity: isLoading ? 0 : 1, + child: child, + ), + if (isLoading) loaderWidget, + ], + ); +} + +class _SpinningIcon extends StatefulWidget { + const _SpinningIcon({required this.color}); + + final Color color; + + @override + State<_SpinningIcon> createState() => _SpinningIconState(); +} + +class _SpinningIconState extends State<_SpinningIcon> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _turns; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1000), + )..repeat(); + _turns = _controller.drive(Tween(begin: 0, end: 1)); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => RotationTransition( + turns: _turns, + child: Icon( + OptimusIcons.spinner, + size: context.tokens.sizing200, + color: widget.color, + ), + ); +} + extension on Set { bool get isPressed => contains(MaterialState.pressed); bool get isHovered => contains(MaterialState.hovered); @@ -278,4 +383,11 @@ extension on OptimusWidgetSize { OptimusWidgetSize.extraLarge => tokens.spacing300, }; + double getInsideHorizontalPadding(OptimusTokens tokens) => switch (this) { + OptimusWidgetSize.small => tokens.spacing100, + OptimusWidgetSize.medium || + OptimusWidgetSize.large || + OptimusWidgetSize.extraLarge => + tokens.spacing150, + }; } diff --git a/optimus/lib/src/button/base_button_variant.dart b/optimus/lib/src/button/base_button_variant.dart new file mode 100644 index 00000000..a13dd9a7 --- /dev/null +++ b/optimus/lib/src/button/base_button_variant.dart @@ -0,0 +1,180 @@ +import 'package:flutter/widgets.dart'; +import 'package:optimus/src/theme/optimus_tokens.dart'; + +enum BaseButtonVariant { primary, secondary, tertiary, ghost, danger, success } + +extension ColorScheme on BaseButtonVariant { + Color? getBackgroundColor( + OptimusTokens tokens, { + required bool isEnabled, + required bool isPressed, + required bool isHovered, + }) { + switch (this) { + case BaseButtonVariant.primary: + if (!isEnabled) return tokens.backgroundDisabled; + if (isPressed) return tokens.backgroundInteractivePrimaryActive; + if (isHovered) return tokens.backgroundInteractivePrimaryHover; + + return tokens.backgroundInteractivePrimaryDefault; + case BaseButtonVariant.secondary: + if (!isEnabled) return null; + if (isPressed) return tokens.backgroundInteractivePrimaryActive; + if (isHovered) return tokens.backgroundInteractivePrimaryHover; + + return null; + case BaseButtonVariant.tertiary: + case BaseButtonVariant.ghost: + if (!isEnabled) return null; + if (isPressed) return tokens.backgroundInteractiveNeutralSubtleActive; + if (isHovered) return tokens.backgroundInteractiveNeutralSubtleHover; + + return null; + case BaseButtonVariant.danger: + if (!isEnabled) return tokens.backgroundDisabled; + if (isPressed) return tokens.backgroundInteractiveDangerActive; + if (isHovered) return tokens.backgroundInteractiveDangerHover; + + return tokens.backgroundInteractiveDangerDefault; + case BaseButtonVariant.success: + if (!isEnabled) return tokens.backgroundDisabled; + if (isPressed) return tokens.backgroundInteractiveSuccessActive; + if (isHovered) return tokens.backgroundInteractiveSuccessHover; + + return tokens.backgroundInteractiveSuccessDefault; + } + } + + Color getForegroundColor( + OptimusTokens tokens, { + required bool isEnabled, + required bool isPressed, + required bool isHovered, + }) { + switch (this) { + case BaseButtonVariant.primary: + case BaseButtonVariant.danger: + case BaseButtonVariant.success: + if (!isEnabled) return tokens.textDisabled; + + return tokens.textStaticInverse; + case BaseButtonVariant.secondary: + if (!isEnabled) return tokens.textDisabled; + if (isPressed || isHovered) { + return tokens.textStaticInverse; + } + + return tokens.textInteractiveDefault; + case BaseButtonVariant.tertiary: + if (!isEnabled) return tokens.textDisabled; + + return tokens.textStaticSecondary; + case BaseButtonVariant.ghost: + if (!isEnabled) return tokens.textDisabled; + + return tokens.textStaticPrimary; + } + } + + Color getBadgeColor( + OptimusTokens tokens, { + required bool isEnabled, + required bool isPressed, + required bool isHovered, + }) { + switch (this) { + case BaseButtonVariant.primary: + case BaseButtonVariant.danger: + case BaseButtonVariant.success: + if (!isEnabled) return tokens.textDisabled; + + return tokens.textStaticInverse; + case BaseButtonVariant.secondary: + if (!isEnabled) return tokens.backgroundDisabled; + if (isPressed || isHovered) return tokens.backgroundStaticFlat; + + return tokens.textInteractiveDefault; + case BaseButtonVariant.tertiary: + if (!isEnabled) return tokens.backgroundDisabled; + + return tokens.textStaticSecondary; + case BaseButtonVariant.ghost: + if (!isEnabled) return tokens.backgroundDisabled; + + return tokens.textStaticPrimary; + } + } + + Color getBadgeTextColor( + OptimusTokens tokens, { + required bool isEnabled, + required bool isPressed, + required bool isHovered, + }) { + switch (this) { + case BaseButtonVariant.primary: + if (!isEnabled) return tokens.backgroundDisabled; + if (isPressed) return tokens.backgroundInteractivePrimaryActive; + if (isHovered) return tokens.backgroundInteractivePrimaryHover; + + return tokens.backgroundInteractivePrimaryDefault; + case BaseButtonVariant.secondary: + if (!isEnabled) return tokens.textDisabled; + if (isPressed) return tokens.backgroundInteractivePrimaryActive; + if (isHovered) return tokens.backgroundInteractivePrimaryHover; + + return tokens.textStaticInverse; + case BaseButtonVariant.tertiary: + if (!isEnabled) return tokens.textDisabled; + if (isPressed) return tokens.backgroundInteractiveNeutralSubtleActive; + if (isHovered) return tokens.backgroundInteractiveNeutralSubtleHover; + + return tokens.backgroundStaticFlat; + case BaseButtonVariant.ghost: + if (!isEnabled) return tokens.textDisabled; + if (isPressed) return tokens.backgroundInteractiveNeutralSubtleActive; + if (isHovered) return tokens.backgroundInteractiveNeutralSubtleHover; + + return tokens.textStaticInverse; + case BaseButtonVariant.danger: + if (!isEnabled) return tokens.backgroundDisabled; + if (isPressed) return tokens.backgroundInteractiveDangerActive; + if (isHovered) return tokens.backgroundInteractiveDangerHover; + + return tokens.backgroundInteractiveDangerDefault; + case BaseButtonVariant.success: + if (!isEnabled) return tokens.backgroundDisabled; + if (isPressed) return tokens.backgroundInteractiveSuccessActive; + if (isHovered) return tokens.backgroundInteractiveSuccessHover; + + return tokens.backgroundInteractiveSuccessDefault; + } + } + + Color? getBorderColor( + OptimusTokens tokens, { + required bool isEnabled, + required bool isPressed, + required bool isHovered, + }) { + switch (this) { + case BaseButtonVariant.primary: + case BaseButtonVariant.ghost: + case BaseButtonVariant.danger: + case BaseButtonVariant.success: + return null; + case BaseButtonVariant.secondary: + if (!isEnabled) return tokens.borderDisabled; + if (isPressed) return tokens.borderInteractivePrimaryActive; + if (isHovered) return tokens.borderInteractivePrimaryHover; + + return tokens.borderInteractivePrimaryDefault; + case BaseButtonVariant.tertiary: + if (!isEnabled) return tokens.borderDisabled; + if (isPressed) return tokens.borderInteractiveSecondaryActive; + if (isHovered) return tokens.borderInteractiveSecondaryHover; + + return tokens.borderInteractiveSecondaryDefault; + } + } +} diff --git a/optimus/lib/src/button/base_dropdown_button.dart b/optimus/lib/src/button/base_dropdown_button.dart index 9c7d34d8..3fec39db 100644 --- a/optimus/lib/src/button/base_dropdown_button.dart +++ b/optimus/lib/src/button/base_dropdown_button.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:optimus/optimus.dart'; +import 'package:optimus/src/button/base_button_variant.dart'; import 'package:optimus/src/button/common.dart'; import 'package:optimus/src/common/gesture_wrapper.dart'; import 'package:optimus/src/overlay_controller.dart'; @@ -71,15 +72,16 @@ class _BaseDropDownButtonState extends State> setState(() => _isPressed = isPressed); bool get _isEnabled => widget.onItemSelected != null; + BaseButtonVariant get _variant => widget.variant.toBaseVariant(); - Color get _textColor => widget.variant.toButtonVariant().foregroundColor( + Color get _textColor => _variant.getForegroundColor( tokens, isEnabled: _isEnabled, isPressed: _isPressed, isHovered: _isHovered, ); - Color? get _borderColor => widget.variant.toButtonVariant().borderColor( + Color? get _borderColor => _variant.getBorderColor( tokens, isHovered: _isHovered, isPressed: _isPressed, @@ -90,7 +92,7 @@ class _BaseDropDownButtonState extends State> ? tokens.bodyMediumStrong.copyWith(color: _textColor) : tokens.bodyLargeStrong.copyWith(color: _textColor); - Color? get _color => widget.variant.toButtonVariant().backgroundColor( + Color? get _color => _variant.getBackgroundColor( tokens, isEnabled: _isEnabled, isPressed: _isPressed, diff --git a/optimus/lib/src/button/button.dart b/optimus/lib/src/button/button.dart index 638cb969..2605d681 100644 --- a/optimus/lib/src/button/button.dart +++ b/optimus/lib/src/button/button.dart @@ -18,6 +18,7 @@ class OptimusButton extends StatelessWidget { this.counter, this.leadingIcon, this.trailingIcon, + this.isLoading = false, this.size = OptimusWidgetSize.large, this.variant = OptimusButtonVariant.primary, }) : assert( @@ -50,6 +51,9 @@ class OptimusButton extends StatelessWidget { /// Size of the button widget. final OptimusWidgetSize size; + /// Whether the button is in the loading state. + final bool isLoading; + /// {@template optimus.button.variant} /// The variant of the button. /// @@ -82,7 +86,8 @@ class OptimusButton extends StatelessWidget { trailingIcon: trailingIcon, badgeLabel: counter?.let((v) => v > 99 ? '99+' : v.toString()), size: size, - variant: variant, + isLoading: isLoading, + variant: variant.toBaseVariant(), child: child, ); } diff --git a/optimus/lib/src/button/button_variant.dart b/optimus/lib/src/button/button_variant.dart index 2586f574..a5247c36 100644 --- a/optimus/lib/src/button/button_variant.dart +++ b/optimus/lib/src/button/button_variant.dart @@ -1,166 +1,13 @@ -import 'package:flutter/material.dart'; -import 'package:optimus/optimus.dart'; +import 'package:optimus/src/button/base_button_variant.dart'; enum OptimusButtonVariant { primary, secondary, tertiary, ghost, danger } -extension ColorScheme on OptimusButtonVariant { - Color? backgroundColor( - OptimusTokens tokens, { - required bool isEnabled, - required bool isPressed, - required bool isHovered, - }) { - switch (this) { - case OptimusButtonVariant.primary: - if (!isEnabled) return tokens.backgroundDisabled; - if (isPressed) return tokens.backgroundInteractivePrimaryActive; - if (isHovered) return tokens.backgroundInteractivePrimaryHover; - - return tokens.backgroundInteractivePrimaryDefault; - case OptimusButtonVariant.secondary: - if (!isEnabled) return null; - if (isPressed) return tokens.backgroundInteractivePrimaryActive; - if (isHovered) return tokens.backgroundInteractivePrimaryHover; - - return null; - case OptimusButtonVariant.tertiary: - case OptimusButtonVariant.ghost: - if (!isEnabled) return null; - if (isPressed) return tokens.backgroundInteractiveNeutralSubtleActive; - if (isHovered) return tokens.backgroundInteractiveNeutralSubtleHover; - - return null; - case OptimusButtonVariant.danger: - if (!isEnabled) return tokens.backgroundDisabled; - if (isPressed) return tokens.backgroundInteractiveDangerActive; - if (isHovered) return tokens.backgroundInteractiveDangerHover; - - return tokens.backgroundInteractiveDangerDefault; - } - } - - Color foregroundColor( - OptimusTokens tokens, { - required bool isEnabled, - required bool isPressed, - required bool isHovered, - }) { - switch (this) { - case OptimusButtonVariant.primary: - case OptimusButtonVariant.danger: - if (!isEnabled) return tokens.textDisabled; - - return tokens.textStaticInverse; - case OptimusButtonVariant.secondary: - if (!isEnabled) return tokens.textDisabled; - if (isPressed || isHovered) { - return tokens.textStaticInverse; - } - - return tokens.textInteractiveDefault; - case OptimusButtonVariant.tertiary: - if (!isEnabled) return tokens.textDisabled; - - return tokens.textStaticSecondary; - case OptimusButtonVariant.ghost: - if (!isEnabled) return tokens.textDisabled; - - return tokens.textStaticPrimary; - } - } - - Color badgeColor( - OptimusTokens tokens, { - required bool isEnabled, - required bool isPressed, - required bool isHovered, - }) { - switch (this) { - case OptimusButtonVariant.primary: - case OptimusButtonVariant.danger: - if (!isEnabled) return tokens.textDisabled; - - return tokens.textStaticInverse; - case OptimusButtonVariant.secondary: - if (!isEnabled) return tokens.backgroundDisabled; - if (isPressed) return tokens.backgroundStaticFlat; - if (isHovered) return tokens.backgroundStaticFlat; - - return tokens.textInteractiveDefault; - case OptimusButtonVariant.tertiary: - if (!isEnabled) return tokens.backgroundDisabled; - - return tokens.textStaticSecondary; - case OptimusButtonVariant.ghost: - if (!isEnabled) return tokens.backgroundDisabled; - - return tokens.textStaticPrimary; - } - } - - Color badgeTextColor( - OptimusTokens tokens, { - required bool isEnabled, - required bool isPressed, - required bool isHovered, - }) { - switch (this) { - case OptimusButtonVariant.primary: - if (!isEnabled) return tokens.backgroundDisabled; - if (isPressed) return tokens.backgroundInteractivePrimaryActive; - if (isHovered) return tokens.backgroundInteractivePrimaryHover; - - return tokens.backgroundInteractivePrimaryDefault; - case OptimusButtonVariant.secondary: - if (!isEnabled) return tokens.textDisabled; - if (isPressed) return tokens.backgroundInteractivePrimaryActive; - if (isHovered) return tokens.backgroundInteractivePrimaryHover; - - return tokens.textStaticInverse; - case OptimusButtonVariant.tertiary: - if (!isEnabled) return tokens.textDisabled; - if (isPressed) return tokens.backgroundInteractiveNeutralSubtleActive; - if (isHovered) return tokens.backgroundInteractiveNeutralSubtleHover; - - return tokens.backgroundStaticFlat; - case OptimusButtonVariant.ghost: - if (!isEnabled) return tokens.textDisabled; - if (isPressed) return tokens.backgroundInteractiveNeutralSubtleActive; - if (isHovered) return tokens.backgroundInteractiveNeutralSubtleHover; - - return tokens.textStaticInverse; - case OptimusButtonVariant.danger: - if (!isEnabled) return tokens.backgroundDisabled; - if (isPressed) return tokens.backgroundInteractiveDangerActive; - if (isHovered) return tokens.backgroundInteractiveDangerHover; - - return tokens.backgroundInteractiveDangerDefault; - } - } - - Color? borderColor( - OptimusTokens tokens, { - required bool isEnabled, - required bool isPressed, - required bool isHovered, - }) { - switch (this) { - case OptimusButtonVariant.primary: - case OptimusButtonVariant.ghost: - case OptimusButtonVariant.danger: - return null; - case OptimusButtonVariant.secondary: - if (!isEnabled) return tokens.borderDisabled; - if (isPressed) return tokens.borderInteractivePrimaryActive; - if (isHovered) return tokens.borderInteractivePrimaryHover; - - return tokens.borderInteractivePrimaryDefault; - case OptimusButtonVariant.tertiary: - if (!isEnabled) return tokens.borderDisabled; - if (isPressed) return tokens.borderInteractiveSecondaryActive; - if (isHovered) return tokens.borderInteractiveSecondaryHover; - - return tokens.borderInteractiveSecondaryDefault; - } - } +extension BaseVariantResolve on OptimusButtonVariant { + BaseButtonVariant toBaseVariant() => switch (this) { + OptimusButtonVariant.primary => BaseButtonVariant.primary, + OptimusButtonVariant.secondary => BaseButtonVariant.secondary, + OptimusButtonVariant.tertiary => BaseButtonVariant.tertiary, + OptimusButtonVariant.ghost => BaseButtonVariant.ghost, + OptimusButtonVariant.danger => BaseButtonVariant.danger, + }; } diff --git a/optimus/lib/src/button/dropdown.dart b/optimus/lib/src/button/dropdown.dart index 34205b1a..a98fdd3e 100644 --- a/optimus/lib/src/button/dropdown.dart +++ b/optimus/lib/src/button/dropdown.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:optimus/optimus.dart'; +import 'package:optimus/src/button/base_button_variant.dart'; import 'package:optimus/src/button/base_dropdown_button.dart'; enum OptimusDropdownButtonVariant { @@ -40,12 +41,11 @@ class OptimusDropDownButton extends StatelessWidget { ); } -extension CommonButtonVariant on OptimusDropdownButtonVariant { - OptimusButtonVariant toButtonVariant() => switch (this) { - OptimusDropdownButtonVariant.tertiary => OptimusButtonVariant.tertiary, - OptimusDropdownButtonVariant.primary => OptimusButtonVariant.primary, - OptimusDropdownButtonVariant.secondary => - OptimusButtonVariant.secondary, - OptimusDropdownButtonVariant.text => OptimusButtonVariant.ghost, +extension BaseButtonVariantResolve on OptimusDropdownButtonVariant { + BaseButtonVariant toBaseVariant() => switch (this) { + OptimusDropdownButtonVariant.tertiary => BaseButtonVariant.tertiary, + OptimusDropdownButtonVariant.primary => BaseButtonVariant.primary, + OptimusDropdownButtonVariant.secondary => BaseButtonVariant.secondary, + OptimusDropdownButtonVariant.text => BaseButtonVariant.ghost, }; } diff --git a/optimus/lib/src/button/icon.dart b/optimus/lib/src/button/icon.dart index aacb9090..1bdcadeb 100644 --- a/optimus/lib/src/button/icon.dart +++ b/optimus/lib/src/button/icon.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:optimus/optimus.dart'; +import 'package:optimus/src/button/base_button_variant.dart'; import 'package:optimus/src/button/common.dart'; import 'package:optimus/src/common/gesture_wrapper.dart'; @@ -44,15 +45,16 @@ class _OptimusIconButtonState extends State setState(() => _isPressed = isPressed); bool get _isEnabled => widget.onPressed != null; + BaseButtonVariant get _variant => widget.variant.toBaseVariant(); @override Widget build(BuildContext context) { - final borderColor = widget.variant.borderColor( - tokens, - isEnabled: _isEnabled, - isPressed: _isPressed, - isHovered: _isHovered, - ); + final borderColor = widget.variant.toBaseVariant().getBorderColor( + tokens, + isEnabled: _isEnabled, + isPressed: _isPressed, + isHovered: _isHovered, + ); final isEnabled = widget.onPressed != null; return IgnorePointer( @@ -66,7 +68,7 @@ class _OptimusIconButtonState extends State width: widget.size.getContainerSize(tokens), padding: EdgeInsets.zero, decoration: BoxDecoration( - color: widget.variant.backgroundColor( + color: _variant.getBackgroundColor( tokens, isEnabled: _isEnabled, isPressed: _isPressed, @@ -83,7 +85,7 @@ class _OptimusIconButtonState extends State duration: buttonAnimationDuration, child: IconTheme.merge( data: IconThemeData( - color: widget.variant.foregroundColor( + color: _variant.getForegroundColor( tokens, isEnabled: _isEnabled, isPressed: _isPressed, diff --git a/optimus/lib/src/button/split.dart b/optimus/lib/src/button/split.dart index f4e4fb87..13f65d97 100644 --- a/optimus/lib/src/button/split.dart +++ b/optimus/lib/src/button/split.dart @@ -1,6 +1,7 @@ import 'package:flutter/widgets.dart'; import 'package:optimus/optimus.dart'; import 'package:optimus/src/button/base_button.dart'; +import 'package:optimus/src/button/base_button_variant.dart'; import 'package:optimus/src/button/base_dropdown_button.dart'; enum OptimusSplitButtonVariant { @@ -80,10 +81,10 @@ class OptimusSplitButton extends StatelessWidget { } extension on OptimusSplitButtonVariant { - OptimusButtonVariant toButtonVariant() => switch (this) { - OptimusSplitButtonVariant.primary => OptimusButtonVariant.primary, - OptimusSplitButtonVariant.secondary => OptimusButtonVariant.secondary, - OptimusSplitButtonVariant.tertiary => OptimusButtonVariant.tertiary, + BaseButtonVariant toButtonVariant() => switch (this) { + OptimusSplitButtonVariant.primary => BaseButtonVariant.primary, + OptimusSplitButtonVariant.secondary => BaseButtonVariant.secondary, + OptimusSplitButtonVariant.tertiary => BaseButtonVariant.tertiary, }; OptimusDropdownButtonVariant toDropdownButtonVariant() => switch (this) { diff --git a/optimus/lib/src/button/toggle.dart b/optimus/lib/src/button/toggle.dart new file mode 100644 index 00000000..35629be9 --- /dev/null +++ b/optimus/lib/src/button/toggle.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:optimus/optimus.dart'; +import 'package:optimus/src/button/base_button.dart'; +import 'package:optimus/src/button/base_button_variant.dart'; + +enum OptimusToggleButtonSizeVariant { small, medium, large } + +/// A toggle button is a button that can be toggled on (selected) or off (not +/// selected). +class OptimusToggleButton extends StatelessWidget { + const OptimusToggleButton({ + super.key, + this.label, + this.isToggled = false, + this.onPressed, + this.isLoading = false, + this.size = OptimusToggleButtonSizeVariant.large, + }); + + /// The label of the button. Typically a [Text] widget. If null, the button + /// will use only the icon. + final Widget? label; + + /// Whether the button is toggled on. + final bool isToggled; + + /// Called when the button is tapped or otherwise activated. + final VoidCallback? onPressed; + + /// Whether the button is in the loading state. + final bool isLoading; + + /// The size of the button. + final OptimusToggleButtonSizeVariant size; + + @override + Widget build(BuildContext context) { + final tokens = context.tokens; + final contentPadding = label != null + ? EdgeInsets.symmetric( + vertical: size.getLabelVerticalPadding(tokens), + horizontal: tokens.spacing100, + ) + : EdgeInsets.all(size.getCompactPadding(tokens)); + + return BaseButton( + variant: + isToggled ? BaseButtonVariant.success : BaseButtonVariant.tertiary, + size: size.toWidgetSize(), + isLoading: isLoading, + onPressed: onPressed, + padding: contentPadding, + child: Row( + children: [ + Icon(OptimusIcons.checkbox_plus, size: tokens.sizing200), + if (label case final label?) + Padding( + padding: EdgeInsets.only( + left: size.getPaddingInsidePadding(tokens), + ), + child: label, + ), + ], + ), + ); + } +} + +extension on OptimusToggleButtonSizeVariant { + OptimusWidgetSize toWidgetSize() => switch (this) { + OptimusToggleButtonSizeVariant.small => OptimusWidgetSize.small, + OptimusToggleButtonSizeVariant.medium => OptimusWidgetSize.medium, + OptimusToggleButtonSizeVariant.large => OptimusWidgetSize.large, + }; + + double getLabelVerticalPadding(OptimusTokens tokens) => switch (this) { + OptimusToggleButtonSizeVariant.small => tokens.spacing50, + OptimusToggleButtonSizeVariant.medium => tokens.spacing100, + OptimusToggleButtonSizeVariant.large => tokens.spacing150, + }; + + double getPaddingInsidePadding(OptimusTokens tokens) => switch (this) { + OptimusToggleButtonSizeVariant.small => tokens.spacing100, + OptimusToggleButtonSizeVariant.medium || + OptimusToggleButtonSizeVariant.large => + tokens.spacing150 + }; + + double getCompactPadding(OptimusTokens tokens) => switch (this) { + OptimusToggleButtonSizeVariant.small => tokens.spacing100, + OptimusToggleButtonSizeVariant.medium => tokens.spacing150, + OptimusToggleButtonSizeVariant.large => tokens.spacing200 + }; +} diff --git a/storybook/lib/main.dart b/storybook/lib/main.dart index a286a57c..931fda2c 100644 --- a/storybook/lib/main.dart +++ b/storybook/lib/main.dart @@ -5,6 +5,7 @@ import 'package:storybook/stories/button/button.dart'; import 'package:storybook/stories/button/dropdown.dart'; import 'package:storybook/stories/button/icon.dart'; import 'package:storybook/stories/button/split.dart'; +import 'package:storybook/stories/button/toggle.dart'; import 'package:storybook/stories/card.dart'; import 'package:storybook/stories/chat/bubble.dart'; import 'package:storybook/stories/chat/chat.dart'; @@ -169,6 +170,7 @@ class _MyAppState extends State { spinnerStory, systemWideBannerStory, passwordStory, + toggleButtonStory, ], ), }, diff --git a/storybook/lib/stories/button/button.dart b/storybook/lib/stories/button/button.dart index 59cd6449..8dc491ed 100644 --- a/storybook/lib/stories/button/button.dart +++ b/storybook/lib/stories/button/button.dart @@ -39,6 +39,7 @@ final Story button = Story( initial: OptimusWidgetSize.large, options: sizeOptions, ), + isLoading: k.boolean(label: 'Loading', initial: false), variant: v, leadingIcon: leadingIcon, trailingIcon: trailingIcon, diff --git a/storybook/lib/stories/button/toggle.dart b/storybook/lib/stories/button/toggle.dart new file mode 100644 index 00000000..2af325f6 --- /dev/null +++ b/storybook/lib/stories/button/toggle.dart @@ -0,0 +1,66 @@ +import 'package:flutter/widgets.dart'; +import 'package:optimus/optimus.dart'; +import 'package:storybook/utils.dart'; +import 'package:storybook_flutter/storybook_flutter.dart'; + +final toggleButtonStory = Story( + name: 'Buttons/Toggle', + builder: (context) { + final k = context.knobs; + final isEnabled = k.boolean(label: 'Enabled', initial: true); + final size = k.options( + label: 'Size', + initial: OptimusToggleButtonSizeVariant.large, + options: OptimusToggleButtonSizeVariant.values.toOptions(), + ); + final label = k.boolean(label: 'Label', initial: true); + + return Center( + child: _ToggleExample(label: label, isEnabled: isEnabled, size: size), + ); + }, +); + +class _ToggleExample extends StatefulWidget { + const _ToggleExample({ + required this.label, + required this.isEnabled, + required this.size, + }); + + final bool label; + final bool isEnabled; + final OptimusToggleButtonSizeVariant size; + + @override + State<_ToggleExample> createState() => _ToggleExampleState(); +} + +class _ToggleExampleState extends State<_ToggleExample> { + bool _isToggled = false; + bool _isLoading = false; + String _label = 'Claim'; + + @override + Widget build(BuildContext context) => OptimusToggleButton( + label: widget.label ? Text(_label) : null, + isToggled: _isToggled, + isLoading: _isLoading, + onPressed: widget.isEnabled + ? () { + setState(() { + _isLoading = true; + }); + Future.delayed( + const Duration(seconds: 2), + () => setState(() { + _isToggled = !_isToggled; + _label = _isToggled ? 'Claimed' : 'Claim'; + _isLoading = false; + }), + ); + } + : null, + size: widget.size, + ); +}