diff --git a/apps/design_system_gallery/lib/components/buttons/split_button.dart b/apps/design_system_gallery/lib/components/buttons/split_button.dart index 870cbb9f..0c613c4b 100644 --- a/apps/design_system_gallery/lib/components/buttons/split_button.dart +++ b/apps/design_system_gallery/lib/components/buttons/split_button.dart @@ -17,20 +17,12 @@ import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; Widget buildStreamSplitButtonPlayground(BuildContext context) { final icons = context.streamIcons; - final style = context.knobs.object.dropdown( - label: 'Style', - options: StreamButtonStyle.values, - initialOption: StreamButtonStyle.secondary, + final variant = context.knobs.object.dropdown( + label: 'Variant', + options: StreamSplitButtonVariant.values, + initialOption: StreamSplitButtonVariant.regular, labelBuilder: (option) => option.name, - description: 'Split button visual style variant.', - ); - - final type = context.knobs.object.dropdown( - label: 'Type', - options: StreamButtonType.values, - initialOption: StreamButtonType.solid, - labelBuilder: (option) => option.name, - description: 'Split button type variant. Outline draws one border around both halves.', + description: 'Split button color scheme variant.', ); final caretUp = context.knobs.boolean( @@ -59,13 +51,12 @@ Widget buildStreamSplitButtonPlayground(BuildContext context) { child: _MaybeBadged( showErrorBadge: showErrorBadge, child: StreamSplitButton.icon( - icon: Icon(icons.voiceFill), + leadingIcon: Icon(icons.voiceFill), trailingIcon: Icon(caretUp ? icons.caretUp : icons.caretDown), - style: style, - type: type, - tooltip: 'Mute', + variant: variant, + leadingTooltip: 'Mute', trailingTooltip: 'Audio settings', - onPressed: leadingEnabled ? () {} : null, + onLeadingPressed: leadingEnabled ? () {} : null, onTrailingPressed: trailingEnabled ? () {} : null, ), ), @@ -94,7 +85,7 @@ Widget buildStreamSplitButtonShowcase(BuildContext context) { crossAxisAlignment: CrossAxisAlignment.start, spacing: spacing.xl, children: const [ - _StyleTypeMatrixSection(), + _VariantSection(), _DisabledSection(), _CallControlSection(), ], @@ -103,8 +94,8 @@ Widget buildStreamSplitButtonShowcase(BuildContext context) { ); } -class _StyleTypeMatrixSection extends StatelessWidget { - const _StyleTypeMatrixSection(); +class _VariantSection extends StatelessWidget { + const _VariantSection(); @override Widget build(BuildContext context) { @@ -112,28 +103,26 @@ class _StyleTypeMatrixSection extends StatelessWidget { final spacing = context.streamSpacing; return _ExampleCard( - title: 'Style × type', + title: 'Variants', description: 'The surface resolves from the same button style the halves use, so the two never drift apart.', child: Column( crossAxisAlignment: CrossAxisAlignment.start, spacing: spacing.md, children: [ - for (final style in StreamButtonStyle.values) + for (final variant in StreamSplitButtonVariant.values) Row( spacing: spacing.md, children: [ - SizedBox(width: 88, child: Text(style.name)), - for (final type in StreamButtonType.values) - StreamSplitButton.icon( - icon: Icon(icons.voiceFill), - trailingIcon: Icon(icons.caretDown), - style: style, - type: type, - tooltip: 'Mute', - trailingTooltip: 'Audio settings', - onPressed: () {}, - onTrailingPressed: () {}, - ), + SizedBox(width: 88, child: Text(variant.name)), + StreamSplitButton.icon( + leadingIcon: Icon(icons.voiceFill), + trailingIcon: Icon(icons.caretDown), + variant: variant, + leadingTooltip: 'Mute', + trailingTooltip: 'Audio settings', + onLeadingPressed: () {}, + onTrailingPressed: () {}, + ), ], ), ], @@ -165,10 +154,9 @@ class _DisabledSection extends StatelessWidget { spacing: spacing.xs, children: [ StreamSplitButton.icon( - icon: Icon(icons.voiceFill), + leadingIcon: Icon(icons.voiceFill), trailingIcon: Icon(icons.caretDown), - style: StreamButtonStyle.secondary, - onPressed: leading ? () {} : null, + onLeadingPressed: leading ? () {} : null, onTrailingPressed: trailing ? () {} : null, ), Text(label), @@ -204,12 +192,12 @@ class _CallControlSectionState extends State<_CallControlSection> { child: _MaybeBadged( showErrorBadge: true, child: StreamSplitButton.icon( - icon: Icon(_isMuted ? icons.voiceOffFill : icons.voiceFill), + leadingIcon: Icon(_isMuted ? icons.voiceOffFill : icons.voiceFill), trailingIcon: Icon(_isSettingsOpen ? icons.caretUp : icons.caretDown), - style: _isMuted ? StreamButtonStyle.destructive : StreamButtonStyle.secondary, - tooltip: _isMuted ? 'Unmute' : 'Mute', + variant: _isMuted ? StreamSplitButtonVariant.destructive : StreamSplitButtonVariant.regular, + leadingTooltip: _isMuted ? 'Unmute' : 'Mute', trailingTooltip: 'Audio settings', - onPressed: () => setState(() => _isMuted = !_isMuted), + onLeadingPressed: () => setState(() => _isMuted = !_isMuted), onTrailingPressed: () => setState(() => _isSettingsOpen = !_isSettingsOpen), ), ), diff --git a/packages/stream_core_flutter/CHANGELOG.md b/packages/stream_core_flutter/CHANGELOG.md index 249dcf30..6126fde9 100644 --- a/packages/stream_core_flutter/CHANGELOG.md +++ b/packages/stream_core_flutter/CHANGELOG.md @@ -4,7 +4,10 @@ - Added `StreamColorScheme.backgroundOverlayDarkStrong`, the design system's `background/core/overlay-dark-strong` token. A heavier version of `backgroundOverlayDark`, for content that has to stay legible on top of arbitrary imagery or video. - Added `StreamReactions.onReactionLongPressed`, reporting the long-pressed `StreamReactionsItem` — or `null` for the cluster/overflow chip. When null, the chips register no long-press gesture, leaving it to an ancestor. -- Added `StreamSplitButton`. +- Added `StreamSplitButton`, in the two variants the design covers — + `StreamSplitButtonVariant.regular` and `.destructive`. Each variant carries + its own `StreamSplitButtonStyle` on `StreamSplitButtonThemeData`, so the two + can be themed independently. - Refreshed the icon set from the design tokens and added 44 icons, including a filled variant for many existing icons: `blurFill`, `boltFill`, `cameraFlipFill`, `captionFill`, `caretDown`, `caretUp`, `copyFill`, diff --git a/packages/stream_core_flutter/lib/src/components/buttons/stream_split_button.dart b/packages/stream_core_flutter/lib/src/components/buttons/stream_split_button.dart index cb5d95cd..bfa47f93 100644 --- a/packages/stream_core_flutter/lib/src/components/buttons/stream_split_button.dart +++ b/packages/stream_core_flutter/lib/src/components/buttons/stream_split_button.dart @@ -26,10 +26,10 @@ import 'stream_button.dart'; /// wraps the two icons rather than their tap targets, which stay full height /// and overhang it. The control has no size of its own. /// -/// The surface is resolved from the same [StreamButtonTheme] entry the halves -/// use, which is what keeps the two from drifting apart. For -/// [StreamButtonType.outline] the border is drawn once around the whole -/// control rather than around each half. +/// The design gives the control the two variants of +/// [StreamSplitButtonVariant], both filled. The surface is resolved from the +/// same [StreamButtonTheme] entry the halves use, which is what keeps the two +/// from drifting apart. /// /// Each half keeps its own tap target, hover and press feedback, and /// accessibility node; the divider is decorative. @@ -40,12 +40,11 @@ import 'stream_button.dart'; /// /// ```dart /// StreamSplitButton.icon( -/// style: StreamButtonStyle.secondary, -/// icon: Icon(context.streamIcons.voiceFill), +/// leadingIcon: Icon(context.streamIcons.voiceFill), /// trailingIcon: Icon(context.streamIcons.caretDown), -/// tooltip: 'Mute', +/// leadingTooltip: 'Mute', /// trailingTooltip: 'Audio settings', -/// onPressed: () => toggleMute(), +/// onLeadingPressed: () => toggleMute(), /// onTrailingPressed: () => showAudioSettings(), /// ) /// ``` @@ -53,14 +52,15 @@ import 'stream_button.dart'; /// /// {@tool snippet} /// -/// Flip the caret while the menu it opens is showing: +/// Go destructive once the microphone is off, and flip the caret while the +/// menu it opens is showing: /// /// ```dart /// StreamSplitButton.icon( -/// type: StreamButtonType.outline, -/// icon: const Icon(Icons.share), +/// variant: isMuted ? StreamSplitButtonVariant.destructive : StreamSplitButtonVariant.regular, +/// leadingIcon: Icon(isMuted ? icons.voiceOffFill : icons.voiceFill), /// trailingIcon: Icon(isMenuOpen ? icons.caretUp : icons.caretDown), -/// onPressed: () => share(), +/// onLeadingPressed: () => toggleMute(), /// onTrailingPressed: () => toggleMenu(), /// ) /// ``` @@ -69,12 +69,13 @@ import 'stream_button.dart'; /// See also: /// /// * [StreamButton], the button each half is built from. +/// * [StreamSplitButtonVariant], for the available variants. /// * [StreamSplitButtonTheme], for customizing split button appearance. @experimental class StreamSplitButton extends StatelessWidget { /// Creates a split button with an icon in each half. /// - /// [icon] labels the primary half and [trailingIcon] the secondary one. + /// [leadingIcon] labels the leading half and [trailingIcon] the trailing one. /// Both are configurable so the trailing half can point the caret at /// whatever it opens — [StreamIcons.caretDown] for a menu below, /// [StreamIcons.caretUp] for one above. @@ -84,23 +85,21 @@ class StreamSplitButton extends StatelessWidget { @experimental StreamSplitButton.icon({ super.key, - required Widget icon, + required Widget leadingIcon, required Widget trailingIcon, - VoidCallback? onPressed, + VoidCallback? onLeadingPressed, VoidCallback? onTrailingPressed, - StreamButtonStyle style = .primary, - StreamButtonType type = .solid, - String? tooltip, + StreamSplitButtonVariant variant = .regular, + String? leadingTooltip, String? trailingTooltip, StreamSplitButtonStyle? themeStyle, }) : props = .new( - icon: icon, + leadingIcon: leadingIcon, trailingIcon: trailingIcon, - onPressed: onPressed, + onLeadingPressed: onLeadingPressed, onTrailingPressed: onTrailingPressed, - style: style, - type: type, - tooltip: tooltip, + variant: variant, + leadingTooltip: leadingTooltip, trailingTooltip: trailingTooltip, themeStyle: themeStyle, ); @@ -116,6 +115,27 @@ class StreamSplitButton extends StatelessWidget { } } +/// The color scheme variant for a [StreamSplitButton]. +/// +/// A split button has no visual weight axis — both variants are filled — so +/// this stands apart from the [StreamButtonStyle] / [StreamButtonType] pair a +/// [StreamButton] takes. +@experimental +enum StreamSplitButtonVariant { + /// The neutral surface fill, for an action in its ordinary state. + regular, + + /// The error/danger fill, for an action that is off or destructive — a + /// muted microphone, a stopped camera. + destructive; + + // The button variant the shared surface and both halves are painted as. + StreamButtonStyle get _buttonStyle => switch (this) { + StreamSplitButtonVariant.regular => StreamButtonStyle.secondary, + StreamSplitButtonVariant.destructive => StreamButtonStyle.destructive, + }; +} + /// Properties for configuring a [StreamSplitButton]. /// /// This class holds all the configuration options for a split button, @@ -128,51 +148,44 @@ class StreamSplitButton extends StatelessWidget { class StreamSplitButtonProps { /// Creates properties for a split button. const StreamSplitButtonProps({ - required this.icon, + required this.leadingIcon, required this.trailingIcon, - this.onPressed, + this.onLeadingPressed, this.onTrailingPressed, - this.style = .primary, - this.type = .solid, - this.tooltip, + this.variant = .regular, + this.leadingTooltip, this.trailingTooltip, this.themeStyle, }); - /// The icon rendered in the primary (leading) half. - final Widget icon; + /// The icon rendered in the leading half. + final Widget leadingIcon; /// The icon rendered in the secondary (trailing) half. /// /// Typically a caret pointing at whatever the half opens. final Widget trailingIcon; - /// Called when the primary half is pressed. + /// Called when the leading half is pressed. /// /// If null, that half is disabled. - final VoidCallback? onPressed; + final VoidCallback? onLeadingPressed; /// Called when the trailing half is pressed. /// /// If null, that half is disabled. final VoidCallback? onTrailingPressed; - /// The visual style variant of the split button. - /// - /// Determines the color scheme used (primary, secondary, destructive). - final StreamButtonStyle style; - - /// The type variant of the split button. + /// The visual variant of the split button. /// - /// Controls the visual weight (solid, outline, ghost). An outline split - /// button draws a single border around both halves. - final StreamButtonType type; + /// Determines the color scheme used (regular, destructive). + final StreamSplitButtonVariant variant; - /// Text shown in a [Tooltip] on hover / long-press of the primary half, and + /// Text shown in a [Tooltip] on hover / long-press of the leading half, and /// used as its accessibility label. /// /// When null, that half has no tooltip. - final String? tooltip; + final String? leadingTooltip; /// Text shown in a [Tooltip] on hover / long-press of the trailing half, and /// used as its accessibility label. @@ -192,6 +205,11 @@ class StreamSplitButtonProps { // scales the control. const _halfButtonSize = StreamButtonSize.small; +// The opacity the design draws the divider at on the destructive fill. At full +// strength the white hairline cuts the accent surface in two; knocked back it +// reads as a seam in one control. +const _accentSeparatorOpacity = 0.35; + /// Default implementation of [StreamSplitButton]. /// /// Renders a [Row] of two [StreamButton.icon] halves over a shared surface, @@ -211,23 +229,27 @@ class DefaultStreamSplitButton extends StatelessWidget { @override Widget build(BuildContext context) { final spacing = context.streamSpacing; - final themeStyle = context.streamSplitButtonTheme.style?.merge(props.themeStyle) ?? props.themeStyle; - final defaults = _StreamSplitButtonDefaults(context); - + final inheritedStyle = context.streamSplitButtonTheme.styleOf(props.variant); + final themeStyle = inheritedStyle?.merge(props.themeStyle) ?? props.themeStyle; + final buttonVariant = props.variant._buttonStyle; // Resolved once and shared: the surface below and the halves above are the // same button style, so they cannot render as different colors. final buttonStyle = resolveStreamButtonThemeStyle( context, - style: props.style, - type: props.type, + style: buttonVariant, + type: StreamButtonType.solid, isFloating: false, themeStyle: themeStyle?.buttonStyle, ); - final isEnabled = props.onPressed != null || props.onTrailingPressed != null; + final defaults = _StreamSplitButtonDefaults(context, props.variant); + + final isEnabled = props.onLeadingPressed != null || props.onTrailingPressed != null; final states = {if (!isEnabled) WidgetState.disabled}; final shape = buttonStyle.shape?.resolve(states) ?? const StadiumBorder(); + // Neither variant is outlined, but a theme may still ask for a border; when + // it does it is drawn once around the whole control rather than each half. final borderColor = buttonStyle.borderColor?.resolve(states); final effectiveSeparatorColor = (themeStyle?.separatorColor ?? defaults.separatorColor).resolve(states); @@ -259,8 +281,7 @@ class DefaultStreamSplitButton extends StatelessWidget { child: StreamButton.icon( icon: icon, onPressed: onPressed, - style: props.style, - type: props.type, + style: buttonVariant, size: _halfButtonSize, tooltip: tooltip, themeStyle: halfStyle, @@ -300,7 +321,7 @@ class DefaultStreamSplitButton extends StatelessWidget { mainAxisSize: MainAxisSize.min, spacing: inset, children: [ - half(icon: props.icon, onPressed: props.onPressed, tooltip: props.tooltip), + half(icon: props.leadingIcon, onPressed: props.onLeadingPressed, tooltip: props.leadingTooltip), SizedBox( width: effectiveSeparatorThickness, height: effectiveSeparatorHeight, @@ -425,17 +446,33 @@ class _RenderHitTarget extends RenderShiftedBox { // These defaults are used when no explicit value is provided via // [StreamSplitButtonStyle] or [StreamSplitButtonThemeData]. class _StreamSplitButtonDefaults extends StreamSplitButtonStyle { - _StreamSplitButtonDefaults(this.context); + _StreamSplitButtonDefaults(this.context, this._variant); final BuildContext context; + final StreamSplitButtonVariant _variant; + late final StreamSpacing _spacing = context.streamSpacing; late final StreamColorScheme _colorScheme = context.streamColorScheme; + // The divider is drawn on the button's own fill, so what it should be is a + // question about that fill rather than about the button's variant. @override WidgetStateProperty get separatorColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return _colorScheme.borderDisabled; - return _colorScheme.borderDefault; + // A disabled button drops its fill and its icon colour for the shared + // disabled treatment, so the divider goes back to the hairline that reads + // on it rather than disappearing into it as borderDisabled did. + if (states.contains(WidgetState.disabled)) return _colorScheme.borderDefault; + + return switch (_variant) { + // A light surface fill, which the ordinary hairline reads on. + StreamSplitButtonVariant.regular => _colorScheme.borderDefault, + // Drawn against the accent fill, knocked back to the opacity the design + // draws it at. + StreamSplitButtonVariant.destructive => _colorScheme.borderOnAccent.withValues( + alpha: _accentSeparatorOpacity, + ), + }; }); @override diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_split_button_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_split_button_theme.dart index 24d58dba..b2bcc6e0 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_split_button_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_split_button_theme.dart @@ -2,6 +2,7 @@ import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; +import '../../components/buttons/stream_split_button.dart'; import '../stream_theme.dart'; import 'stream_button_theme.dart'; @@ -15,19 +16,19 @@ part 'stream_split_button_theme.g.theme.dart'; /// /// {@tool snippet} /// -/// Override the separator for a specific section: +/// Override the separator of regular split buttons for a specific section: /// /// ```dart /// StreamSplitButtonTheme( /// data: StreamSplitButtonThemeData( -/// style: StreamSplitButtonStyle( +/// regular: StreamSplitButtonStyle( /// separatorColor: WidgetStatePropertyAll(Colors.white24), /// ), /// ), /// child: StreamSplitButton.icon( -/// icon: Icon(icons.voiceFill), +/// leadingIcon: Icon(icons.voiceFill), /// trailingIcon: Icon(icons.caretDown), -/// onPressed: () {}, +/// onLeadingPressed: () {}, /// onTrailingPressed: () {}, /// ), /// ) @@ -71,6 +72,9 @@ class StreamSplitButtonTheme extends InheritedTheme { /// Theme data for customizing [StreamSplitButton] widgets. /// +/// Organizes split button styles by [StreamSplitButtonVariant], so the +/// destructive variant can be styled without touching the regular one. +/// /// {@tool snippet} /// /// Customize split button appearance globally via [StreamTheme]: @@ -78,7 +82,10 @@ class StreamSplitButtonTheme extends InheritedTheme { /// ```dart /// StreamTheme( /// splitButtonTheme: StreamSplitButtonThemeData( -/// style: StreamSplitButtonStyle(separatorThickness: 2), +/// regular: StreamSplitButtonStyle(separatorThickness: 2), +/// destructive: StreamSplitButtonStyle( +/// separatorColor: WidgetStatePropertyAll(Colors.white), +/// ), /// ), /// ) /// ``` @@ -92,11 +99,31 @@ class StreamSplitButtonTheme extends InheritedTheme { @immutable @experimental class StreamSplitButtonThemeData with _$StreamSplitButtonThemeData { - /// Creates split button theme data with optional style overrides. - const StreamSplitButtonThemeData({this.style}); + /// Creates split button theme data with optional style overrides per + /// variant. + const StreamSplitButtonThemeData({this.regular, this.destructive}); - /// The visual styling for split buttons. - final StreamSplitButtonStyle? style; + /// Creates split button theme data that applies [style] to every variant. + /// + /// Both [regular] and [destructive] are set to [style]. Useful when scoping + /// a [StreamSplitButtonTheme] to a slot that should override every split + /// button regardless of its configured [StreamSplitButtonVariant]. + const StreamSplitButtonThemeData.all( + StreamSplitButtonStyle style, + ) : regular = style, + destructive = style; + + /// Styling for regular (neutral surface) split buttons. + final StreamSplitButtonStyle? regular; + + /// Styling for destructive (error/danger) split buttons. + final StreamSplitButtonStyle? destructive; + + /// The styling for split buttons of the given [variant]. + StreamSplitButtonStyle? styleOf(StreamSplitButtonVariant variant) => switch (variant) { + StreamSplitButtonVariant.regular => regular, + StreamSplitButtonVariant.destructive => destructive, + }; /// Linearly interpolate between two [StreamSplitButtonThemeData] objects. static StreamSplitButtonThemeData? lerp( @@ -106,7 +133,7 @@ class StreamSplitButtonThemeData with _$StreamSplitButtonThemeData { ) => _$StreamSplitButtonThemeData.lerp(a, b, t); } -/// Visual styling properties for [StreamSplitButton]. +/// Visual styling properties for a single [StreamSplitButtonVariant]. /// /// A split button paints one shared surface behind two [StreamButton] halves. /// That surface is derived from the same [StreamButtonTheme] entry the halves @@ -132,8 +159,8 @@ class StreamSplitButtonStyle with _$StreamSplitButtonStyle { /// Per-instance style overrides for the split button. /// - /// These take precedence over the inherited [StreamButtonTheme] entry for - /// the split button's `style`/`type` combination, and apply to both the + /// These take precedence over the inherited [StreamButtonTheme] entry the + /// split button's [StreamSplitButtonVariant] maps to, and apply to both the /// shared surface and the two halves, without affecting other /// [StreamButton] instances in the tree. /// @@ -159,8 +186,10 @@ class StreamSplitButtonStyle with _$StreamSplitButtonStyle { /// The color of the divider between the two halves. /// - /// Defaults to [StreamColorScheme.borderDefault], or - /// [StreamColorScheme.borderDisabled] while the whole control is disabled. + /// Defaults to [StreamColorScheme.borderDefault] on the regular variant and + /// [StreamColorScheme.borderOnAccent] at 35% opacity on the destructive one, + /// falling back to a solid [StreamColorScheme.borderDefault] while the whole + /// control is disabled and painting the shared disabled surface. final WidgetStateProperty? separatorColor; /// The width of the divider between the two halves, in logical pixels. @@ -171,9 +200,9 @@ class StreamSplitButtonStyle with _$StreamSplitButtonStyle { /// The height of the divider between the two halves, in logical pixels. /// /// The divider is shorter than the halves it separates, which are in turn - /// shorter than the surface. Defaults to the button size inset by - /// [StreamSpacing.xxs] twice over on both ends — 24 for a - /// [StreamButtonSize.medium] split button. + /// shorter than the surface. Defaults to the half's own size inset by + /// [StreamSpacing.xxs] at both ends — 24, since the halves are + /// [StreamButtonSize.small]. final double? separatorHeight; /// Linearly interpolate between two [StreamSplitButtonStyle] objects. diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_split_button_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_split_button_theme.g.theme.dart index 106f7d67..798a3dbc 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_split_button_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_split_button_theme.g.theme.dart @@ -30,14 +30,21 @@ mixin _$StreamSplitButtonThemeData { } return StreamSplitButtonThemeData( - style: StreamSplitButtonStyle.lerp(a.style, b.style, t), + regular: StreamSplitButtonStyle.lerp(a.regular, b.regular, t), + destructive: StreamSplitButtonStyle.lerp(a.destructive, b.destructive, t), ); } - StreamSplitButtonThemeData copyWith({StreamSplitButtonStyle? style}) { + StreamSplitButtonThemeData copyWith({ + StreamSplitButtonStyle? regular, + StreamSplitButtonStyle? destructive, + }) { final _this = (this as StreamSplitButtonThemeData); - return StreamSplitButtonThemeData(style: style ?? _this.style); + return StreamSplitButtonThemeData( + regular: regular ?? _this.regular, + destructive: destructive ?? _this.destructive, + ); } StreamSplitButtonThemeData merge(StreamSplitButtonThemeData? other) { @@ -51,7 +58,11 @@ mixin _$StreamSplitButtonThemeData { return other; } - return copyWith(style: _this.style?.merge(other.style) ?? other.style); + return copyWith( + regular: _this.regular?.merge(other.regular) ?? other.regular, + destructive: + _this.destructive?.merge(other.destructive) ?? other.destructive, + ); } @override @@ -67,14 +78,15 @@ mixin _$StreamSplitButtonThemeData { final _this = (this as StreamSplitButtonThemeData); final _other = (other as StreamSplitButtonThemeData); - return _other.style == _this.style; + return _other.regular == _this.regular && + _other.destructive == _this.destructive; } @override int get hashCode { final _this = (this as StreamSplitButtonThemeData); - return Object.hash(runtimeType, _this.style); + return Object.hash(runtimeType, _this.regular, _this.destructive); } } diff --git a/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_split_button_dark.png b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_split_button_dark.png index c599f31e..a609ec66 100644 Binary files a/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_split_button_dark.png and b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_split_button_dark.png differ diff --git a/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_split_button_disabled.png b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_split_button_disabled.png index f39ee7eb..70afda85 100644 Binary files a/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_split_button_disabled.png and b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_split_button_disabled.png differ diff --git a/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_split_button_disabled_matrix.png b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_split_button_disabled_matrix.png new file mode 100644 index 00000000..6646eebd Binary files /dev/null and b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_split_button_disabled_matrix.png differ diff --git a/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_split_button_disabled_matrix_dark.png b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_split_button_disabled_matrix_dark.png new file mode 100644 index 00000000..24611862 Binary files /dev/null and b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_split_button_disabled_matrix_dark.png differ diff --git a/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_split_button_light.png b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_split_button_light.png index 2f57f995..0a9cb0c6 100644 Binary files a/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_split_button_light.png and b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_split_button_light.png differ diff --git a/packages/stream_core_flutter/test/components/buttons/stream_split_button_golden_test.dart b/packages/stream_core_flutter/test/components/buttons/stream_split_button_golden_test.dart index 54210caf..5c9ce480 100644 --- a/packages/stream_core_flutter/test/components/buttons/stream_split_button_golden_test.dart +++ b/packages/stream_core_flutter/test/components/buttons/stream_split_button_golden_test.dart @@ -37,12 +37,24 @@ void main() { children: [ GoldenTestScenario( name: 'pressed', - child: _buildInTheme(_splitButton(style: .secondary)), + child: _buildInTheme(_splitButton()), ), ], ), ); + goldenTest( + 'renders the disabled matrix', + fileName: 'stream_split_button_disabled_matrix', + builder: () => _buildMatrix(enabled: false), + ); + + goldenTest( + 'renders the disabled matrix in dark theme', + fileName: 'stream_split_button_disabled_matrix_dark', + builder: () => _buildMatrix(brightness: Brightness.dark, enabled: false), + ); + goldenTest( 'renders disabled halves', fileName: 'stream_split_button_disabled', @@ -51,17 +63,15 @@ void main() { children: [ GoldenTestScenario( name: 'leading disabled', - child: _buildInTheme(_splitButton(style: .secondary, onPressed: null)), + child: _buildInTheme(_splitButton(onLeadingPressed: null)), ), GoldenTestScenario( name: 'trailing disabled', - child: _buildInTheme(_splitButton(style: .secondary, onTrailingPressed: null)), + child: _buildInTheme(_splitButton(onTrailingPressed: null)), ), GoldenTestScenario( name: 'both disabled', - child: _buildInTheme( - _splitButton(style: .secondary, onPressed: null, onTrailingPressed: null), - ), + child: _buildInTheme(_splitButton(onLeadingPressed: null, onTrailingPressed: null)), ), ], ), @@ -69,35 +79,36 @@ void main() { }); } -GoldenTestGroup _buildMatrix({Brightness brightness = Brightness.light}) { +GoldenTestGroup _buildMatrix({Brightness brightness = Brightness.light, bool enabled = true}) { return GoldenTestGroup( - columns: StreamButtonType.values.length, + columns: StreamSplitButtonVariant.values.length, children: [ - for (final style in StreamButtonStyle.values) - for (final type in StreamButtonType.values) - GoldenTestScenario( - name: '${style.name} / ${type.name}', - child: _buildInTheme( - _splitButton(style: style, type: type), - brightness: brightness, + for (final variant in StreamSplitButtonVariant.values) + GoldenTestScenario( + name: variant.name, + child: _buildInTheme( + _splitButton( + variant: variant, + onLeadingPressed: enabled ? () {} : null, + onTrailingPressed: enabled ? () {} : null, ), + brightness: brightness, ), + ), ], ); } StreamSplitButton _splitButton({ - StreamButtonStyle style = StreamButtonStyle.primary, - StreamButtonType type = StreamButtonType.solid, - VoidCallback? onPressed = _noop, + StreamSplitButtonVariant variant = StreamSplitButtonVariant.regular, + VoidCallback? onLeadingPressed = _noop, VoidCallback? onTrailingPressed = _noop, }) { return StreamSplitButton.icon( - icon: const Icon(StreamIconData.voiceFill), + leadingIcon: const Icon(StreamIconData.voiceFill), trailingIcon: const Icon(StreamIconData.caretDown), - style: style, - type: type, - onPressed: onPressed, + variant: variant, + onLeadingPressed: onLeadingPressed, onTrailingPressed: onTrailingPressed, ); } diff --git a/packages/stream_core_flutter/test/components/buttons/stream_split_button_test.dart b/packages/stream_core_flutter/test/components/buttons/stream_split_button_test.dart index 2f908c5f..b9d569b4 100644 --- a/packages/stream_core_flutter/test/components/buttons/stream_split_button_test.dart +++ b/packages/stream_core_flutter/test/components/buttons/stream_split_button_test.dart @@ -10,23 +10,21 @@ Widget _withStreamTheme(Widget child, {StreamTheme? streamTheme}) { } StreamSplitButton _splitButton({ - StreamButtonStyle style = StreamButtonStyle.primary, - StreamButtonType type = StreamButtonType.solid, + StreamSplitButtonVariant variant = StreamSplitButtonVariant.regular, IconData trailingIcon = StreamIconData.caretDown, - VoidCallback? onPressed, + VoidCallback? onLeadingPressed, VoidCallback? onTrailingPressed, - String? tooltip, + String? leadingTooltip, String? trailingTooltip, StreamSplitButtonStyle? themeStyle, }) { return StreamSplitButton.icon( - icon: const Icon(StreamIconData.voiceFill), + leadingIcon: const Icon(StreamIconData.voiceFill), trailingIcon: Icon(trailingIcon), - style: style, - type: type, - onPressed: onPressed, + variant: variant, + onLeadingPressed: onLeadingPressed, onTrailingPressed: onTrailingPressed, - tooltip: tooltip, + leadingTooltip: leadingTooltip, trailingTooltip: trailingTooltip, themeStyle: themeStyle, ); @@ -55,13 +53,18 @@ void main() { testWidgets('paints the background a StreamButton of the same variant would', (tester) async { // The whole point of the component: the surface and the halves resolve // from one button style, so they cannot drift into different colours. - for (final style in StreamButtonStyle.values) { + const buttonStyles = { + StreamSplitButtonVariant.regular: StreamButtonStyle.secondary, + StreamSplitButtonVariant.destructive: StreamButtonStyle.destructive, + }; + + for (final MapEntry(key: variant, value: buttonStyle) in buttonStyles.entries) { await tester.pumpWidget( _withStreamTheme( Column( children: [ - _splitButton(style: style, onPressed: () {}, onTrailingPressed: () {}), - StreamButton.icon(icon: const Icon(Icons.mic), style: style, onPressed: () {}), + _splitButton(variant: variant, onLeadingPressed: () {}, onTrailingPressed: () {}), + StreamButton.icon(icon: const Icon(Icons.mic), style: buttonStyle, onPressed: () {}), ], ), ), @@ -74,7 +77,7 @@ void main() { expect( _surfaceOf(tester).color, reference.style!.backgroundColor!.resolve({}), - reason: 'surface should match a $style StreamButton', + reason: 'surface should match a $buttonStyle StreamButton', ); } }); @@ -84,12 +87,12 @@ void main() { _withStreamTheme( streamTheme: StreamTheme( buttonTheme: const StreamButtonThemeData( - primary: StreamButtonTypeStyle( + secondary: StreamButtonTypeStyle( solid: StreamButtonThemeStyle(backgroundColor: WidgetStatePropertyAll(Color(0xFF00FF00))), ), ), ), - _splitButton(onPressed: () {}, onTrailingPressed: () {}), + _splitButton(onLeadingPressed: () {}, onTrailingPressed: () {}), ), ); @@ -98,9 +101,7 @@ void main() { testWidgets('halves paint neither background nor border', (tester) async { await tester.pumpWidget( - _withStreamTheme( - _splitButton(type: StreamButtonType.outline, onPressed: () {}, onTrailingPressed: () {}), - ), + _withStreamTheme(_splitButton(onLeadingPressed: () {}, onTrailingPressed: () {})), ); for (var index = 0; index < 2; index++) { @@ -111,33 +112,46 @@ void main() { } }); - testWidgets('outline draws a single border around the whole control', (tester) async { + testWidgets('draws no border of its own', (tester) async { + // Neither variant is outlined. await tester.pumpWidget( - _withStreamTheme( - _splitButton(type: StreamButtonType.outline, onPressed: () {}, onTrailingPressed: () {}), - ), + _withStreamTheme(_splitButton(onLeadingPressed: () {}, onTrailingPressed: () {})), ); final shape = _surfaceOf(tester).shape as OutlinedBorder; - expect(shape.side.style, BorderStyle.solid); + expect(shape.side.style, BorderStyle.none); }); - testWidgets('solid draws no border', (tester) async { + testWidgets('draws a themed border once around the whole control', (tester) async { + // A theme may still ask for one, and then it wraps the pair rather than + // outlining each half. await tester.pumpWidget( - _withStreamTheme(_splitButton(onPressed: () {}, onTrailingPressed: () {})), + _withStreamTheme( + _splitButton( + onLeadingPressed: () {}, + onTrailingPressed: () {}, + themeStyle: const StreamSplitButtonStyle( + buttonStyle: StreamButtonThemeStyle(borderColor: WidgetStatePropertyAll(Color(0xFF00FF00))), + ), + ), + ), ); final shape = _surfaceOf(tester).shape as OutlinedBorder; - expect(shape.side.style, BorderStyle.none); + expect(shape.side.color, const Color(0xFF00FF00)); + + for (var index = 0; index < 2; index++) { + expect(_halfStyleOf(tester, index).side?.resolve({}), isNull); + } }); testWidgets('only takes the disabled surface once both halves are disabled', (tester) async { final streamTheme = StreamTheme(); - final enabledColor = streamTheme.colorScheme.accentPrimary; + final enabledColor = streamTheme.colorScheme.backgroundSurface; final disabledColor = streamTheme.colorScheme.backgroundDisabled; await tester.pumpWidget( - _withStreamTheme(streamTheme: streamTheme, _splitButton(onPressed: () {})), + _withStreamTheme(streamTheme: streamTheme, _splitButton(onLeadingPressed: () {})), ); expect(_surfaceOf(tester).color, enabledColor); @@ -154,7 +168,7 @@ void main() { group('StreamSplitButton layout', () { testWidgets('matches the design: 89x48 control over an 81x40 surface', (tester) async { await tester.pumpWidget( - _withStreamTheme(_splitButton(style: .secondary, onPressed: () {}, onTrailingPressed: () {})), + _withStreamTheme(_splitButton(onLeadingPressed: () {}, onTrailingPressed: () {})), ); // The control is wider than the surface it paints: the same 4pt inset @@ -180,7 +194,7 @@ void main() { _withStreamTheme( Column( children: [ - _splitButton(style: .secondary, onPressed: () {}, onTrailingPressed: () {}), + _splitButton(onLeadingPressed: () {}, onTrailingPressed: () {}), StreamButton.icon(icon: const Icon(Icons.mic), style: .secondary, onPressed: () {}), ], ), @@ -208,9 +222,9 @@ void main() { ), ), _splitButton( - onPressed: () {}, + onLeadingPressed: () {}, onTrailingPressed: () {}, - tooltip: 'Mute', + leadingTooltip: 'Mute', trailingTooltip: 'Audio settings', ), ), @@ -235,9 +249,9 @@ void main() { await tester.pumpWidget( _withStreamTheme( _splitButton( - onPressed: () => pressed++, + onLeadingPressed: () => pressed++, onTrailingPressed: () => trailingPressed++, - tooltip: 'Mute', + leadingTooltip: 'Mute', trailingTooltip: 'Audio settings', ), ), @@ -262,7 +276,7 @@ void main() { await tester.pumpWidget( _withStreamTheme( streamTheme: streamTheme, - _splitButton(onPressed: () {}, onTrailingPressed: () {}), + _splitButton(onLeadingPressed: () {}, onTrailingPressed: () {}), ), ); @@ -271,11 +285,99 @@ void main() { expect(tester.getSize(divider), const Size(1, 24)); }); + // The divider is drawn on the shared surface, so which colour it takes is + // a question about that surface rather than about the button's style. + // At full strength the white hairline cuts the accent surface in two, so + // the design knocks it back to 35%. + testWidgets('draws the divider against an accent fill at 35% opacity', (tester) async { + final streamTheme = StreamTheme(); + + await tester.pumpWidget( + _withStreamTheme( + streamTheme: streamTheme, + _splitButton(variant: .destructive, onLeadingPressed: () {}, onTrailingPressed: () {}), + ), + ); + + final divider = find.descendant(of: find.byType(StreamSplitButton), matching: find.byType(ColoredBox)); + expect( + tester.widget(divider).color, + streamTheme.colorScheme.borderOnAccent.withValues(alpha: 0.35), + ); + }); + + // A disabled button drops its accent fill for the shared disabled surface, + // so the divider has to come back to the hairline that reads on it — it + // used to take borderDisabled and vanish. + testWidgets('keeps the divider visible while disabled', (tester) async { + final streamTheme = StreamTheme(); + + for (final variant in StreamSplitButtonVariant.values) { + await tester.pumpWidget( + _withStreamTheme(streamTheme: streamTheme, _splitButton(variant: variant)), + ); + + final divider = find.descendant(of: find.byType(StreamSplitButton), matching: find.byType(ColoredBox)); + expect( + tester.widget(divider).color, + streamTheme.colorScheme.borderDefault, + reason: 'disabled $variant', + ); + } + }); + + testWidgets('takes the separator style of its own variant', (tester) async { + // Each variant carries its own entry, so styling the destructive one + // must leave the regular one alone. + const themeData = StreamSplitButtonThemeData( + regular: StreamSplitButtonStyle(separatorColor: WidgetStatePropertyAll(Color(0xFF00FF00))), + destructive: StreamSplitButtonStyle(separatorColor: WidgetStatePropertyAll(Color(0xFF0000FF))), + ); + + const expected = { + StreamSplitButtonVariant.regular: Color(0xFF00FF00), + StreamSplitButtonVariant.destructive: Color(0xFF0000FF), + }; + + for (final MapEntry(key: variant, value: color) in expected.entries) { + await tester.pumpWidget( + _withStreamTheme( + streamTheme: StreamTheme(splitButtonTheme: themeData), + _splitButton(variant: variant, onLeadingPressed: () {}, onTrailingPressed: () {}), + ), + ); + // Settled, or a subtree can still be reporting the previous + // iteration's colours when it is read. + await tester.pumpAndSettle(); + + final divider = find.descendant(of: find.byType(StreamSplitButton), matching: find.byType(ColoredBox)); + expect(tester.widget(divider).color, color, reason: '$variant'); + } + }); + + testWidgets('leaves a variant with no entry on its defaults', (tester) async { + final streamTheme = StreamTheme( + splitButtonTheme: const StreamSplitButtonThemeData( + destructive: StreamSplitButtonStyle(separatorThickness: 4), + ), + ); + + await tester.pumpWidget( + _withStreamTheme( + streamTheme: streamTheme, + _splitButton(onLeadingPressed: () {}, onTrailingPressed: () {}), + ), + ); + + final divider = find.descendant(of: find.byType(StreamSplitButton), matching: find.byType(ColoredBox)); + expect(tester.getSize(divider), const Size(1, 24)); + }); + testWidgets('honours separator overrides', (tester) async { await tester.pumpWidget( _withStreamTheme( _splitButton( - onPressed: () {}, + onLeadingPressed: () {}, onTrailingPressed: () {}, themeStyle: const StreamSplitButtonStyle( separatorColor: WidgetStatePropertyAll(Color(0xFFFF0000)), @@ -295,7 +397,7 @@ void main() { group('StreamSplitButton icons', () { testWidgets('renders the leading icon before the trailing one', (tester) async { await tester.pumpWidget( - _withStreamTheme(_splitButton(onPressed: () {}, onTrailingPressed: () {})), + _withStreamTheme(_splitButton(onLeadingPressed: () {}, onTrailingPressed: () {})), ); final leading = tester.getCenter(find.byIcon(StreamIconData.voiceFill)); @@ -308,7 +410,7 @@ void main() { // to pick rather than something the component hard-codes. await tester.pumpWidget( _withStreamTheme( - _splitButton(trailingIcon: StreamIconData.caretUp, onPressed: () {}, onTrailingPressed: () {}), + _splitButton(trailingIcon: StreamIconData.caretUp, onLeadingPressed: () {}, onTrailingPressed: () {}), ), ); @@ -321,7 +423,7 @@ void main() { _withStreamTheme( Directionality( textDirection: TextDirection.rtl, - child: _splitButton(onPressed: () {}, onTrailingPressed: () {}), + child: _splitButton(onLeadingPressed: () {}, onTrailingPressed: () {}), ), ), ); @@ -340,9 +442,9 @@ void main() { await tester.pumpWidget( _withStreamTheme( _splitButton( - onPressed: () => pressed++, + onLeadingPressed: () => pressed++, onTrailingPressed: () => trailingPressed++, - tooltip: 'Mute', + leadingTooltip: 'Mute', trailingTooltip: 'Audio settings', ), ), @@ -364,7 +466,7 @@ void main() { _withStreamTheme( _splitButton( onTrailingPressed: () => trailingPressed++, - tooltip: 'Mute', + leadingTooltip: 'Mute', trailingTooltip: 'Audio settings', ), ), @@ -383,9 +485,9 @@ void main() { await tester.pumpWidget( _withStreamTheme( _splitButton( - onPressed: () {}, + onLeadingPressed: () {}, onTrailingPressed: () {}, - tooltip: 'Mute', + leadingTooltip: 'Mute', trailingTooltip: 'Audio settings', ), ), @@ -414,9 +516,9 @@ void main() { _withStreamTheme( StreamComponentFactory( builders: StreamComponentBuilders( - splitButton: (context, props) => Text('custom ${props.tooltip}'), + splitButton: (context, props) => Text('custom ${props.leadingTooltip}'), ), - child: _splitButton(onPressed: () {}, onTrailingPressed: () {}, tooltip: 'Mute'), + child: _splitButton(onLeadingPressed: () {}, onTrailingPressed: () {}, leadingTooltip: 'Mute'), ), ), );