From 398880ca722e44b5fe41d28b18d9c397a852ec53 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 18 Aug 2026 15:18:35 +0200 Subject: [PATCH 1/5] poc for split button --- .../lib/app/gallery_app.directories.g.dart | 17 + .../lib/components/buttons/split_button.dart | 342 +++++++++++++ packages/stream_core_flutter/CHANGELOG.md | 9 + .../stream_core_flutter/check_barrels.yaml | 1 + packages/stream_core_flutter/lib/core.dart | 2 + .../internal/stream_button_defaults.dart | 445 ++++++++++++++++ .../src/components/buttons/stream_button.dart | 473 +----------------- .../buttons/stream_split_button.dart | 307 ++++++++++++ .../src/factory/stream_component_factory.dart | 9 + .../stream_component_factory.g.theme.dart | 6 + .../components/stream_split_button_theme.dart | 180 +++++++ .../stream_split_button_theme.g.theme.dart | 185 +++++++ .../lib/src/theme/stream_theme.dart | 9 + .../lib/src/theme/stream_theme.g.theme.dart | 9 + .../src/theme/stream_theme_extensions.dart | 4 + .../stream_split_button_golden_test.dart | 150 ++++++ .../buttons/stream_split_button_test.dart | 349 +++++++++++++ 17 files changed, 2048 insertions(+), 449 deletions(-) create mode 100644 apps/design_system_gallery/lib/components/buttons/split_button.dart create mode 100644 packages/stream_core_flutter/lib/src/components/buttons/internal/stream_button_defaults.dart create mode 100644 packages/stream_core_flutter/lib/src/components/buttons/stream_split_button.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_split_button_theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_split_button_theme.g.theme.dart create mode 100644 packages/stream_core_flutter/test/components/buttons/stream_split_button_golden_test.dart create mode 100644 packages/stream_core_flutter/test/components/buttons/stream_split_button_test.dart diff --git a/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart b/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart index 4dbdef0e..bc8f4899 100644 --- a/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart +++ b/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart @@ -36,6 +36,8 @@ import 'package:design_system_gallery/components/badge/stream_retry_badge.dart' as _design_system_gallery_components_badge_stream_retry_badge; import 'package:design_system_gallery/components/buttons/button.dart' as _design_system_gallery_components_buttons_button; +import 'package:design_system_gallery/components/buttons/split_button.dart' + as _design_system_gallery_components_buttons_split_button; import 'package:design_system_gallery/components/buttons/stream_emoji_button.dart' as _design_system_gallery_components_buttons_stream_emoji_button; import 'package:design_system_gallery/components/buttons/stream_jump_to_unread_button.dart' @@ -520,6 +522,21 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookComponent( + name: 'StreamSplitButton', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: _design_system_gallery_components_buttons_split_button + .buildStreamSplitButtonPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: _design_system_gallery_components_buttons_split_button + .buildStreamSplitButtonShowcase, + ), + ], + ), ], ), _widgetbook.WidgetbookFolder( diff --git a/apps/design_system_gallery/lib/components/buttons/split_button.dart b/apps/design_system_gallery/lib/components/buttons/split_button.dart new file mode 100644 index 00000000..56022990 --- /dev/null +++ b/apps/design_system_gallery/lib/components/buttons/split_button.dart @@ -0,0 +1,342 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/core.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +// ============================================================================= +// Playground +// ============================================================================= + +@widgetbook.UseCase( + name: 'Playground', + type: StreamSplitButton, + path: '[Components]/Buttons', +) +Widget buildStreamSplitButtonPlayground(BuildContext context) { + final icons = context.streamIcons; + + final style = context.knobs.object.dropdown( + label: 'Style', + options: StreamButtonStyle.values, + initialOption: StreamButtonStyle.secondary, + 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.', + ); + + final size = context.knobs.object.dropdown( + label: 'Size', + options: StreamButtonSize.values, + initialOption: StreamButtonSize.small, + labelBuilder: (option) => option.name, + description: 'Painted area of each half. The tap target stays accessible regardless.', + ); + + final caretUp = context.knobs.boolean( + label: 'Caret Up', + description: 'Point the trailing caret up, as when the menu it opens is already showing.', + ); + + final leadingEnabled = context.knobs.boolean( + label: 'Leading Enabled', + initialValue: true, + description: 'Whether the primary half accepts taps.', + ); + + final trailingEnabled = context.knobs.boolean( + label: 'Trailing Enabled', + initialValue: true, + description: 'Whether the trailing half accepts taps.', + ); + + final showErrorBadge = context.knobs.boolean( + label: 'Error Badge', + description: 'Overlay a StreamErrorBadge, as a call control does when the mic fails.', + ); + + return Center( + child: _MaybeBadged( + showErrorBadge: showErrorBadge, + child: StreamSplitButton.icon( + icon: Icon(icons.voiceFill), + trailingIcon: Icon(caretUp ? icons.caretUp : icons.caretDown), + style: style, + type: type, + size: size, + tooltip: 'Mute', + trailingTooltip: 'Audio settings', + onPressed: leadingEnabled ? () {} : null, + onTrailingPressed: trailingEnabled ? () {} : null, + ), + ), + ); +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamSplitButton, + path: '[Components]/Buttons', +) +Widget buildStreamSplitButtonShowcase(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return DefaultTextStyle( + style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), + child: SingleChildScrollView( + padding: EdgeInsets.all(spacing.lg), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.xl, + children: const [ + _StyleTypeMatrixSection(), + _SizeScaleSection(), + _DisabledSection(), + _CallControlSection(), + ], + ), + ), + ); +} + +class _StyleTypeMatrixSection extends StatelessWidget { + const _StyleTypeMatrixSection(); + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final spacing = context.streamSpacing; + + return _ExampleCard( + title: 'Style × type', + 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) + 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, + size: StreamButtonSize.small, + tooltip: 'Mute', + trailingTooltip: 'Audio settings', + onPressed: () {}, + onTrailingPressed: () {}, + ), + ], + ), + ], + ), + ); + } +} + +class _SizeScaleSection extends StatelessWidget { + const _SizeScaleSection(); + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final spacing = context.streamSpacing; + + return _ExampleCard( + title: 'Sizes', + description: + 'Size sets the area a half highlights on hover and press — press one to see it. ' + 'The surface itself always hugs the tap targets.', + child: Row( + spacing: spacing.md, + children: [ + for (final size in StreamButtonSize.values) + StreamSplitButton.icon( + icon: Icon(icons.voiceFill), + trailingIcon: Icon(icons.caretDown), + style: StreamButtonStyle.secondary, + size: size, + tooltip: size.name, + trailingTooltip: 'Audio settings', + onPressed: () {}, + onTrailingPressed: () {}, + ), + ], + ), + ); + } +} + +class _DisabledSection extends StatelessWidget { + const _DisabledSection(); + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final spacing = context.streamSpacing; + + return _ExampleCard( + title: 'Disabled halves', + description: 'Each half disables on its own. The surface only goes disabled once both halves are.', + child: Row( + spacing: spacing.md, + children: [ + for (final (label, leading, trailing) in const [ + ('leading', false, true), + ('trailing', true, false), + ('both', false, false), + ]) + Column( + spacing: spacing.xs, + children: [ + StreamSplitButton.icon( + icon: Icon(icons.voiceFill), + trailingIcon: Icon(icons.caretDown), + style: StreamButtonStyle.secondary, + size: StreamButtonSize.small, + onPressed: leading ? () {} : null, + onTrailingPressed: trailing ? () {} : null, + ), + Text(label), + ], + ), + ], + ), + ); + } +} + +class _CallControlSection extends StatefulWidget { + const _CallControlSection(); + + @override + State<_CallControlSection> createState() => _CallControlSectionState(); +} + +class _CallControlSectionState extends State<_CallControlSection> { + var _isMuted = false; + var _isSettingsOpen = false; + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + + return _ExampleCard( + title: 'Call control', + description: + 'A microphone toggle paired with a caret that opens the audio settings, ' + 'badged when the device fails.', + child: Center( + child: _MaybeBadged( + showErrorBadge: true, + child: StreamSplitButton.icon( + icon: Icon(_isMuted ? icons.voiceOffFill : icons.voiceFill), + trailingIcon: Icon(_isSettingsOpen ? icons.caretUp : icons.caretDown), + style: _isMuted ? StreamButtonStyle.destructive : StreamButtonStyle.secondary, + size: StreamButtonSize.small, + tooltip: _isMuted ? 'Unmute' : 'Mute', + trailingTooltip: 'Audio settings', + onPressed: () => setState(() => _isMuted = !_isMuted), + onTrailingPressed: () => setState(() => _isSettingsOpen = !_isSettingsOpen), + ), + ), + ), + ); + } +} + +// ============================================================================= +// Shared Widgets +// ============================================================================= + +/// Overlays a [StreamErrorBadge] on the trailing top corner of [child]. +/// +/// The badge is not part of [StreamSplitButton] — call controls compose the +/// two, and this shows what that looks like. +class _MaybeBadged extends StatelessWidget { + const _MaybeBadged({required this.showErrorBadge, required this.child}); + + final bool showErrorBadge; + final Widget child; + + @override + Widget build(BuildContext context) { + if (!showErrorBadge) return child; + + return Stack( + clipBehavior: Clip.none, + children: [ + child, + PositionedDirectional(top: -4, end: -4, child: StreamErrorBadge(size: StreamErrorBadgeSize.sm)), + ], + ); + } +} + +class _ExampleCard extends StatelessWidget { + const _ExampleCard({ + required this.title, + required this.description, + required this.child, + }); + + final String title; + final String description; + final Widget child; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final boxShadow = context.streamBoxShadow; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + return Container( + width: double.infinity, + decoration: BoxDecoration( + color: colorScheme.backgroundSurfaceSubtle, + borderRadius: BorderRadius.all(radius.lg), + boxShadow: boxShadow.elevation1, + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.all(radius.lg), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(spacing.md, spacing.sm, spacing.md, spacing.sm), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: textTheme.captionEmphasis.copyWith(color: colorScheme.textPrimary)), + Text(description, style: textTheme.metadataDefault.copyWith(color: colorScheme.textTertiary)), + ], + ), + ), + Divider(height: 1, color: colorScheme.borderSubtle), + Padding(padding: EdgeInsets.all(spacing.md), child: child), + ], + ), + ); + } +} diff --git a/packages/stream_core_flutter/CHANGELOG.md b/packages/stream_core_flutter/CHANGELOG.md index 2ecf750c..39940481 100644 --- a/packages/stream_core_flutter/CHANGELOG.md +++ b/packages/stream_core_flutter/CHANGELOG.md @@ -3,6 +3,15 @@ ### ✨ Features - 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`, a pair of icon buttons sharing one surface with a + divider between them — a primary action alongside a caret that opens its + options. Create it with `StreamSplitButton.icon`, configure both icons (so + the caret can point up or down), and style it with the same + `StreamButtonStyle` / `StreamButtonType` / `StreamButtonSize` values a + `StreamButton` takes. The surface resolves from the same `StreamButtonTheme` + entry the halves use, so the two cannot drift apart; an `outline` split + button draws a single border around the whole control. Customize the divider + through `StreamSplitButtonTheme`. - 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/check_barrels.yaml b/packages/stream_core_flutter/check_barrels.yaml index 67d7a3ff..6b40504f 100644 --- a/packages/stream_core_flutter/check_barrels.yaml +++ b/packages/stream_core_flutter/check_barrels.yaml @@ -27,3 +27,4 @@ forbidden_src_imports: internal_dirs: - lib/src/theme/primitives/internal - lib/src/cache/internal + - lib/src/components/buttons/internal diff --git a/packages/stream_core_flutter/lib/core.dart b/packages/stream_core_flutter/lib/core.dart index fa8e0dee..57d5d165 100644 --- a/packages/stream_core_flutter/lib/core.dart +++ b/packages/stream_core_flutter/lib/core.dart @@ -28,6 +28,7 @@ export 'src/components/badge/stream_online_indicator.dart'; export 'src/components/badge/stream_retry_badge.dart'; export 'src/components/buttons/stream_button.dart'; export 'src/components/buttons/stream_emoji_button.dart'; +export 'src/components/buttons/stream_split_button.dart'; export 'src/components/common/stream_checkbox.dart'; export 'src/components/common/stream_flex.dart'; export 'src/components/common/stream_intrinsic_flex.dart'; @@ -86,6 +87,7 @@ export 'src/theme/components/stream_sheet_header_theme.dart'; export 'src/theme/components/stream_sheet_theme.dart'; export 'src/theme/components/stream_skeleton_loading_theme.dart'; export 'src/theme/components/stream_snackbar_theme.dart'; +export 'src/theme/components/stream_split_button_theme.dart'; export 'src/theme/components/stream_stepper_theme.dart'; export 'src/theme/components/stream_switch_theme.dart'; export 'src/theme/components/stream_text_input_theme.dart'; diff --git a/packages/stream_core_flutter/lib/src/components/buttons/internal/stream_button_defaults.dart b/packages/stream_core_flutter/lib/src/components/buttons/internal/stream_button_defaults.dart new file mode 100644 index 00000000..3b660f7d --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/buttons/internal/stream_button_defaults.dart @@ -0,0 +1,445 @@ +import 'package:flutter/material.dart'; + +import '../../../theme/components/stream_button_theme.dart'; +import '../../../theme/primitives/stream_colors.dart'; +import '../../../theme/primitives/stream_radius.dart'; +import '../../../theme/semantics/stream_color_scheme.dart'; +import '../../../theme/semantics/stream_text_theme.dart'; +import '../../../theme/stream_theme_extensions.dart'; +import '../stream_button.dart'; + +/// Resolves the effective style for a button of the given [style] and [type]. +/// +/// The result layers, from lowest to highest precedence, the built-in defaults +/// for the variant, the inherited [StreamButtonTheme], and [themeStyle]. +/// +/// Components that compose [StreamButton] use this to paint surfaces that have +/// to match the buttons they contain, such as the shared background behind the +/// two halves of a split button. +/// +/// [StreamButtonThemeStyle.padding] and [StreamButtonThemeStyle.fixedSize] are +/// left as-is; both depend on the button's size and shape, which callers +/// resolve themselves. +StreamButtonThemeStyle resolveStreamButtonThemeStyle( + BuildContext context, { + required StreamButtonStyle style, + required StreamButtonType type, + required bool isFloating, + StreamButtonThemeStyle? themeStyle, +}) { + final buttonTheme = context.streamButtonTheme; + final inheritedStyle = switch ((style, type)) { + (.primary, .solid) => buttonTheme.primary?.solid, + (.primary, .outline) => buttonTheme.primary?.outline, + (.primary, .ghost) => buttonTheme.primary?.ghost, + (.secondary, .solid) => buttonTheme.secondary?.solid, + (.secondary, .outline) => buttonTheme.secondary?.outline, + (.secondary, .ghost) => buttonTheme.secondary?.ghost, + (.destructive, .solid) => buttonTheme.destructive?.solid, + (.destructive, .outline) => buttonTheme.destructive?.outline, + (.destructive, .ghost) => buttonTheme.destructive?.ghost, + }; + + final defaults = switch ((style, type)) { + (.primary, .solid) => _PrimarySolidDefaults(context, isFloating: isFloating), + (.primary, .outline) => _PrimaryOutlineDefaults(context, isFloating: isFloating), + (.primary, .ghost) => _PrimaryGhostDefaults(context, isFloating: isFloating), + (.secondary, .solid) => _SecondarySolidDefaults(context, isFloating: isFloating), + (.secondary, .outline) => _SecondaryOutlineDefaults(context, isFloating: isFloating), + (.secondary, .ghost) => _SecondaryGhostDefaults(context, isFloating: isFloating), + (.destructive, .solid) => _DestructiveSolidDefaults(context, isFloating: isFloating), + (.destructive, .outline) => _DestructiveOutlineDefaults(context, isFloating: isFloating), + (.destructive, .ghost) => _DestructiveGhostDefaults(context, isFloating: isFloating), + }; + + return defaults.merge(inheritedStyle?.merge(themeStyle) ?? themeStyle); +} + +// -- Shared defaults -------------------------------------------------------- + +mixin _SharedButtonDefaults on StreamButtonThemeStyle { + BuildContext get context; + bool get isFloating; + StreamRadius get radius; + StreamTextTheme get textTheme; + StreamColorScheme get colorScheme; + + @override + AlignmentGeometry get alignment => Alignment.center; + + @override + MaterialTapTargetSize get tapTargetSize => MaterialTapTargetSize.padded; + + @override + WidgetStateProperty get iconSize => const WidgetStatePropertyAll(20); + + @override + WidgetStateProperty get textStyle => WidgetStatePropertyAll(textTheme.bodyEmphasis); + + @override + WidgetStateProperty get shape => .all(RoundedSuperellipseBorder(borderRadius: .all(radius.max))); + + @override + WidgetStateProperty get overlayColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) return colorScheme.backgroundPressed; + if (states.contains(WidgetState.hovered)) return colorScheme.backgroundHover; + return StreamColors.transparent; + }); + + @override + WidgetStateProperty get minimumSize => const WidgetStatePropertyAll(Size.zero); + + @override + WidgetStateProperty get maximumSize => const WidgetStatePropertyAll(Size.infinite); + + @override + WidgetStateProperty get elevation { + final elevations = context.streamElevation; + return WidgetStateProperty.resolveWith((states) { + if (!isFloating) return elevations.none; + if (states.contains(WidgetState.disabled)) return elevations.level3; + if (states.contains(WidgetState.pressed)) return elevations.level3; + if (states.contains(WidgetState.hovered)) return elevations.level4; + return elevations.level3; + }); + } +} + +// -- Primary defaults ------------------------------------------------------- + +// Default style for primary solid buttons. +class _PrimarySolidDefaults extends StreamButtonThemeStyle with _SharedButtonDefaults { + _PrimarySolidDefaults( + this.context, { + required this.isFloating, + }) : radius = context.streamRadius, + textTheme = context.streamTextTheme, + colorScheme = context.streamColorScheme; + + @override + final BuildContext context; + @override + final StreamRadius radius; + @override + final StreamTextTheme textTheme; + @override + final StreamColorScheme colorScheme; + @override + final bool isFloating; + + @override + WidgetStateProperty get backgroundColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return colorScheme.backgroundDisabled; + final base = colorScheme.accentPrimary; + if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.backgroundSelected, base); + return base; + }); + + @override + WidgetStateProperty get foregroundColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return colorScheme.textDisabled; + return colorScheme.textOnAccent; + }); +} + +// Default style for primary outline buttons. +class _PrimaryOutlineDefaults extends StreamButtonThemeStyle with _SharedButtonDefaults { + _PrimaryOutlineDefaults( + this.context, { + required this.isFloating, + }) : radius = context.streamRadius, + textTheme = context.streamTextTheme, + colorScheme = context.streamColorScheme; + + @override + final BuildContext context; + @override + final StreamRadius radius; + @override + final StreamTextTheme textTheme; + @override + final StreamColorScheme colorScheme; + @override + final bool isFloating; + + @override + WidgetStateProperty get backgroundColor => WidgetStateProperty.resolveWith((states) { + final base = isFloating ? colorScheme.backgroundElevation1 : StreamColors.transparent; + if (states.contains(WidgetState.disabled)) return base; + if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.backgroundSelected, base); + return base; + }); + + @override + WidgetStateProperty get borderColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return colorScheme.borderDisabled; + return colorScheme.brand.shade200; + }); + + @override + WidgetStateProperty get foregroundColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return colorScheme.textDisabled; + return colorScheme.accentPrimary; + }); +} + +// Default style for primary ghost buttons. +class _PrimaryGhostDefaults extends StreamButtonThemeStyle with _SharedButtonDefaults { + _PrimaryGhostDefaults( + this.context, { + required this.isFloating, + }) : radius = context.streamRadius, + textTheme = context.streamTextTheme, + colorScheme = context.streamColorScheme; + + @override + final BuildContext context; + @override + final StreamRadius radius; + @override + final StreamTextTheme textTheme; + @override + final StreamColorScheme colorScheme; + @override + final bool isFloating; + + @override + WidgetStateProperty get backgroundColor => WidgetStateProperty.resolveWith((states) { + final base = isFloating ? colorScheme.backgroundElevation1 : StreamColors.transparent; + if (states.contains(WidgetState.disabled)) return base; + if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.backgroundSelected, base); + return base; + }); + + @override + WidgetStateProperty get foregroundColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return colorScheme.textDisabled; + return colorScheme.accentPrimary; + }); +} + +// -- Secondary defaults ----------------------------------------------------- + +// Default style for secondary solid buttons. +class _SecondarySolidDefaults extends StreamButtonThemeStyle with _SharedButtonDefaults { + _SecondarySolidDefaults( + this.context, { + required this.isFloating, + }) : radius = context.streamRadius, + textTheme = context.streamTextTheme, + colorScheme = context.streamColorScheme; + + @override + final BuildContext context; + @override + final StreamRadius radius; + @override + final StreamTextTheme textTheme; + @override + final StreamColorScheme colorScheme; + @override + final bool isFloating; + + @override + WidgetStateProperty get backgroundColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return colorScheme.backgroundDisabled; + final base = colorScheme.backgroundSurface; + if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.backgroundSelected, base); + return base; + }); + + @override + WidgetStateProperty get foregroundColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return colorScheme.textDisabled; + return colorScheme.textPrimary; + }); +} + +// Default style for secondary outline buttons. +class _SecondaryOutlineDefaults extends StreamButtonThemeStyle with _SharedButtonDefaults { + _SecondaryOutlineDefaults( + this.context, { + required this.isFloating, + }) : radius = context.streamRadius, + textTheme = context.streamTextTheme, + colorScheme = context.streamColorScheme; + + @override + final BuildContext context; + @override + final StreamRadius radius; + @override + final StreamTextTheme textTheme; + @override + final StreamColorScheme colorScheme; + @override + final bool isFloating; + + @override + WidgetStateProperty get backgroundColor => WidgetStateProperty.resolveWith((states) { + final base = isFloating ? colorScheme.backgroundElevation1 : StreamColors.transparent; + if (states.contains(WidgetState.disabled)) return base; + if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.backgroundSelected, base); + return base; + }); + + @override + WidgetStateProperty get foregroundColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return colorScheme.textDisabled; + return colorScheme.textPrimary; + }); + + @override + WidgetStateProperty get borderColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return colorScheme.borderDisabled; + return colorScheme.borderDefault; + }); +} + +// Default style for secondary ghost buttons. +class _SecondaryGhostDefaults extends StreamButtonThemeStyle with _SharedButtonDefaults { + _SecondaryGhostDefaults( + this.context, { + required this.isFloating, + }) : radius = context.streamRadius, + textTheme = context.streamTextTheme, + colorScheme = context.streamColorScheme; + + @override + final BuildContext context; + @override + final StreamRadius radius; + @override + final StreamTextTheme textTheme; + @override + final StreamColorScheme colorScheme; + @override + final bool isFloating; + + @override + WidgetStateProperty get backgroundColor => WidgetStateProperty.resolveWith((states) { + final base = isFloating ? colorScheme.backgroundElevation1 : StreamColors.transparent; + if (states.contains(WidgetState.disabled)) return base; + if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.backgroundSelected, base); + return base; + }); + + @override + WidgetStateProperty get foregroundColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return colorScheme.textDisabled; + return colorScheme.textPrimary; + }); +} + +// -- Destructive defaults --------------------------------------------------- + +// Default style for destructive solid buttons. +class _DestructiveSolidDefaults extends StreamButtonThemeStyle with _SharedButtonDefaults { + _DestructiveSolidDefaults( + this.context, { + required this.isFloating, + }) : radius = context.streamRadius, + textTheme = context.streamTextTheme, + colorScheme = context.streamColorScheme; + + @override + final BuildContext context; + @override + final StreamRadius radius; + @override + final StreamTextTheme textTheme; + @override + final StreamColorScheme colorScheme; + @override + final bool isFloating; + + @override + WidgetStateProperty get backgroundColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return colorScheme.backgroundDisabled; + final base = colorScheme.accentError; + if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.backgroundSelected, base); + return base; + }); + + @override + WidgetStateProperty get foregroundColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return colorScheme.textDisabled; + return colorScheme.textOnAccent; + }); +} + +// Default style for destructive outline buttons. +class _DestructiveOutlineDefaults extends StreamButtonThemeStyle with _SharedButtonDefaults { + _DestructiveOutlineDefaults( + this.context, { + required this.isFloating, + }) : radius = context.streamRadius, + textTheme = context.streamTextTheme, + colorScheme = context.streamColorScheme; + + @override + final BuildContext context; + @override + final StreamRadius radius; + @override + final StreamTextTheme textTheme; + @override + final StreamColorScheme colorScheme; + @override + final bool isFloating; + + @override + WidgetStateProperty get backgroundColor => WidgetStateProperty.resolveWith((states) { + final base = isFloating ? colorScheme.backgroundElevation1 : StreamColors.transparent; + if (states.contains(WidgetState.disabled)) return base; + if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.backgroundSelected, base); + return base; + }); + + @override + WidgetStateProperty get borderColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return colorScheme.borderDisabled; + return colorScheme.accentError; + }); + + @override + WidgetStateProperty get foregroundColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return colorScheme.textDisabled; + return colorScheme.accentError; + }); +} + +// Default style for destructive ghost buttons. +class _DestructiveGhostDefaults extends StreamButtonThemeStyle with _SharedButtonDefaults { + _DestructiveGhostDefaults( + this.context, { + required this.isFloating, + }) : radius = context.streamRadius, + textTheme = context.streamTextTheme, + colorScheme = context.streamColorScheme; + + @override + final BuildContext context; + @override + final StreamRadius radius; + @override + final StreamTextTheme textTheme; + @override + final StreamColorScheme colorScheme; + @override + final bool isFloating; + + @override + WidgetStateProperty get backgroundColor => WidgetStateProperty.resolveWith((states) { + final base = isFloating ? colorScheme.backgroundElevation1 : StreamColors.transparent; + if (states.contains(WidgetState.disabled)) return base; + if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.backgroundSelected, base); + return base; + }); + + @override + WidgetStateProperty get foregroundColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return colorScheme.textDisabled; + return colorScheme.accentError; + }); +} diff --git a/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart b/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart index a5e2e110..90cd7b22 100644 --- a/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart +++ b/packages/stream_core_flutter/lib/src/components/buttons/stream_button.dart @@ -2,11 +2,8 @@ import 'package:flutter/material.dart'; import '../../factory/stream_component_factory.dart'; import '../../theme/components/stream_button_theme.dart'; -import '../../theme/primitives/stream_colors.dart'; -import '../../theme/primitives/stream_radius.dart'; -import '../../theme/semantics/stream_color_scheme.dart'; -import '../../theme/semantics/stream_text_theme.dart'; import '../../theme/stream_theme_extensions.dart'; +import 'internal/stream_button_defaults.dart'; /// A versatile button with support for multiple styles, types, and sizes. /// @@ -349,56 +346,23 @@ class _DefaultStreamButtonState extends State { @override Widget build(BuildContext context) { final spacing = context.streamSpacing; - final buttonTheme = context.streamButtonTheme; - - final inheritedStyle = switch ((props.style, props.type)) { - (.primary, .solid) => buttonTheme.primary?.solid, - (.primary, .outline) => buttonTheme.primary?.outline, - (.primary, .ghost) => buttonTheme.primary?.ghost, - (.secondary, .solid) => buttonTheme.secondary?.solid, - (.secondary, .outline) => buttonTheme.secondary?.outline, - (.secondary, .ghost) => buttonTheme.secondary?.ghost, - (.destructive, .solid) => buttonTheme.destructive?.solid, - (.destructive, .outline) => buttonTheme.destructive?.outline, - (.destructive, .ghost) => buttonTheme.destructive?.ghost, - }; - - final themeStyle = inheritedStyle?.merge(props.themeStyle) ?? props.themeStyle; - - final isFloating = props.isFloating ?? false; - final defaults = switch ((props.style, props.type)) { - (.primary, .solid) => _PrimarySolidDefaults(context, isFloating: isFloating), - (.primary, .outline) => _PrimaryOutlineDefaults(context, isFloating: isFloating), - (.primary, .ghost) => _PrimaryGhostDefaults(context, isFloating: isFloating), - (.secondary, .solid) => _SecondarySolidDefaults(context, isFloating: isFloating), - (.secondary, .outline) => _SecondaryOutlineDefaults(context, isFloating: isFloating), - (.secondary, .ghost) => _SecondaryGhostDefaults(context, isFloating: isFloating), - (.destructive, .solid) => _DestructiveSolidDefaults(context, isFloating: isFloating), - (.destructive, .outline) => _DestructiveOutlineDefaults(context, isFloating: isFloating), - (.destructive, .ghost) => _DestructiveGhostDefaults(context, isFloating: isFloating), - }; - - final effectiveBackgroundColor = themeStyle?.backgroundColor ?? defaults.backgroundColor; - final effectiveForegroundColor = themeStyle?.foregroundColor ?? defaults.foregroundColor; - final effectiveBorderColor = themeStyle?.borderColor ?? defaults.borderColor; - final effectiveOverlayColor = themeStyle?.overlayColor ?? defaults.overlayColor; - final effectiveElevation = themeStyle?.elevation ?? defaults.elevation; - final effectiveIconSize = themeStyle?.iconSize ?? defaults.iconSize; - final effectiveTextStyle = themeStyle?.textStyle ?? defaults.textStyle; - final effectiveShape = themeStyle?.shape ?? defaults.shape; - final effectiveTapTargetSize = themeStyle?.tapTargetSize ?? defaults.tapTargetSize; + + final themeStyle = resolveStreamButtonThemeStyle( + context, + style: props.style, + type: props.type, + isFloating: props.isFloating ?? false, + themeStyle: props.themeStyle, + ); final buttonSize = props.size.value; final isIconButton = props.child == null; final effectiveFixedSize = - themeStyle?.fixedSize ?? + themeStyle.fixedSize ?? WidgetStatePropertyAll(isIconButton ? Size.square(buttonSize) : Size.fromHeight(buttonSize)); - final effectiveMinimumSize = themeStyle?.minimumSize ?? defaults.minimumSize; - final effectiveMaximumSize = themeStyle?.maximumSize ?? defaults.maximumSize; - final effectiveAlignment = themeStyle?.alignment ?? defaults.alignment; final effectivePadding = - themeStyle?.padding ?? + themeStyle.padding ?? switch (isIconButton) { true => const WidgetStatePropertyAll(EdgeInsets.zero), false => WidgetStatePropertyAll(.symmetric(horizontal: spacing.md)), @@ -411,22 +375,22 @@ class _DefaultStreamButtonState extends State { onPressed: props.onPressed, statesController: _statesController, style: ButtonStyle( - tapTargetSize: effectiveTapTargetSize, + tapTargetSize: themeStyle.tapTargetSize, visualDensity: .standard, - textStyle: effectiveTextStyle, - iconSize: effectiveIconSize, - elevation: effectiveElevation, - backgroundColor: effectiveBackgroundColor, - foregroundColor: effectiveForegroundColor, - iconColor: effectiveForegroundColor, - overlayColor: effectiveOverlayColor, + textStyle: themeStyle.textStyle, + iconSize: themeStyle.iconSize, + elevation: themeStyle.elevation, + backgroundColor: themeStyle.backgroundColor, + foregroundColor: themeStyle.foregroundColor, + iconColor: themeStyle.foregroundColor, + overlayColor: themeStyle.overlayColor, fixedSize: effectiveFixedSize, - minimumSize: effectiveMinimumSize, - maximumSize: effectiveMaximumSize, + minimumSize: themeStyle.minimumSize, + maximumSize: themeStyle.maximumSize, padding: effectivePadding, - alignment: effectiveAlignment, - shape: effectiveShape, - side: switch (effectiveBorderColor) { + alignment: themeStyle.alignment, + shape: themeStyle.shape, + side: switch (themeStyle.borderColor) { final color? => .resolveWith( (states) { final resolvedColor = color.resolve(states); @@ -460,392 +424,3 @@ class _DefaultStreamButtonState extends State { return MergeSemantics(child: button); } } - -// -- Shared defaults -------------------------------------------------------- - -mixin _SharedButtonDefaults on StreamButtonThemeStyle { - BuildContext get context; - bool get isFloating; - StreamRadius get radius; - StreamTextTheme get textTheme; - StreamColorScheme get colorScheme; - - @override - AlignmentGeometry get alignment => Alignment.center; - - @override - MaterialTapTargetSize get tapTargetSize => MaterialTapTargetSize.padded; - - @override - WidgetStateProperty get iconSize => const WidgetStatePropertyAll(20); - - @override - WidgetStateProperty get textStyle => WidgetStatePropertyAll(textTheme.bodyEmphasis); - - @override - WidgetStateProperty get shape => .all(RoundedSuperellipseBorder(borderRadius: .all(radius.max))); - - @override - WidgetStateProperty get overlayColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.pressed)) return colorScheme.backgroundPressed; - if (states.contains(WidgetState.hovered)) return colorScheme.backgroundHover; - return StreamColors.transparent; - }); - - @override - WidgetStateProperty get minimumSize => const WidgetStatePropertyAll(Size.zero); - - @override - WidgetStateProperty get maximumSize => const WidgetStatePropertyAll(Size.infinite); - - @override - WidgetStateProperty get elevation { - final elevations = context.streamElevation; - return WidgetStateProperty.resolveWith((states) { - if (!isFloating) return elevations.none; - if (states.contains(WidgetState.disabled)) return elevations.level3; - if (states.contains(WidgetState.pressed)) return elevations.level3; - if (states.contains(WidgetState.hovered)) return elevations.level4; - return elevations.level3; - }); - } -} - -// -- Primary defaults ------------------------------------------------------- - -// Default style for primary solid buttons. -class _PrimarySolidDefaults extends StreamButtonThemeStyle with _SharedButtonDefaults { - _PrimarySolidDefaults( - this.context, { - required this.isFloating, - }) : radius = context.streamRadius, - textTheme = context.streamTextTheme, - colorScheme = context.streamColorScheme; - - @override - final BuildContext context; - @override - final StreamRadius radius; - @override - final StreamTextTheme textTheme; - @override - final StreamColorScheme colorScheme; - @override - final bool isFloating; - - @override - WidgetStateProperty get backgroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return colorScheme.backgroundDisabled; - final base = colorScheme.accentPrimary; - if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.backgroundSelected, base); - return base; - }); - - @override - WidgetStateProperty get foregroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return colorScheme.textDisabled; - return colorScheme.textOnAccent; - }); -} - -// Default style for primary outline buttons. -class _PrimaryOutlineDefaults extends StreamButtonThemeStyle with _SharedButtonDefaults { - _PrimaryOutlineDefaults( - this.context, { - required this.isFloating, - }) : radius = context.streamRadius, - textTheme = context.streamTextTheme, - colorScheme = context.streamColorScheme; - - @override - final BuildContext context; - @override - final StreamRadius radius; - @override - final StreamTextTheme textTheme; - @override - final StreamColorScheme colorScheme; - @override - final bool isFloating; - - @override - WidgetStateProperty get backgroundColor => WidgetStateProperty.resolveWith((states) { - final base = isFloating ? colorScheme.backgroundElevation1 : StreamColors.transparent; - if (states.contains(WidgetState.disabled)) return base; - if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.backgroundSelected, base); - return base; - }); - - @override - WidgetStateProperty get borderColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return colorScheme.borderDisabled; - return colorScheme.brand.shade200; - }); - - @override - WidgetStateProperty get foregroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return colorScheme.textDisabled; - return colorScheme.accentPrimary; - }); -} - -// Default style for primary ghost buttons. -class _PrimaryGhostDefaults extends StreamButtonThemeStyle with _SharedButtonDefaults { - _PrimaryGhostDefaults( - this.context, { - required this.isFloating, - }) : radius = context.streamRadius, - textTheme = context.streamTextTheme, - colorScheme = context.streamColorScheme; - - @override - final BuildContext context; - @override - final StreamRadius radius; - @override - final StreamTextTheme textTheme; - @override - final StreamColorScheme colorScheme; - @override - final bool isFloating; - - @override - WidgetStateProperty get backgroundColor => WidgetStateProperty.resolveWith((states) { - final base = isFloating ? colorScheme.backgroundElevation1 : StreamColors.transparent; - if (states.contains(WidgetState.disabled)) return base; - if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.backgroundSelected, base); - return base; - }); - - @override - WidgetStateProperty get foregroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return colorScheme.textDisabled; - return colorScheme.accentPrimary; - }); -} - -// -- Secondary defaults ----------------------------------------------------- - -// Default style for secondary solid buttons. -class _SecondarySolidDefaults extends StreamButtonThemeStyle with _SharedButtonDefaults { - _SecondarySolidDefaults( - this.context, { - required this.isFloating, - }) : radius = context.streamRadius, - textTheme = context.streamTextTheme, - colorScheme = context.streamColorScheme; - - @override - final BuildContext context; - @override - final StreamRadius radius; - @override - final StreamTextTheme textTheme; - @override - final StreamColorScheme colorScheme; - @override - final bool isFloating; - - @override - WidgetStateProperty get backgroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return colorScheme.backgroundDisabled; - final base = colorScheme.backgroundSurface; - if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.backgroundSelected, base); - return base; - }); - - @override - WidgetStateProperty get foregroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return colorScheme.textDisabled; - return colorScheme.textPrimary; - }); -} - -// Default style for secondary outline buttons. -class _SecondaryOutlineDefaults extends StreamButtonThemeStyle with _SharedButtonDefaults { - _SecondaryOutlineDefaults( - this.context, { - required this.isFloating, - }) : radius = context.streamRadius, - textTheme = context.streamTextTheme, - colorScheme = context.streamColorScheme; - - @override - final BuildContext context; - @override - final StreamRadius radius; - @override - final StreamTextTheme textTheme; - @override - final StreamColorScheme colorScheme; - @override - final bool isFloating; - - @override - WidgetStateProperty get backgroundColor => WidgetStateProperty.resolveWith((states) { - final base = isFloating ? colorScheme.backgroundElevation1 : StreamColors.transparent; - if (states.contains(WidgetState.disabled)) return base; - if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.backgroundSelected, base); - return base; - }); - - @override - WidgetStateProperty get foregroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return colorScheme.textDisabled; - return colorScheme.textPrimary; - }); - - @override - WidgetStateProperty get borderColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return colorScheme.borderDisabled; - return colorScheme.borderDefault; - }); -} - -// Default style for secondary ghost buttons. -class _SecondaryGhostDefaults extends StreamButtonThemeStyle with _SharedButtonDefaults { - _SecondaryGhostDefaults( - this.context, { - required this.isFloating, - }) : radius = context.streamRadius, - textTheme = context.streamTextTheme, - colorScheme = context.streamColorScheme; - - @override - final BuildContext context; - @override - final StreamRadius radius; - @override - final StreamTextTheme textTheme; - @override - final StreamColorScheme colorScheme; - @override - final bool isFloating; - - @override - WidgetStateProperty get backgroundColor => WidgetStateProperty.resolveWith((states) { - final base = isFloating ? colorScheme.backgroundElevation1 : StreamColors.transparent; - if (states.contains(WidgetState.disabled)) return base; - if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.backgroundSelected, base); - return base; - }); - - @override - WidgetStateProperty get foregroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return colorScheme.textDisabled; - return colorScheme.textPrimary; - }); -} - -// -- Destructive defaults --------------------------------------------------- - -// Default style for destructive solid buttons. -class _DestructiveSolidDefaults extends StreamButtonThemeStyle with _SharedButtonDefaults { - _DestructiveSolidDefaults( - this.context, { - required this.isFloating, - }) : radius = context.streamRadius, - textTheme = context.streamTextTheme, - colorScheme = context.streamColorScheme; - - @override - final BuildContext context; - @override - final StreamRadius radius; - @override - final StreamTextTheme textTheme; - @override - final StreamColorScheme colorScheme; - @override - final bool isFloating; - - @override - WidgetStateProperty get backgroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return colorScheme.backgroundDisabled; - final base = colorScheme.accentError; - if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.backgroundSelected, base); - return base; - }); - - @override - WidgetStateProperty get foregroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return colorScheme.textDisabled; - return colorScheme.textOnAccent; - }); -} - -// Default style for destructive outline buttons. -class _DestructiveOutlineDefaults extends StreamButtonThemeStyle with _SharedButtonDefaults { - _DestructiveOutlineDefaults( - this.context, { - required this.isFloating, - }) : radius = context.streamRadius, - textTheme = context.streamTextTheme, - colorScheme = context.streamColorScheme; - - @override - final BuildContext context; - @override - final StreamRadius radius; - @override - final StreamTextTheme textTheme; - @override - final StreamColorScheme colorScheme; - @override - final bool isFloating; - - @override - WidgetStateProperty get backgroundColor => WidgetStateProperty.resolveWith((states) { - final base = isFloating ? colorScheme.backgroundElevation1 : StreamColors.transparent; - if (states.contains(WidgetState.disabled)) return base; - if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.backgroundSelected, base); - return base; - }); - - @override - WidgetStateProperty get borderColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return colorScheme.borderDisabled; - return colorScheme.accentError; - }); - - @override - WidgetStateProperty get foregroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return colorScheme.textDisabled; - return colorScheme.accentError; - }); -} - -// Default style for destructive ghost buttons. -class _DestructiveGhostDefaults extends StreamButtonThemeStyle with _SharedButtonDefaults { - _DestructiveGhostDefaults( - this.context, { - required this.isFloating, - }) : radius = context.streamRadius, - textTheme = context.streamTextTheme, - colorScheme = context.streamColorScheme; - - @override - final BuildContext context; - @override - final StreamRadius radius; - @override - final StreamTextTheme textTheme; - @override - final StreamColorScheme colorScheme; - @override - final bool isFloating; - - @override - WidgetStateProperty get backgroundColor => WidgetStateProperty.resolveWith((states) { - final base = isFloating ? colorScheme.backgroundElevation1 : StreamColors.transparent; - if (states.contains(WidgetState.disabled)) return base; - if (states.contains(WidgetState.selected)) return .alphaBlend(colorScheme.backgroundSelected, base); - return base; - }); - - @override - WidgetStateProperty get foregroundColor => WidgetStateProperty.resolveWith((states) { - if (states.contains(WidgetState.disabled)) return colorScheme.textDisabled; - return colorScheme.accentError; - }); -} 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 new file mode 100644 index 00000000..08d01d8d --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/buttons/stream_split_button.dart @@ -0,0 +1,307 @@ +import 'package:flutter/material.dart'; + +import '../../factory/stream_component_factory.dart'; +import '../../theme/components/stream_button_theme.dart'; +import '../../theme/components/stream_split_button_theme.dart'; +import '../../theme/primitives/stream_colors.dart'; +import '../../theme/primitives/stream_spacing.dart'; +import '../../theme/semantics/stream_color_scheme.dart'; +import '../../theme/stream_theme_extensions.dart'; +import 'internal/stream_button_defaults.dart'; +import 'stream_button.dart'; + +/// Two buttons sharing one surface, separated by a divider. +/// +/// A split button pairs a primary action with a secondary one — most often a +/// caret that opens the options for that action. Both halves are +/// [StreamButton.icon] instances painted on a single background, so the +/// control reads as one pill rather than two adjacent buttons. +/// +/// 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. +/// +/// Each half keeps its own tap target, hover and press feedback, and +/// accessibility node; the divider is decorative. +/// +/// {@tool snippet} +/// +/// A microphone button with a caret that opens the audio settings: +/// +/// ```dart +/// StreamSplitButton.icon( +/// style: StreamButtonStyle.secondary, +/// icon: Icon(context.streamIcons.voiceFill), +/// trailingIcon: Icon(context.streamIcons.caretDown), +/// tooltip: 'Mute', +/// trailingTooltip: 'Audio settings', +/// onPressed: () => toggleMute(), +/// onTrailingPressed: () => showAudioSettings(), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Flip the caret while the menu it opens is showing: +/// +/// ```dart +/// StreamSplitButton.icon( +/// type: StreamButtonType.outline, +/// icon: const Icon(Icons.share), +/// trailingIcon: Icon(isMenuOpen ? icons.caretUp : icons.caretDown), +/// onPressed: () => share(), +/// onTrailingPressed: () => toggleMenu(), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamButton], the button each half is built from. +/// * [StreamSplitButtonTheme], for customizing split button appearance. +class StreamSplitButton extends StatelessWidget { + /// Creates a split button with an icon in each half. + /// + /// [icon] labels the primary half and [trailingIcon] the secondary 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. + /// + /// A half with a null callback is disabled; the control as a whole only + /// takes on its disabled surface once both halves are. + StreamSplitButton.icon({ + super.key, + required Widget icon, + required Widget trailingIcon, + VoidCallback? onPressed, + VoidCallback? onTrailingPressed, + StreamButtonStyle style = .primary, + StreamButtonType type = .solid, + StreamButtonSize size = .medium, + String? tooltip, + String? trailingTooltip, + StreamSplitButtonStyle? themeStyle, + }) : props = .new( + icon: icon, + trailingIcon: trailingIcon, + onPressed: onPressed, + onTrailingPressed: onTrailingPressed, + style: style, + type: type, + size: size, + tooltip: tooltip, + trailingTooltip: trailingTooltip, + themeStyle: themeStyle, + ); + + /// The props controlling the appearance and behavior of this split button. + final StreamSplitButtonProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).splitButton; + if (builder != null) return builder(context, props); + return DefaultStreamSplitButton(props: props); + } +} + +/// Properties for configuring a [StreamSplitButton]. +/// +/// This class holds all the configuration options for a split button, +/// allowing them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamSplitButton], which uses these properties. +/// * [DefaultStreamSplitButton], the default implementation. +class StreamSplitButtonProps { + /// Creates properties for a split button. + const StreamSplitButtonProps({ + required this.icon, + required this.trailingIcon, + this.onPressed, + this.onTrailingPressed, + this.style = .primary, + this.type = .solid, + this.size = .medium, + this.tooltip, + this.trailingTooltip, + this.themeStyle, + }); + + /// The icon rendered in the primary (leading) half. + final Widget icon; + + /// 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. + /// + /// If null, that half is disabled. + final VoidCallback? onPressed; + + /// 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. + /// + /// Controls the visual weight (solid, outline, ghost). An outline split + /// button draws a single border around both halves. + final StreamButtonType type; + + /// The size of each half. + /// + /// Sets the painted area of a half — the surface it highlights on hover and + /// press. Each half keeps an accessible tap target regardless of this value. + final StreamButtonSize size; + + /// Text shown in a [Tooltip] on hover / long-press of the primary half, and + /// used as its accessibility label. + /// + /// When null, that half has no tooltip. + final String? tooltip; + + /// Text shown in a [Tooltip] on hover / long-press of the trailing half, and + /// used as its accessibility label. + /// + /// When null, that half has no tooltip. + final String? trailingTooltip; + + /// Per-instance style overrides for this split button. + /// + /// These properties take precedence over the inherited + /// [StreamSplitButtonTheme] values for this specific instance. + final StreamSplitButtonStyle? themeStyle; +} + +/// Default implementation of [StreamSplitButton]. +/// +/// Renders a [Row] of two [StreamButton.icon] halves over a shared surface, +/// with a divider between them. +/// +/// See also: +/// +/// * [StreamSplitButton], the public widget that delegates to this. +/// * [StreamSplitButtonProps], the configuration properties. +class DefaultStreamSplitButton extends StatelessWidget { + /// Creates a default split button. + const DefaultStreamSplitButton({super.key, required this.props}); + + /// The props controlling the appearance and behavior of this split button. + final StreamSplitButtonProps props; + + @override + Widget build(BuildContext context) { + final themeStyle = context.streamSplitButtonTheme.style?.merge(props.themeStyle) ?? props.themeStyle; + final defaults = _StreamSplitButtonDefaults(context, size: props.size); + + // 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, + isFloating: false, + themeStyle: defaults.buttonStyle.merge(themeStyle?.buttonStyle), + ); + + final isEnabled = props.onPressed != null || props.onTrailingPressed != null; + final states = {if (!isEnabled) WidgetState.disabled}; + + final shape = buttonStyle.shape?.resolve(states) ?? const StadiumBorder(); + final borderColor = buttonStyle.borderColor?.resolve(states); + + final effectiveSeparatorColor = (themeStyle?.separatorColor ?? defaults.separatorColor).resolve(states); + final effectiveSeparatorThickness = themeStyle?.separatorThickness ?? defaults.separatorThickness; + final effectiveSeparatorHeight = themeStyle?.separatorHeight ?? defaults.separatorHeight; + + // The halves sit on the shared surface, so they paint neither their own + // background nor their own border. + final halfStyle = buttonStyle.copyWith( + backgroundColor: const WidgetStatePropertyAll(StreamColors.transparent), + borderColor: const WidgetStatePropertyAll(null), + elevation: const WidgetStatePropertyAll(0), + ); + + return DecoratedBox( + decoration: ShapeDecoration( + color: buttonStyle.backgroundColor?.resolve(states), + shape: switch (borderColor) { + final color? => shape.copyWith(side: BorderSide(color: color)), + _ => shape, + }, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + StreamButton.icon( + icon: props.icon, + onPressed: props.onPressed, + style: props.style, + type: props.type, + size: props.size, + tooltip: props.tooltip, + themeStyle: halfStyle, + ), + SizedBox( + width: effectiveSeparatorThickness, + height: effectiveSeparatorHeight, + child: ColoredBox(color: effectiveSeparatorColor ?? StreamColors.transparent), + ), + StreamButton.icon( + icon: props.trailingIcon, + onPressed: props.onTrailingPressed, + style: props.style, + type: props.type, + size: props.size, + tooltip: props.trailingTooltip, + themeStyle: halfStyle, + ), + ], + ), + ); + } +} + +// Default theme values for [StreamSplitButton]. +// +// These defaults are used when no explicit value is provided via +// [StreamSplitButtonStyle] or [StreamSplitButtonThemeData]. +class _StreamSplitButtonDefaults extends StreamSplitButtonStyle { + _StreamSplitButtonDefaults(this.context, {required this.size}); + + final BuildContext context; + final StreamButtonSize size; + + late final StreamSpacing _spacing = context.streamSpacing; + late final StreamColorScheme _colorScheme = context.streamColorScheme; + + // Forced onto both halves and the surface, above the inherited + // [StreamButtonTheme] but below the caller's own overrides: a split button + // whose halves lost their tap target is not worth shipping. + @override + StreamButtonThemeStyle get buttonStyle => const StreamButtonThemeStyle(tapTargetSize: MaterialTapTargetSize.padded); + + @override + WidgetStateProperty get separatorColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return _colorScheme.borderDisabled; + return _colorScheme.borderDefault; + }); + + @override + double get separatorThickness => 1; + + @override + double get separatorHeight => size.value - _spacing.xxs * 2; +} diff --git a/packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart b/packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart index 6c215d19..da8b9348 100644 --- a/packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart +++ b/packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart @@ -15,6 +15,7 @@ import '../components/badge/stream_retry_badge.dart'; import '../components/buttons/stream_button.dart'; import '../components/buttons/stream_emoji_button.dart'; import '../components/buttons/stream_jump_to_unread_button.dart'; +import '../components/buttons/stream_split_button.dart'; import '../components/common/stream_checkbox.dart'; import '../components/common/stream_loading_spinner.dart'; import '../components/common/stream_network_image.dart'; @@ -223,6 +224,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { StreamComponentBuilder? sheetHeader, StreamComponentBuilder? skeletonLoading, StreamComponentBuilder? snackbar, + StreamComponentBuilder? splitButton, StreamComponentBuilder? stepper, StreamComponentBuilder? textInput, StreamComponentBuilder? toggleSwitch, @@ -277,6 +279,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { sheetHeader: sheetHeader, skeletonLoading: skeletonLoading, snackbar: snackbar, + splitButton: splitButton, stepper: stepper, textInput: textInput, toggleSwitch: toggleSwitch, @@ -332,6 +335,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { required this.sheetHeader, required this.skeletonLoading, required this.snackbar, + required this.splitButton, required this.stepper, required this.textInput, required this.toggleSwitch, @@ -591,6 +595,11 @@ class StreamComponentBuilders with _$StreamComponentBuilders { /// them by returning `const SizedBox.shrink()`). final StreamComponentBuilder? snackbar; + /// Custom builder for split button widgets. + /// + /// When null, [StreamSplitButton] uses [DefaultStreamSplitButton]. + final StreamComponentBuilder? splitButton; + /// Custom builder for stepper widgets. /// /// When null, [StreamStepper] uses [DefaultStreamStepper]. diff --git a/packages/stream_core_flutter/lib/src/factory/stream_component_factory.g.theme.dart b/packages/stream_core_flutter/lib/src/factory/stream_component_factory.g.theme.dart index 1f39ed62..13e31d9a 100644 --- a/packages/stream_core_flutter/lib/src/factory/stream_component_factory.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/factory/stream_component_factory.g.theme.dart @@ -91,6 +91,7 @@ mixin _$StreamComponentBuilders { sheetHeader: t < 0.5 ? a.sheetHeader : b.sheetHeader, skeletonLoading: t < 0.5 ? a.skeletonLoading : b.skeletonLoading, snackbar: t < 0.5 ? a.snackbar : b.snackbar, + splitButton: t < 0.5 ? a.splitButton : b.splitButton, stepper: t < 0.5 ? a.stepper : b.stepper, textInput: t < 0.5 ? a.textInput : b.textInput, toggleSwitch: t < 0.5 ? a.toggleSwitch : b.toggleSwitch, @@ -166,6 +167,7 @@ mixin _$StreamComponentBuilders { Widget Function(BuildContext, StreamSheetHeaderProps)? sheetHeader, Widget Function(BuildContext, StreamSkeletonLoadingProps)? skeletonLoading, Widget Function(BuildContext, StreamSnackbarProps)? snackbar, + Widget Function(BuildContext, StreamSplitButtonProps)? splitButton, Widget Function(BuildContext, StreamStepperProps)? stepper, Widget Function(BuildContext, StreamTextInputProps)? textInput, Widget Function(BuildContext, StreamSwitchProps)? toggleSwitch, @@ -234,6 +236,7 @@ mixin _$StreamComponentBuilders { sheetHeader: sheetHeader ?? _this.sheetHeader, skeletonLoading: skeletonLoading ?? _this.skeletonLoading, snackbar: snackbar ?? _this.snackbar, + splitButton: splitButton ?? _this.splitButton, stepper: stepper ?? _this.stepper, textInput: textInput ?? _this.textInput, toggleSwitch: toggleSwitch ?? _this.toggleSwitch, @@ -302,6 +305,7 @@ mixin _$StreamComponentBuilders { sheetHeader: other.sheetHeader, skeletonLoading: other.skeletonLoading, snackbar: other.snackbar, + splitButton: other.splitButton, stepper: other.stepper, textInput: other.textInput, toggleSwitch: other.toggleSwitch, @@ -374,6 +378,7 @@ mixin _$StreamComponentBuilders { _other.sheetHeader == _this.sheetHeader && _other.skeletonLoading == _this.skeletonLoading && _other.snackbar == _this.snackbar && + _other.splitButton == _this.splitButton && _other.stepper == _this.stepper && _other.textInput == _this.textInput && _other.toggleSwitch == _this.toggleSwitch && @@ -432,6 +437,7 @@ mixin _$StreamComponentBuilders { _this.sheetHeader, _this.skeletonLoading, _this.snackbar, + _this.splitButton, _this.stepper, _this.textInput, _this.toggleSwitch, 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 new file mode 100644 index 00000000..b98ebf6b --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_split_button_theme.dart @@ -0,0 +1,180 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; +import 'stream_button_theme.dart'; + +part 'stream_split_button_theme.g.theme.dart'; + +/// Applies a split button theme to descendant [StreamSplitButton] widgets. +/// +/// Wrap a subtree with [StreamSplitButtonTheme] to override split button +/// styling. Access the merged theme using +/// [BuildContext.streamSplitButtonTheme]. +/// +/// {@tool snippet} +/// +/// Override the separator for a specific section: +/// +/// ```dart +/// StreamSplitButtonTheme( +/// data: StreamSplitButtonThemeData( +/// style: StreamSplitButtonStyle( +/// separatorColor: WidgetStatePropertyAll(Colors.white24), +/// ), +/// ), +/// child: StreamSplitButton.icon( +/// icon: Icon(icons.voiceFill), +/// trailingIcon: Icon(icons.caretDown), +/// onPressed: () {}, +/// onTrailingPressed: () {}, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamSplitButtonThemeData], which describes the split button theme. +/// * [StreamSplitButton], the widget affected by this theme. +class StreamSplitButtonTheme extends InheritedTheme { + /// Creates a split button theme that controls descendant split buttons. + const StreamSplitButtonTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The split button theme data for descendant widgets. + final StreamSplitButtonThemeData data; + + /// Returns the [StreamSplitButtonThemeData] merged from local and global + /// themes. + /// + /// Local values from the nearest [StreamSplitButtonTheme] ancestor take + /// precedence over global values from [StreamTheme.of]. + static StreamSplitButtonThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).splitButtonTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamSplitButtonTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamSplitButtonTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamSplitButton] widgets. +/// +/// {@tool snippet} +/// +/// Customize split button appearance globally via [StreamTheme]: +/// +/// ```dart +/// StreamTheme( +/// splitButtonTheme: StreamSplitButtonThemeData( +/// style: StreamSplitButtonStyle(separatorThickness: 2), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamSplitButtonTheme], for overriding theme in a widget subtree. +/// * [StreamSplitButton], the widget that uses this theme data. +@themeGen +@immutable +class StreamSplitButtonThemeData with _$StreamSplitButtonThemeData { + /// Creates split button theme data with optional style overrides. + const StreamSplitButtonThemeData({this.style}); + + /// The visual styling for split buttons. + final StreamSplitButtonStyle? style; + + /// Linearly interpolate between two [StreamSplitButtonThemeData] objects. + static StreamSplitButtonThemeData? lerp( + StreamSplitButtonThemeData? a, + StreamSplitButtonThemeData? b, + double t, + ) => _$StreamSplitButtonThemeData.lerp(a, b, t); +} + +/// Visual styling properties for [StreamSplitButton]. +/// +/// A split button paints one shared surface behind two [StreamButton] halves. +/// That surface is derived from the same [StreamButtonTheme] entry the halves +/// use, so the two can never drift apart; [buttonStyle] adjusts both at once. +/// The remaining properties describe the divider between the halves. +/// +/// See also: +/// +/// * [StreamSplitButtonThemeData], which wraps this style for theming. +/// * [StreamSplitButton], which uses this styling. +/// * [StreamButtonThemeStyle], for available button style properties. +@themeGen +@immutable +class StreamSplitButtonStyle with _$StreamSplitButtonStyle { + /// Creates split button style properties. + const StreamSplitButtonStyle({ + this.buttonStyle, + this.separatorColor, + this.separatorThickness, + this.separatorHeight, + }); + + /// 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 + /// shared surface and the two halves, without affecting other + /// [StreamButton] instances in the tree. + /// + /// [StreamButtonThemeStyle.backgroundColor] and + /// [StreamButtonThemeStyle.borderColor] land on the shared surface — the + /// halves themselves are always painted transparent and borderless so the + /// surface reads as a single control. + /// + /// {@tool snippet} + /// + /// Give the split button a custom surface: + /// + /// ```dart + /// StreamSplitButtonStyle( + /// buttonStyle: StreamButtonThemeStyle.from( + /// backgroundColor: Colors.black12, + /// foregroundColor: Colors.white, + /// ), + /// ) + /// ``` + /// {@end-tool} + final StreamButtonThemeStyle? buttonStyle; + + /// The color of the divider between the two halves. + /// + /// Defaults to [StreamColorScheme.borderDefault], or + /// [StreamColorScheme.borderDisabled] while the whole control is disabled. + final WidgetStateProperty? separatorColor; + + /// The width of the divider between the two halves, in logical pixels. + /// + /// Defaults to 1. + final double? separatorThickness; + + /// The height of the divider between the two halves, in logical pixels. + /// + /// The divider is shorter than the control so it does not run into the + /// rounded ends. Defaults to the button size inset by [StreamSpacing.xxs] on + /// both ends — 24 for a [StreamButtonSize.small] split button. + final double? separatorHeight; + + /// Linearly interpolate between two [StreamSplitButtonStyle] objects. + static StreamSplitButtonStyle? lerp( + StreamSplitButtonStyle? a, + StreamSplitButtonStyle? b, + double t, + ) => _$StreamSplitButtonStyle.lerp(a, b, t); +} 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 new file mode 100644 index 00000000..106f7d67 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_split_button_theme.g.theme.dart @@ -0,0 +1,185 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_split_button_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamSplitButtonThemeData { + bool get canMerge => true; + + static StreamSplitButtonThemeData? lerp( + StreamSplitButtonThemeData? a, + StreamSplitButtonThemeData? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamSplitButtonThemeData( + style: StreamSplitButtonStyle.lerp(a.style, b.style, t), + ); + } + + StreamSplitButtonThemeData copyWith({StreamSplitButtonStyle? style}) { + final _this = (this as StreamSplitButtonThemeData); + + return StreamSplitButtonThemeData(style: style ?? _this.style); + } + + StreamSplitButtonThemeData merge(StreamSplitButtonThemeData? other) { + final _this = (this as StreamSplitButtonThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith(style: _this.style?.merge(other.style) ?? other.style); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamSplitButtonThemeData); + final _other = (other as StreamSplitButtonThemeData); + + return _other.style == _this.style; + } + + @override + int get hashCode { + final _this = (this as StreamSplitButtonThemeData); + + return Object.hash(runtimeType, _this.style); + } +} + +mixin _$StreamSplitButtonStyle { + bool get canMerge => true; + + static StreamSplitButtonStyle? lerp( + StreamSplitButtonStyle? a, + StreamSplitButtonStyle? b, + double t, + ) { + if (identical(a, b)) { + return a; + } + + if (a == null) { + return t == 1.0 ? b : null; + } + + if (b == null) { + return t == 0.0 ? a : null; + } + + return StreamSplitButtonStyle( + buttonStyle: StreamButtonThemeStyle.lerp(a.buttonStyle, b.buttonStyle, t), + separatorColor: WidgetStateProperty.lerp( + a.separatorColor, + b.separatorColor, + t, + Color.lerp, + ), + separatorThickness: lerpDouble$( + a.separatorThickness, + b.separatorThickness, + t, + ), + separatorHeight: lerpDouble$(a.separatorHeight, b.separatorHeight, t), + ); + } + + StreamSplitButtonStyle copyWith({ + StreamButtonThemeStyle? buttonStyle, + WidgetStateProperty? separatorColor, + double? separatorThickness, + double? separatorHeight, + }) { + final _this = (this as StreamSplitButtonStyle); + + return StreamSplitButtonStyle( + buttonStyle: buttonStyle ?? _this.buttonStyle, + separatorColor: separatorColor ?? _this.separatorColor, + separatorThickness: separatorThickness ?? _this.separatorThickness, + separatorHeight: separatorHeight ?? _this.separatorHeight, + ); + } + + StreamSplitButtonStyle merge(StreamSplitButtonStyle? other) { + final _this = (this as StreamSplitButtonStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + buttonStyle: + _this.buttonStyle?.merge(other.buttonStyle) ?? other.buttonStyle, + separatorColor: other.separatorColor, + separatorThickness: other.separatorThickness, + separatorHeight: other.separatorHeight, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamSplitButtonStyle); + final _other = (other as StreamSplitButtonStyle); + + return _other.buttonStyle == _this.buttonStyle && + _other.separatorColor == _this.separatorColor && + _other.separatorThickness == _this.separatorThickness && + _other.separatorHeight == _this.separatorHeight; + } + + @override + int get hashCode { + final _this = (this as StreamSplitButtonStyle); + + return Object.hash( + runtimeType, + _this.buttonStyle, + _this.separatorColor, + _this.separatorThickness, + _this.separatorHeight, + ); + } +} diff --git a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart index 0371732d..ae3b1757 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -36,6 +36,7 @@ import 'components/stream_sheet_header_theme.dart'; import 'components/stream_sheet_theme.dart'; import 'components/stream_skeleton_loading_theme.dart'; import 'components/stream_snackbar_theme.dart'; +import 'components/stream_split_button_theme.dart'; import 'components/stream_stepper_theme.dart'; import 'components/stream_switch_theme.dart'; import 'components/stream_text_input_theme.dart'; @@ -153,6 +154,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { StreamSheetThemeData? sheetTheme, StreamSkeletonLoadingThemeData? skeletonLoadingTheme, StreamSnackbarThemeData? snackbarTheme, + StreamSplitButtonThemeData? splitButtonTheme, StreamStepperThemeData? stepperTheme, StreamSwitchThemeData? switchTheme, }) { @@ -214,6 +216,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { sheetTheme ??= const StreamSheetThemeData(); skeletonLoadingTheme ??= const StreamSkeletonLoadingThemeData(); snackbarTheme ??= const StreamSnackbarThemeData(); + splitButtonTheme ??= const StreamSplitButtonThemeData(); stepperTheme ??= const StreamStepperThemeData(); switchTheme ??= const StreamSwitchThemeData(); @@ -263,6 +266,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { sheetTheme: sheetTheme, skeletonLoadingTheme: skeletonLoadingTheme, snackbarTheme: snackbarTheme, + splitButtonTheme: splitButtonTheme, stepperTheme: stepperTheme, switchTheme: switchTheme, ); @@ -326,6 +330,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { required this.sheetTheme, required this.skeletonLoadingTheme, required this.snackbarTheme, + required this.splitButtonTheme, required this.stepperTheme, required this.switchTheme, }); @@ -508,6 +513,9 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// The snackbar theme for this theme. final StreamSnackbarThemeData snackbarTheme; + /// The split button theme for this theme. + final StreamSplitButtonThemeData splitButtonTheme; + /// The stepper theme for this theme. final StreamStepperThemeData stepperTheme; @@ -580,6 +588,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { sheetTheme: sheetTheme, skeletonLoadingTheme: skeletonLoadingTheme, snackbarTheme: snackbarTheme, + splitButtonTheme: splitButtonTheme, stepperTheme: stepperTheme, switchTheme: switchTheme, ); diff --git a/packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart index ce7ab227..41dbcae2 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart @@ -63,6 +63,7 @@ mixin _$StreamTheme on ThemeExtension { StreamSheetThemeData? sheetTheme, StreamSkeletonLoadingThemeData? skeletonLoadingTheme, StreamSnackbarThemeData? snackbarTheme, + StreamSplitButtonThemeData? splitButtonTheme, StreamStepperThemeData? stepperTheme, StreamSwitchThemeData? switchTheme, }) { @@ -132,6 +133,7 @@ mixin _$StreamTheme on ThemeExtension { sheetTheme: sheetTheme ?? _this.sheetTheme, skeletonLoadingTheme: skeletonLoadingTheme ?? _this.skeletonLoadingTheme, snackbarTheme: snackbarTheme ?? _this.snackbarTheme, + splitButtonTheme: splitButtonTheme ?? _this.splitButtonTheme, stepperTheme: stepperTheme ?? _this.stepperTheme, switchTheme: switchTheme ?? _this.switchTheme, ); @@ -342,6 +344,11 @@ mixin _$StreamTheme on ThemeExtension { other.snackbarTheme, t, )!, + splitButtonTheme: StreamSplitButtonThemeData.lerp( + _this.splitButtonTheme, + other.splitButtonTheme, + t, + )!, stepperTheme: StreamStepperThemeData.lerp( _this.stepperTheme, other.stepperTheme, @@ -420,6 +427,7 @@ mixin _$StreamTheme on ThemeExtension { _other.sheetTheme == _this.sheetTheme && _other.skeletonLoadingTheme == _this.skeletonLoadingTheme && _other.snackbarTheme == _this.snackbarTheme && + _other.splitButtonTheme == _this.splitButtonTheme && _other.stepperTheme == _this.stepperTheme && _other.switchTheme == _this.switchTheme; } @@ -475,6 +483,7 @@ mixin _$StreamTheme on ThemeExtension { _this.sheetTheme, _this.skeletonLoadingTheme, _this.snackbarTheme, + _this.splitButtonTheme, _this.stepperTheme, _this.switchTheme, ]); diff --git a/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart b/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart index 802fb66b..d75227dd 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart @@ -34,6 +34,7 @@ import 'components/stream_sheet_header_theme.dart'; import 'components/stream_sheet_theme.dart'; import 'components/stream_skeleton_loading_theme.dart'; import 'components/stream_snackbar_theme.dart'; +import 'components/stream_split_button_theme.dart'; import 'components/stream_stepper_theme.dart'; import 'components/stream_switch_theme.dart'; import 'components/stream_text_input_theme.dart'; @@ -211,6 +212,9 @@ extension StreamThemeExtension on BuildContext { /// Returns the [StreamSnackbarThemeData] from the nearest ancestor. StreamSnackbarThemeData get streamSnackbarTheme => StreamSnackbarTheme.of(this); + /// Returns the [StreamSplitButtonThemeData] from the nearest ancestor. + StreamSplitButtonThemeData get streamSplitButtonTheme => StreamSplitButtonTheme.of(this); + /// Returns the [StreamStepperThemeData] from the nearest ancestor. StreamStepperThemeData get streamStepperTheme => StreamStepperTheme.of(this); 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 new file mode 100644 index 00000000..de88c64a --- /dev/null +++ b/packages/stream_core_flutter/test/components/buttons/stream_split_button_golden_test.dart @@ -0,0 +1,150 @@ +import 'dart:io'; + +import 'package:alchemist/alchemist.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/core.dart'; + +void main() { + // Without the real glyphs a caret-up golden is indistinguishable from a + // caret-down one, so this suite renders the shipped icon font rather than + // the test framework's placeholder boxes. + setUpAll(() async { + final loader = FontLoader('packages/stream_core_flutter/${StreamIconData.iconFontFamily}') + ..addFont(File('lib/fonts/stream_icons_font.otf').readAsBytes().then(ByteData.sublistView)); + await loader.load(); + }); + + group('StreamSplitButton Golden Tests', () { + goldenTest( + 'renders light theme matrix', + fileName: 'stream_split_button_light', + builder: _buildMatrix, + ); + + goldenTest( + 'renders dark theme matrix', + fileName: 'stream_split_button_dark', + builder: () => _buildMatrix(brightness: Brightness.dark), + ); + + goldenTest( + 'renders sizes', + fileName: 'stream_split_button_sizes', + builder: () => GoldenTestGroup( + columns: StreamButtonSize.values.length, + children: [ + for (final size in StreamButtonSize.values) + GoldenTestScenario( + name: size.name, + child: _buildInTheme( + _splitButton(style: .secondary, size: size), + ), + ), + ], + ), + ); + + goldenTest( + 'renders the pressed leading half per size', + fileName: 'stream_split_button_pressed', + // The highlight is the only place `size` shows up: the surface always + // hugs the halves' tap targets, so at rest every size looks the same. + whilePerforming: press(find.byIcon(StreamIconData.voiceFill)), + builder: () => GoldenTestGroup( + columns: StreamButtonSize.values.length, + children: [ + for (final size in StreamButtonSize.values) + GoldenTestScenario( + name: size.name, + child: _buildInTheme(_splitButton(style: .secondary, size: size)), + ), + ], + ), + ); + + goldenTest( + 'renders disabled halves', + fileName: 'stream_split_button_disabled', + builder: () => GoldenTestGroup( + columns: 3, + children: [ + GoldenTestScenario( + name: 'leading disabled', + child: _buildInTheme(_splitButton(style: .secondary, onPressed: null)), + ), + GoldenTestScenario( + name: 'trailing disabled', + child: _buildInTheme(_splitButton(style: .secondary, onTrailingPressed: null)), + ), + GoldenTestScenario( + name: 'both disabled', + child: _buildInTheme( + _splitButton(style: .secondary, onPressed: null, onTrailingPressed: null), + ), + ), + ], + ), + ); + }); +} + +GoldenTestGroup _buildMatrix({Brightness brightness = Brightness.light}) { + return GoldenTestGroup( + columns: StreamButtonType.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, + ), + ), + ], + ); +} + +StreamSplitButton _splitButton({ + StreamButtonStyle style = StreamButtonStyle.primary, + StreamButtonType type = StreamButtonType.solid, + StreamButtonSize size = StreamButtonSize.small, + VoidCallback? onPressed = _noop, + VoidCallback? onTrailingPressed = _noop, +}) { + return StreamSplitButton.icon( + icon: const Icon(StreamIconData.voiceFill), + trailingIcon: const Icon(StreamIconData.caretDown), + style: style, + type: type, + size: size, + onPressed: onPressed, + onTrailingPressed: onTrailingPressed, + ); +} + +void _noop() {} + +Widget _buildInTheme( + Widget splitButton, { + Brightness brightness = Brightness.light, +}) { + final streamTheme = StreamTheme(brightness: brightness); + return Theme( + data: ThemeData( + brightness: brightness, + extensions: [streamTheme], + ), + child: Builder( + builder: (context) => Material( + color: StreamTheme.of(context).colorScheme.backgroundApp, + child: Padding( + padding: const EdgeInsets.all(8), + child: splitButton, + ), + ), + ), + ); +} 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 new file mode 100644 index 00000000..5b591732 --- /dev/null +++ b/packages/stream_core_flutter/test/components/buttons/stream_split_button_test.dart @@ -0,0 +1,349 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/core.dart'; + +Widget _withStreamTheme(Widget child, {StreamTheme? streamTheme}) { + return MaterialApp( + theme: ThemeData(extensions: [streamTheme ?? StreamTheme()]), + home: Scaffold(body: Center(child: child)), + ); +} + +StreamSplitButton _splitButton({ + StreamButtonStyle style = StreamButtonStyle.primary, + StreamButtonType type = StreamButtonType.solid, + StreamButtonSize size = StreamButtonSize.small, + IconData trailingIcon = StreamIconData.caretDown, + VoidCallback? onPressed, + VoidCallback? onTrailingPressed, + String? tooltip, + String? trailingTooltip, + StreamSplitButtonStyle? themeStyle, +}) { + return StreamSplitButton.icon( + icon: const Icon(StreamIconData.voiceFill), + trailingIcon: Icon(trailingIcon), + style: style, + type: type, + size: size, + onPressed: onPressed, + onTrailingPressed: onTrailingPressed, + tooltip: tooltip, + trailingTooltip: trailingTooltip, + themeStyle: themeStyle, + ); +} + +/// The [ShapeDecoration] of the shared surface both halves sit on. +ShapeDecoration _surfaceOf(WidgetTester tester) { + final decorated = tester.widget( + find.descendant(of: find.byType(StreamSplitButton), matching: find.byType(DecoratedBox)).first, + ); + return decorated.decoration as ShapeDecoration; +} + +/// The resolved [ButtonStyle] of the half at [index] (0 leading, 1 trailing). +ButtonStyle _halfStyleOf(WidgetTester tester, int index) { + final button = tester.widget( + find.descendant(of: find.byType(StreamSplitButton), matching: find.byType(ElevatedButton)).at(index), + ); + return button.style!; +} + +void main() { + group('StreamSplitButton surface', () { + 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) { + await tester.pumpWidget( + _withStreamTheme( + Column( + children: [ + _splitButton(style: style, onPressed: () {}, onTrailingPressed: () {}), + StreamButton.icon(icon: const Icon(Icons.mic), style: style, onPressed: () {}), + ], + ), + ), + ); + + final reference = tester.widget( + find.descendant(of: find.byType(StreamButton).last, matching: find.byType(ElevatedButton)), + ); + + expect( + _surfaceOf(tester).color, + reference.style!.backgroundColor!.resolve({}), + reason: 'surface should match a $style StreamButton', + ); + } + }); + + testWidgets('follows a StreamButtonTheme override', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + streamTheme: StreamTheme( + buttonTheme: const StreamButtonThemeData( + primary: StreamButtonTypeStyle( + solid: StreamButtonThemeStyle(backgroundColor: WidgetStatePropertyAll(Color(0xFF00FF00))), + ), + ), + ), + _splitButton(onPressed: () {}, onTrailingPressed: () {}), + ), + ); + + expect(_surfaceOf(tester).color, const Color(0xFF00FF00)); + }); + + testWidgets('halves paint neither background nor border', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + _splitButton(type: StreamButtonType.outline, onPressed: () {}, onTrailingPressed: () {}), + ), + ); + + for (var index = 0; index < 2; index++) { + final style = _halfStyleOf(tester, index); + expect(style.backgroundColor!.resolve({})!.a, 0); + expect(style.side?.resolve({}), isNull); + expect(style.elevation!.resolve({}), 0); + } + }); + + testWidgets('outline draws a single border around the whole control', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + _splitButton(type: StreamButtonType.outline, onPressed: () {}, onTrailingPressed: () {}), + ), + ); + + final shape = _surfaceOf(tester).shape as OutlinedBorder; + expect(shape.side.style, BorderStyle.solid); + }); + + testWidgets('solid draws no border', (tester) async { + await tester.pumpWidget( + _withStreamTheme(_splitButton(onPressed: () {}, onTrailingPressed: () {})), + ); + + final shape = _surfaceOf(tester).shape as OutlinedBorder; + expect(shape.side.style, BorderStyle.none); + }); + + testWidgets('only takes the disabled surface once both halves are disabled', (tester) async { + final streamTheme = StreamTheme(); + final enabledColor = streamTheme.colorScheme.accentPrimary; + final disabledColor = streamTheme.colorScheme.backgroundDisabled; + + await tester.pumpWidget( + _withStreamTheme(streamTheme: streamTheme, _splitButton(onPressed: () {})), + ); + expect(_surfaceOf(tester).color, enabledColor); + + await tester.pumpWidget( + _withStreamTheme(streamTheme: streamTheme, _splitButton(onTrailingPressed: () {})), + ); + expect(_surfaceOf(tester).color, enabledColor); + + await tester.pumpWidget(_withStreamTheme(streamTheme: streamTheme, _splitButton())); + expect(_surfaceOf(tester).color, disabledColor); + }); + }); + + group('StreamSplitButton layout', () { + testWidgets('keeps a tap target per half whatever the button theme asks for', (tester) async { + // A theme that shrink-wraps every button must not shrink the halves + // below the platform tap target. + await tester.pumpWidget( + _withStreamTheme( + streamTheme: StreamTheme( + buttonTheme: StreamButtonThemeData.all( + StreamButtonTypeStyle.all( + StreamButtonThemeStyle.from(tapTargetSize: MaterialTapTargetSize.shrinkWrap), + ), + ), + ), + _splitButton(onPressed: () {}, onTrailingPressed: () {}), + ), + ); + + final halves = find.descendant(of: find.byType(StreamSplitButton), matching: find.byType(StreamButton)); + expect(tester.getSize(halves.at(0)), const Size(48, 48)); + expect(tester.getSize(halves.at(1)), const Size(48, 48)); + + final handle = tester.ensureSemantics(); + await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); + await expectLater(tester, meetsGuideline(iOSTapTargetGuideline)); + handle.dispose(); + }); + + testWidgets('separates the halves with a divider inset from the rounded ends', (tester) async { + final streamTheme = StreamTheme(); + await tester.pumpWidget( + _withStreamTheme( + streamTheme: streamTheme, + _splitButton(onPressed: () {}, onTrailingPressed: () {}), + ), + ); + + final divider = find.descendant(of: find.byType(StreamSplitButton), matching: find.byType(ColoredBox)); + expect(tester.widget(divider).color, streamTheme.colorScheme.borderDefault); + expect(tester.getSize(divider), const Size(1, 24)); + }); + + testWidgets('honours separator overrides', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + _splitButton( + onPressed: () {}, + onTrailingPressed: () {}, + themeStyle: const StreamSplitButtonStyle( + separatorColor: WidgetStatePropertyAll(Color(0xFFFF0000)), + separatorThickness: 2, + separatorHeight: 10, + ), + ), + ), + ); + + final divider = find.descendant(of: find.byType(StreamSplitButton), matching: find.byType(ColoredBox)); + expect(tester.widget(divider).color, const Color(0xFFFF0000)); + expect(tester.getSize(divider), const Size(2, 10)); + }); + }); + + group('StreamSplitButton icons', () { + testWidgets('renders the leading icon before the trailing one', (tester) async { + await tester.pumpWidget( + _withStreamTheme(_splitButton(onPressed: () {}, onTrailingPressed: () {})), + ); + + final leading = tester.getCenter(find.byIcon(StreamIconData.voiceFill)); + final trailing = tester.getCenter(find.byIcon(StreamIconData.caretDown)); + expect(leading.dx, lessThan(trailing.dx)); + }); + + testWidgets('takes whichever caret the trailing half should show', (tester) async { + // The half can open a menu above or below, so the caret is the caller's + // to pick rather than something the component hard-codes. + await tester.pumpWidget( + _withStreamTheme( + _splitButton(trailingIcon: StreamIconData.caretUp, onPressed: () {}, onTrailingPressed: () {}), + ), + ); + + expect(find.byIcon(StreamIconData.caretUp), findsOneWidget); + expect(find.byIcon(StreamIconData.caretDown), findsNothing); + }); + + testWidgets('mirrors the halves in RTL', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + Directionality( + textDirection: TextDirection.rtl, + child: _splitButton(onPressed: () {}, onTrailingPressed: () {}), + ), + ), + ); + + final leading = tester.getCenter(find.byIcon(StreamIconData.voiceFill)); + final trailing = tester.getCenter(find.byIcon(StreamIconData.caretDown)); + expect(leading.dx, greaterThan(trailing.dx)); + }); + }); + + group('StreamSplitButton interaction', () { + testWidgets('each half fires only its own callback', (tester) async { + var pressed = 0; + var trailingPressed = 0; + + await tester.pumpWidget( + _withStreamTheme( + _splitButton( + onPressed: () => pressed++, + onTrailingPressed: () => trailingPressed++, + tooltip: 'Mute', + trailingTooltip: 'Audio settings', + ), + ), + ); + + await tester.tap(find.byTooltip('Mute')); + await tester.pumpAndSettle(); + expect((pressed, trailingPressed), (1, 0)); + + await tester.tap(find.byTooltip('Audio settings')); + await tester.pumpAndSettle(); + expect((pressed, trailingPressed), (1, 1)); + }); + + testWidgets('a disabled half stays inert while the other still works', (tester) async { + var trailingPressed = 0; + + await tester.pumpWidget( + _withStreamTheme( + _splitButton( + onTrailingPressed: () => trailingPressed++, + tooltip: 'Mute', + trailingTooltip: 'Audio settings', + ), + ), + ); + + await tester.tap(find.byTooltip('Mute')); + await tester.tap(find.byTooltip('Audio settings')); + await tester.pumpAndSettle(); + + expect(trailingPressed, 1); + }); + + testWidgets('exposes one accessibility node per half', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + _withStreamTheme( + _splitButton( + onPressed: () {}, + onTrailingPressed: () {}, + tooltip: 'Mute', + trailingTooltip: 'Audio settings', + ), + ), + ); + + for (final tooltip in ['Mute', 'Audio settings']) { + expect( + tester.getSemantics(find.byTooltip(tooltip)), + isSemantics( + tooltip: tooltip, + isButton: true, + isEnabled: true, + hasEnabledState: true, + hasTapAction: true, + ), + ); + } + + handle.dispose(); + }); + }); + + group('StreamSplitButton factory', () { + testWidgets('defers to a StreamComponentFactory builder', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + StreamComponentFactory( + builders: StreamComponentBuilders( + splitButton: (context, props) => Text('custom ${props.tooltip}'), + ), + child: _splitButton(onPressed: () {}, onTrailingPressed: () {}, tooltip: 'Mute'), + ), + ), + ); + + expect(find.text('custom Mute'), findsOneWidget); + expect(find.byType(DefaultStreamSplitButton), findsNothing); + }); + }); +} From bfd0d0cc16a737e5a7415f96dc7d4c4bcfd74f0c Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 18 Aug 2026 15:44:28 +0200 Subject: [PATCH 2/5] add regular split button --- .../lib/components/buttons/split_button.dart | 145 +++++++++++++++++- packages/stream_core_flutter/CHANGELOG.md | 10 +- packages/stream_core_flutter/lib/core.dart | 2 - .../buttons/stream_split_button.dart | 116 +++++++++++--- .../components/stream_split_button_theme.dart | 4 + packages/stream_core_flutter/lib/video.dart | 10 ++ packages/stream_core_flutter/pubspec.yaml | 1 + .../stream_split_button_golden_test.dart | 51 +++++- .../buttons/stream_split_button_test.dart | 126 ++++++++++++++- 9 files changed, 429 insertions(+), 36 deletions(-) create mode 100644 packages/stream_core_flutter/lib/video.dart 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 56022990..5ba7b3e1 100644 --- a/apps/design_system_gallery/lib/components/buttons/split_button.dart +++ b/apps/design_system_gallery/lib/components/buttons/split_button.dart @@ -1,5 +1,7 @@ +// ignore_for_file: experimental_member_use + import 'package:flutter/material.dart'; -import 'package:stream_core_flutter/core.dart'; +import 'package:stream_core_flutter/video.dart'; import 'package:widgetbook/widgetbook.dart'; import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @@ -105,6 +107,7 @@ Widget buildStreamSplitButtonShowcase(BuildContext context) { _SizeScaleSection(), _DisabledSection(), _CallControlSection(), + _DevicePickerSection(), ], ), ), @@ -241,8 +244,8 @@ class _CallControlSectionState extends State<_CallControlSection> { return _ExampleCard( title: 'Call control', description: - 'A microphone toggle paired with a caret that opens the audio settings, ' - 'badged when the device fails.', + 'The compact form from the design: a microphone toggle, a caret that opens the audio ' + 'settings, and an error badge for when the device fails.', child: Center( child: _MaybeBadged( showErrorBadge: true, @@ -262,6 +265,142 @@ class _CallControlSectionState extends State<_CallControlSection> { } } +class _DevicePickerSection extends StatefulWidget { + const _DevicePickerSection(); + + @override + State<_DevicePickerSection> createState() => _DevicePickerSectionState(); +} + +class _DevicePickerSectionState extends State<_DevicePickerSection> { + static const _microphones = ['MacBook Pro Microphone (Built-in)', 'ZoomAudioDevice (Virtual)']; + static const _cameras = ['MacBook Pro Camera (Built-in)', 'ZoomVideoDevice (Virtual)']; + + var _microphone = _microphones.first; + var _camera = _cameras.first; + String? _openPicker; + + void _togglePicker(String picker) { + setState(() => _openPicker = _openPicker == picker ? null : picker); + } + + @override + Widget build(BuildContext context) { + final icons = context.streamIcons; + final spacing = context.streamSpacing; + + return _ExampleCard( + title: 'Device picker', + description: + 'The call-control shape this component was drawn for: a device toggle labelled with ' + 'whatever the OS reports, and a caret that flips while its picker is open.', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: [ + if (_openPicker case final picker?) + _PickerMenu( + maxWidth: 360, + title: picker == 'microphone' ? 'Microphone' : 'Camera', + options: picker == 'microphone' ? _microphones : _cameras, + selected: picker == 'microphone' ? _microphone : _camera, + onSelected: (option) => setState(() { + if (picker == 'microphone') { + _microphone = option; + } else { + _camera = option; + } + _openPicker = null; + }), + ), + Row( + spacing: spacing.sm, + children: [ + Flexible( + child: StreamSplitButton( + icon: Icon(icons.voiceFill), + trailingIcon: Icon(_openPicker == 'microphone' ? icons.caretUp : icons.caretDown), + style: StreamButtonStyle.secondary, + trailingTooltip: 'Select a microphone', + onPressed: () {}, + onTrailingPressed: () => _togglePicker('microphone'), + child: Text(_microphone, overflow: TextOverflow.ellipsis, maxLines: 1), + ), + ), + Flexible( + child: StreamSplitButton( + icon: Icon(icons.videoFill), + trailingIcon: Icon(_openPicker == 'camera' ? icons.caretUp : icons.caretDown), + style: StreamButtonStyle.secondary, + type: StreamButtonType.outline, + trailingTooltip: 'Select a camera', + onPressed: () {}, + onTrailingPressed: () => _togglePicker('camera'), + child: Text(_camera, overflow: TextOverflow.ellipsis, maxLines: 1), + ), + ), + ], + ), + ], + ), + ); + } +} + +/// A stand-in for the menu a split button's trailing half opens. +class _PickerMenu extends StatelessWidget { + const _PickerMenu({ + required this.maxWidth, + required this.title, + required this.options, + required this.selected, + required this.onSelected, + }); + + final double maxWidth; + final String title; + final List options; + final String selected; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final boxShadow = context.streamBoxShadow; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + return Container( + constraints: BoxConstraints(maxWidth: maxWidth), + padding: EdgeInsets.symmetric(vertical: spacing.sm), + decoration: BoxDecoration( + color: colorScheme.backgroundSurfaceCard, + borderRadius: BorderRadius.all(radius.lg), + boxShadow: boxShadow.elevation2, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.md, vertical: spacing.xxs), + child: Text(title, style: textTheme.captionEmphasis.copyWith(color: colorScheme.textTertiary)), + ), + for (final option in options) + StreamListTile( + title: Text(option), + leading: StreamCheckbox.circular( + value: option == selected, + onChanged: (_) => onSelected(option), + ), + onTap: () => onSelected(option), + ), + ], + ), + ); + } +} + // ============================================================================= // Shared Widgets // ============================================================================= diff --git a/packages/stream_core_flutter/CHANGELOG.md b/packages/stream_core_flutter/CHANGELOG.md index 39940481..23a5d1e1 100644 --- a/packages/stream_core_flutter/CHANGELOG.md +++ b/packages/stream_core_flutter/CHANGELOG.md @@ -3,15 +3,7 @@ ### ✨ Features - 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`, a pair of icon buttons sharing one surface with a - divider between them — a primary action alongside a caret that opens its - options. Create it with `StreamSplitButton.icon`, configure both icons (so - the caret can point up or down), and style it with the same - `StreamButtonStyle` / `StreamButtonType` / `StreamButtonSize` values a - `StreamButton` takes. The surface resolves from the same `StreamButtonTheme` - entry the halves use, so the two cannot drift apart; an `outline` split - button draws a single border around the whole control. Customize the divider - through `StreamSplitButtonTheme`. +- Added `StreamSplitButton`. - 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/core.dart b/packages/stream_core_flutter/lib/core.dart index 57d5d165..fa8e0dee 100644 --- a/packages/stream_core_flutter/lib/core.dart +++ b/packages/stream_core_flutter/lib/core.dart @@ -28,7 +28,6 @@ export 'src/components/badge/stream_online_indicator.dart'; export 'src/components/badge/stream_retry_badge.dart'; export 'src/components/buttons/stream_button.dart'; export 'src/components/buttons/stream_emoji_button.dart'; -export 'src/components/buttons/stream_split_button.dart'; export 'src/components/common/stream_checkbox.dart'; export 'src/components/common/stream_flex.dart'; export 'src/components/common/stream_intrinsic_flex.dart'; @@ -87,7 +86,6 @@ export 'src/theme/components/stream_sheet_header_theme.dart'; export 'src/theme/components/stream_sheet_theme.dart'; export 'src/theme/components/stream_skeleton_loading_theme.dart'; export 'src/theme/components/stream_snackbar_theme.dart'; -export 'src/theme/components/stream_split_button_theme.dart'; export 'src/theme/components/stream_stepper_theme.dart'; export 'src/theme/components/stream_switch_theme.dart'; export 'src/theme/components/stream_text_input_theme.dart'; 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 08d01d8d..cd55fe97 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 @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:meta/meta.dart'; import '../../factory/stream_component_factory.dart'; import '../../theme/components/stream_button_theme.dart'; @@ -14,8 +15,12 @@ import 'stream_button.dart'; /// /// A split button pairs a primary action with a secondary one — most often a /// caret that opens the options for that action. Both halves are -/// [StreamButton.icon] instances painted on a single background, so the -/// control reads as one pill rather than two adjacent buttons. +/// [StreamButton]s painted on a single background, so the control reads as one +/// pill rather than two adjacent buttons. +/// +/// The primary half takes either a [child] with an optional leading icon +/// (the default constructor) or an icon on its own ([StreamSplitButton.icon]). +/// The trailing half is always icon-only. /// /// The surface is resolved from the same [StreamButtonTheme] entry the halves /// use, which is what keeps the two from drifting apart. For @@ -44,15 +49,17 @@ import 'stream_button.dart'; /// /// {@tool snippet} /// -/// Flip the caret while the menu it opens is showing: +/// A labelled action whose caret flips while the menu it opens is showing: /// /// ```dart -/// StreamSplitButton.icon( +/// StreamSplitButton( /// type: StreamButtonType.outline, /// icon: const Icon(Icons.share), /// trailingIcon: Icon(isMenuOpen ? icons.caretUp : icons.caretDown), +/// trailingTooltip: 'More share options', /// onPressed: () => share(), /// onTrailingPressed: () => toggleMenu(), +/// child: const Text('Share'), /// ) /// ``` /// {@end-tool} @@ -61,7 +68,45 @@ import 'stream_button.dart'; /// /// * [StreamButton], the button each half is built from. /// * [StreamSplitButtonTheme], for customizing split button appearance. +@experimental class StreamSplitButton extends StatelessWidget { + /// Creates a split button whose primary half displays [child], optionally + /// preceded by [icon]. + /// + /// The trailing half stays icon-only: [trailingIcon] is typically a caret + /// pointing at whatever that half opens. + /// + /// The primary half takes its accessibility label from [child], so there is + /// no tooltip for it; the trailing half has [trailingTooltip]. + /// + /// A half with a null callback is disabled; the control as a whole only + /// takes on its disabled surface once both halves are. + @experimental + StreamSplitButton({ + super.key, + required Widget child, + required Widget trailingIcon, + Widget? icon, + VoidCallback? onPressed, + VoidCallback? onTrailingPressed, + StreamButtonStyle style = .primary, + StreamButtonType type = .solid, + StreamButtonSize size = .small, + String? trailingTooltip, + StreamSplitButtonStyle? themeStyle, + }) : props = .new( + child: child, + icon: icon, + trailingIcon: trailingIcon, + onPressed: onPressed, + onTrailingPressed: onTrailingPressed, + style: style, + type: type, + size: size, + trailingTooltip: trailingTooltip, + themeStyle: themeStyle, + ); + /// Creates a split button with an icon in each half. /// /// [icon] labels the primary half and [trailingIcon] the secondary one. @@ -71,6 +116,7 @@ class StreamSplitButton extends StatelessWidget { /// /// A half with a null callback is disabled; the control as a whole only /// takes on its disabled surface once both halves are. + @experimental StreamSplitButton.icon({ super.key, required Widget icon, @@ -119,8 +165,9 @@ class StreamSplitButton extends StatelessWidget { class StreamSplitButtonProps { /// Creates properties for a split button. const StreamSplitButtonProps({ - required this.icon, required this.trailingIcon, + this.child, + this.icon, this.onPressed, this.onTrailingPressed, this.style = .primary, @@ -129,10 +176,18 @@ class StreamSplitButtonProps { this.tooltip, this.trailingTooltip, this.themeStyle, - }); + }) : assert(child != null || icon != null, 'A primary half with no child needs an icon'); + + /// The main content widget displayed in the primary (leading) half. + /// + /// When null, that half renders as an icon-only button using [icon] as its + /// sole icon (see [StreamSplitButton.icon]). + final Widget? child; - /// The icon rendered in the primary (leading) half. - final Widget icon; + /// The icon rendered in the primary (leading) half, before [child]. + /// + /// When [child] is null, this is the sole icon that half renders. + final Widget? icon; /// The icon rendered in the secondary (trailing) half. /// @@ -163,12 +218,15 @@ class StreamSplitButtonProps { /// The size of each half. /// /// Sets the painted area of a half — the surface it highlights on hover and - /// press. Each half keeps an accessible tap target regardless of this value. + /// press, and the height of a half carrying a [child]. Each half keeps an + /// accessible tap target regardless of this value. final StreamButtonSize size; /// Text shown in a [Tooltip] on hover / long-press of the primary half, and /// used as its accessibility label. /// + /// Only honoured while [child] is null; a primary half with a [child] + /// derives its label from that child. /// When null, that half has no tooltip. final String? tooltip; @@ -187,8 +245,8 @@ class StreamSplitButtonProps { /// Default implementation of [StreamSplitButton]. /// -/// Renders a [Row] of two [StreamButton.icon] halves over a shared surface, -/// with a divider between them. +/// Renders a [Row] of two [StreamButton] halves over a shared surface, with a +/// divider between them. /// /// See also: /// @@ -234,6 +292,29 @@ class DefaultStreamSplitButton extends StatelessWidget { elevation: const WidgetStatePropertyAll(0), ); + final leadingHalf = switch (props.child) { + final child? => StreamButton( + iconLeft: props.icon, + onPressed: props.onPressed, + style: props.style, + type: props.type, + // For the regular `StreamButton` we always use large, otherwise the padding is weird. + size: .large, + themeStyle: halfStyle, + child: child, + ), + // The assert on the props guarantees an icon once there is no child. + _ => StreamButton.icon( + icon: props.icon!, + onPressed: props.onPressed, + style: props.style, + type: props.type, + size: props.size, + tooltip: props.tooltip, + themeStyle: halfStyle, + ), + }; + return DecoratedBox( decoration: ShapeDecoration( color: buttonStyle.backgroundColor?.resolve(states), @@ -245,15 +326,10 @@ class DefaultStreamSplitButton extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - StreamButton.icon( - icon: props.icon, - onPressed: props.onPressed, - style: props.style, - type: props.type, - size: props.size, - tooltip: props.tooltip, - themeStyle: halfStyle, - ), + // A label can outgrow the space on offer; an icon never does, and + // staying inflexible keeps the icon-only variant usable in an + // unbounded row. + if (props.child != null) Flexible(child: leadingHalf) else leadingHalf, SizedBox( width: effectiveSeparatorThickness, height: effectiveSeparatorHeight, 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 b98ebf6b..ac10945f 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 @@ -1,4 +1,5 @@ import 'package:flutter/widgets.dart'; +import 'package:meta/meta.dart'; import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; import '../stream_theme.dart'; @@ -37,6 +38,7 @@ part 'stream_split_button_theme.g.theme.dart'; /// /// * [StreamSplitButtonThemeData], which describes the split button theme. /// * [StreamSplitButton], the widget affected by this theme. +@experimental class StreamSplitButtonTheme extends InheritedTheme { /// Creates a split button theme that controls descendant split buttons. const StreamSplitButtonTheme({ @@ -88,6 +90,7 @@ class StreamSplitButtonTheme extends InheritedTheme { /// * [StreamSplitButton], the widget that uses this theme data. @themeGen @immutable +@experimental class StreamSplitButtonThemeData with _$StreamSplitButtonThemeData { /// Creates split button theme data with optional style overrides. const StreamSplitButtonThemeData({this.style}); @@ -117,6 +120,7 @@ class StreamSplitButtonThemeData with _$StreamSplitButtonThemeData { /// * [StreamButtonThemeStyle], for available button style properties. @themeGen @immutable +@experimental class StreamSplitButtonStyle with _$StreamSplitButtonStyle { /// Creates split button style properties. const StreamSplitButtonStyle({ diff --git a/packages/stream_core_flutter/lib/video.dart b/packages/stream_core_flutter/lib/video.dart new file mode 100644 index 00000000..af0e7078 --- /dev/null +++ b/packages/stream_core_flutter/lib/video.dart @@ -0,0 +1,10 @@ +@experimental +library; + +import 'package:meta/meta.dart'; + +export 'core.dart'; + +// Move to SplitButton to core when stable +export 'src/components/buttons/stream_split_button.dart'; +export 'src/theme/components/stream_split_button_theme.dart'; diff --git a/packages/stream_core_flutter/pubspec.yaml b/packages/stream_core_flutter/pubspec.yaml index 968e8990..6cead773 100644 --- a/packages/stream_core_flutter/pubspec.yaml +++ b/packages/stream_core_flutter/pubspec.yaml @@ -16,6 +16,7 @@ dependencies: flutter_svg: ^2.2.3 markdown: ^7.3.0 material_color_utilities: ">=0.11.0 <0.14.0" + meta: ^1.15.0 path: ^1.9.0 path_provider: ^2.1.5 shimmer: ^3.0.0 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 de88c64a..be8faf27 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 @@ -1,10 +1,12 @@ +// ignore_for_file: avoid_redundant_argument_values + import 'dart:io'; import 'package:alchemist/alchemist.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_core_flutter/core.dart'; +import 'package:stream_core_flutter/video.dart'; void main() { // Without the real glyphs a caret-up golden is indistinguishable from a @@ -46,6 +48,33 @@ void main() { ), ); + goldenTest( + 'renders a labelled primary half', + fileName: 'stream_split_button_label', + builder: () => GoldenTestGroup( + columns: 2, + scenarioConstraints: const BoxConstraints(maxWidth: 260), + children: [ + GoldenTestScenario( + name: 'label and icon', + child: _buildInTheme(_labelledSplitButton()), + ), + GoldenTestScenario( + name: 'label only', + child: _buildInTheme(_labelledSplitButton(icon: null)), + ), + GoldenTestScenario( + name: 'truncated label', + child: _buildInTheme(_labelledSplitButton(maxWidth: 110)), + ), + GoldenTestScenario( + name: 'outline', + child: _buildInTheme(_labelledSplitButton(type: .outline)), + ), + ], + ), + ); + goldenTest( 'renders the pressed leading half per size', fileName: 'stream_split_button_pressed', @@ -125,6 +154,26 @@ StreamSplitButton _splitButton({ ); } +StreamSplitButton _labelledSplitButton({ + StreamButtonType type = StreamButtonType.solid, + Widget? icon = const Icon(StreamIconData.voiceFill), + double maxWidth = double.infinity, +}) { + return StreamSplitButton( + icon: icon, + trailingIcon: const Icon(StreamIconData.caretDown), + style: StreamButtonStyle.secondary, + type: type, + size: StreamButtonSize.small, + onPressed: _noop, + onTrailingPressed: _noop, + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: const Text('MacBook Pro Microphone', overflow: TextOverflow.ellipsis), + ), + ); +} + void _noop() {} Widget _buildInTheme( 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 5b591732..f58414c0 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 @@ -1,6 +1,8 @@ +// ignore_for_file: avoid_redundant_argument_values + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_core_flutter/core.dart'; +import 'package:stream_core_flutter/video.dart'; Widget _withStreamTheme(Widget child, {StreamTheme? streamTheme}) { return MaterialApp( @@ -50,6 +52,24 @@ ButtonStyle _halfStyleOf(WidgetTester tester, int index) { return button.style!; } +StreamSplitButton _labelledSplitButton({ + Widget? icon = const Icon(StreamIconData.voiceFill), + VoidCallback? onPressed, + VoidCallback? onTrailingPressed, + String? trailingTooltip, +}) { + return StreamSplitButton( + icon: icon, + trailingIcon: const Icon(StreamIconData.caretDown), + style: StreamButtonStyle.secondary, + size: StreamButtonSize.small, + onPressed: onPressed, + onTrailingPressed: onTrailingPressed, + trailingTooltip: trailingTooltip, + child: const Text('MacBook Pro Microphone', overflow: TextOverflow.ellipsis), + ); +} + void main() { group('StreamSplitButton surface', () { testWidgets('paints the background a StreamButton of the same variant would', (tester) async { @@ -213,6 +233,110 @@ void main() { }); }); + group('StreamSplitButton label', () { + testWidgets('renders the child between the leading icon and the divider', (tester) async { + await tester.pumpWidget( + _withStreamTheme(_labelledSplitButton(onPressed: () {}, onTrailingPressed: () {})), + ); + + final icon = tester.getCenter(find.byIcon(StreamIconData.voiceFill)); + final label = tester.getCenter(find.text('MacBook Pro Microphone')); + final caret = tester.getCenter(find.byIcon(StreamIconData.caretDown)); + expect(icon.dx, lessThan(label.dx)); + expect(label.dx, lessThan(caret.dx)); + }); + + testWidgets('renders without a leading icon', (tester) async { + await tester.pumpWidget( + _withStreamTheme(_labelledSplitButton(icon: null, onPressed: () {}, onTrailingPressed: () {})), + ); + + expect(find.byIcon(StreamIconData.voiceFill), findsNothing); + expect(find.text('MacBook Pro Microphone'), findsOneWidget); + }); + + testWidgets('takes its accessibility label from the child', (tester) async { + final handle = tester.ensureSemantics(); + + await tester.pumpWidget( + _withStreamTheme( + _labelledSplitButton(onPressed: () {}, onTrailingPressed: () {}, trailingTooltip: 'Audio settings'), + ), + ); + + expect( + tester.getSemantics(find.byType(StreamButton).first), + isSemantics( + label: 'MacBook Pro Microphone', + isButton: true, + isEnabled: true, + hasEnabledState: true, + hasTapAction: true, + ), + ); + + handle.dispose(); + }); + + testWidgets('hugs its content when there is room', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: _labelledSplitButton(onPressed: () {}, onTrailingPressed: () {}), + ), + ), + ); + + expect(tester.getSize(find.byType(StreamSplitButton)).width, lessThan(600)); + }); + + testWidgets('fills the width it is given', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + SizedBox( + width: 600, + child: _labelledSplitButton(onPressed: () {}, onTrailingPressed: () {}), + ), + ), + ); + + expect(tester.getSize(find.byType(StreamSplitButton)).width, 600); + }); + + testWidgets('gives up width to the label rather than overflowing', (tester) async { + // A device picker names whatever the OS reports, so the label has to + // truncate inside the space on offer instead of blowing out the row. + await tester.pumpWidget( + _withStreamTheme( + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200), + child: _labelledSplitButton(onPressed: () {}, onTrailingPressed: () {}), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(tester.getSize(find.byType(StreamSplitButton)).width, 200); + // The trailing half never gives up its tap target to the label. + expect(tester.getSize(find.byType(StreamButton).last), const Size(48, 48)); + }); + + testWidgets('lays out in an unbounded row', (tester) async { + await tester.pumpWidget( + _withStreamTheme( + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: _labelledSplitButton(onPressed: () {}, onTrailingPressed: () {}), + ), + ), + ); + + expect(tester.takeException(), isNull); + expect(find.text('MacBook Pro Microphone'), findsOneWidget); + }); + }); + group('StreamSplitButton icons', () { testWidgets('renders the leading icon before the trailing one', (tester) async { await tester.pumpWidget( From 93e4849672a50749c867dd0a79cfd9837710f2cd Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 18 Aug 2026 17:15:59 +0200 Subject: [PATCH 3/5] remove regular split button --- .../lib/components/buttons/split_button.dart | 137 ------------------ .../stream_core_flutter/check_barrels.yaml | 2 + .../buttons/stream_split_button.dart | 113 +++------------ .../stream_split_button_golden_test.dart | 49 ------- .../buttons/stream_split_button_test.dart | 124 ---------------- scripts/check_barrels.dart | 4 + 6 files changed, 26 insertions(+), 403 deletions(-) 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 5ba7b3e1..7250df63 100644 --- a/apps/design_system_gallery/lib/components/buttons/split_button.dart +++ b/apps/design_system_gallery/lib/components/buttons/split_button.dart @@ -107,7 +107,6 @@ Widget buildStreamSplitButtonShowcase(BuildContext context) { _SizeScaleSection(), _DisabledSection(), _CallControlSection(), - _DevicePickerSection(), ], ), ), @@ -265,142 +264,6 @@ class _CallControlSectionState extends State<_CallControlSection> { } } -class _DevicePickerSection extends StatefulWidget { - const _DevicePickerSection(); - - @override - State<_DevicePickerSection> createState() => _DevicePickerSectionState(); -} - -class _DevicePickerSectionState extends State<_DevicePickerSection> { - static const _microphones = ['MacBook Pro Microphone (Built-in)', 'ZoomAudioDevice (Virtual)']; - static const _cameras = ['MacBook Pro Camera (Built-in)', 'ZoomVideoDevice (Virtual)']; - - var _microphone = _microphones.first; - var _camera = _cameras.first; - String? _openPicker; - - void _togglePicker(String picker) { - setState(() => _openPicker = _openPicker == picker ? null : picker); - } - - @override - Widget build(BuildContext context) { - final icons = context.streamIcons; - final spacing = context.streamSpacing; - - return _ExampleCard( - title: 'Device picker', - description: - 'The call-control shape this component was drawn for: a device toggle labelled with ' - 'whatever the OS reports, and a caret that flips while its picker is open.', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: spacing.md, - children: [ - if (_openPicker case final picker?) - _PickerMenu( - maxWidth: 360, - title: picker == 'microphone' ? 'Microphone' : 'Camera', - options: picker == 'microphone' ? _microphones : _cameras, - selected: picker == 'microphone' ? _microphone : _camera, - onSelected: (option) => setState(() { - if (picker == 'microphone') { - _microphone = option; - } else { - _camera = option; - } - _openPicker = null; - }), - ), - Row( - spacing: spacing.sm, - children: [ - Flexible( - child: StreamSplitButton( - icon: Icon(icons.voiceFill), - trailingIcon: Icon(_openPicker == 'microphone' ? icons.caretUp : icons.caretDown), - style: StreamButtonStyle.secondary, - trailingTooltip: 'Select a microphone', - onPressed: () {}, - onTrailingPressed: () => _togglePicker('microphone'), - child: Text(_microphone, overflow: TextOverflow.ellipsis, maxLines: 1), - ), - ), - Flexible( - child: StreamSplitButton( - icon: Icon(icons.videoFill), - trailingIcon: Icon(_openPicker == 'camera' ? icons.caretUp : icons.caretDown), - style: StreamButtonStyle.secondary, - type: StreamButtonType.outline, - trailingTooltip: 'Select a camera', - onPressed: () {}, - onTrailingPressed: () => _togglePicker('camera'), - child: Text(_camera, overflow: TextOverflow.ellipsis, maxLines: 1), - ), - ), - ], - ), - ], - ), - ); - } -} - -/// A stand-in for the menu a split button's trailing half opens. -class _PickerMenu extends StatelessWidget { - const _PickerMenu({ - required this.maxWidth, - required this.title, - required this.options, - required this.selected, - required this.onSelected, - }); - - final double maxWidth; - final String title; - final List options; - final String selected; - final ValueChanged onSelected; - - @override - Widget build(BuildContext context) { - final colorScheme = context.streamColorScheme; - final textTheme = context.streamTextTheme; - final boxShadow = context.streamBoxShadow; - final radius = context.streamRadius; - final spacing = context.streamSpacing; - - return Container( - constraints: BoxConstraints(maxWidth: maxWidth), - padding: EdgeInsets.symmetric(vertical: spacing.sm), - decoration: BoxDecoration( - color: colorScheme.backgroundSurfaceCard, - borderRadius: BorderRadius.all(radius.lg), - boxShadow: boxShadow.elevation2, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: spacing.md, vertical: spacing.xxs), - child: Text(title, style: textTheme.captionEmphasis.copyWith(color: colorScheme.textTertiary)), - ), - for (final option in options) - StreamListTile( - title: Text(option), - leading: StreamCheckbox.circular( - value: option == selected, - onChanged: (_) => onSelected(option), - ), - onTap: () => onSelected(option), - ), - ], - ), - ); - } -} - // ============================================================================= // Shared Widgets // ============================================================================= diff --git a/packages/stream_core_flutter/check_barrels.yaml b/packages/stream_core_flutter/check_barrels.yaml index 6b40504f..0a7d34f9 100644 --- a/packages/stream_core_flutter/check_barrels.yaml +++ b/packages/stream_core_flutter/check_barrels.yaml @@ -14,6 +14,7 @@ package_name: stream_core_flutter barrels: - lib/chat.dart - lib/core.dart + - lib/video.dart # Public-facing libraries that files under `source_root` must never import. # Typically: the barrels themselves, plus any deprecated entry points. @@ -21,6 +22,7 @@ forbidden_src_imports: - lib/chat.dart - lib/core.dart - lib/stream_core_flutter.dart + - lib/video.dart # Full paths (relative to the package root) of directories whose files are # internal to the package. Anything under these is excluded from coverage. 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 cd55fe97..64ed6fc7 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 @@ -15,12 +15,8 @@ import 'stream_button.dart'; /// /// A split button pairs a primary action with a secondary one — most often a /// caret that opens the options for that action. Both halves are -/// [StreamButton]s painted on a single background, so the control reads as one -/// pill rather than two adjacent buttons. -/// -/// The primary half takes either a [child] with an optional leading icon -/// (the default constructor) or an icon on its own ([StreamSplitButton.icon]). -/// The trailing half is always icon-only. +/// [StreamButton.icon]s painted on a single background, so the control reads +/// as one pill rather than two adjacent buttons. /// /// The surface is resolved from the same [StreamButtonTheme] entry the halves /// use, which is what keeps the two from drifting apart. For @@ -49,17 +45,15 @@ import 'stream_button.dart'; /// /// {@tool snippet} /// -/// A labelled action whose caret flips while the menu it opens is showing: +/// Flip the caret while the menu it opens is showing: /// /// ```dart -/// StreamSplitButton( +/// StreamSplitButton.icon( /// type: StreamButtonType.outline, /// icon: const Icon(Icons.share), /// trailingIcon: Icon(isMenuOpen ? icons.caretUp : icons.caretDown), -/// trailingTooltip: 'More share options', /// onPressed: () => share(), /// onTrailingPressed: () => toggleMenu(), -/// child: const Text('Share'), /// ) /// ``` /// {@end-tool} @@ -70,43 +64,6 @@ import 'stream_button.dart'; /// * [StreamSplitButtonTheme], for customizing split button appearance. @experimental class StreamSplitButton extends StatelessWidget { - /// Creates a split button whose primary half displays [child], optionally - /// preceded by [icon]. - /// - /// The trailing half stays icon-only: [trailingIcon] is typically a caret - /// pointing at whatever that half opens. - /// - /// The primary half takes its accessibility label from [child], so there is - /// no tooltip for it; the trailing half has [trailingTooltip]. - /// - /// A half with a null callback is disabled; the control as a whole only - /// takes on its disabled surface once both halves are. - @experimental - StreamSplitButton({ - super.key, - required Widget child, - required Widget trailingIcon, - Widget? icon, - VoidCallback? onPressed, - VoidCallback? onTrailingPressed, - StreamButtonStyle style = .primary, - StreamButtonType type = .solid, - StreamButtonSize size = .small, - String? trailingTooltip, - StreamSplitButtonStyle? themeStyle, - }) : props = .new( - child: child, - icon: icon, - trailingIcon: trailingIcon, - onPressed: onPressed, - onTrailingPressed: onTrailingPressed, - style: style, - type: type, - size: size, - trailingTooltip: trailingTooltip, - themeStyle: themeStyle, - ); - /// Creates a split button with an icon in each half. /// /// [icon] labels the primary half and [trailingIcon] the secondary one. @@ -165,9 +122,8 @@ class StreamSplitButton extends StatelessWidget { class StreamSplitButtonProps { /// Creates properties for a split button. const StreamSplitButtonProps({ + required this.icon, required this.trailingIcon, - this.child, - this.icon, this.onPressed, this.onTrailingPressed, this.style = .primary, @@ -176,18 +132,10 @@ class StreamSplitButtonProps { this.tooltip, this.trailingTooltip, this.themeStyle, - }) : assert(child != null || icon != null, 'A primary half with no child needs an icon'); - - /// The main content widget displayed in the primary (leading) half. - /// - /// When null, that half renders as an icon-only button using [icon] as its - /// sole icon (see [StreamSplitButton.icon]). - final Widget? child; + }); - /// The icon rendered in the primary (leading) half, before [child]. - /// - /// When [child] is null, this is the sole icon that half renders. - final Widget? icon; + /// The icon rendered in the primary (leading) half. + final Widget icon; /// The icon rendered in the secondary (trailing) half. /// @@ -218,15 +166,12 @@ class StreamSplitButtonProps { /// The size of each half. /// /// Sets the painted area of a half — the surface it highlights on hover and - /// press, and the height of a half carrying a [child]. Each half keeps an - /// accessible tap target regardless of this value. + /// press. Each half keeps an accessible tap target regardless of this value. final StreamButtonSize size; /// Text shown in a [Tooltip] on hover / long-press of the primary half, and /// used as its accessibility label. /// - /// Only honoured while [child] is null; a primary half with a [child] - /// derives its label from that child. /// When null, that half has no tooltip. final String? tooltip; @@ -245,8 +190,8 @@ class StreamSplitButtonProps { /// Default implementation of [StreamSplitButton]. /// -/// Renders a [Row] of two [StreamButton] halves over a shared surface, with a -/// divider between them. +/// Renders a [Row] of two [StreamButton.icon] halves over a shared surface, +/// with a divider between them. /// /// See also: /// @@ -292,29 +237,6 @@ class DefaultStreamSplitButton extends StatelessWidget { elevation: const WidgetStatePropertyAll(0), ); - final leadingHalf = switch (props.child) { - final child? => StreamButton( - iconLeft: props.icon, - onPressed: props.onPressed, - style: props.style, - type: props.type, - // For the regular `StreamButton` we always use large, otherwise the padding is weird. - size: .large, - themeStyle: halfStyle, - child: child, - ), - // The assert on the props guarantees an icon once there is no child. - _ => StreamButton.icon( - icon: props.icon!, - onPressed: props.onPressed, - style: props.style, - type: props.type, - size: props.size, - tooltip: props.tooltip, - themeStyle: halfStyle, - ), - }; - return DecoratedBox( decoration: ShapeDecoration( color: buttonStyle.backgroundColor?.resolve(states), @@ -326,10 +248,15 @@ class DefaultStreamSplitButton extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - // A label can outgrow the space on offer; an icon never does, and - // staying inflexible keeps the icon-only variant usable in an - // unbounded row. - if (props.child != null) Flexible(child: leadingHalf) else leadingHalf, + StreamButton.icon( + icon: props.icon, + onPressed: props.onPressed, + style: props.style, + type: props.type, + size: props.size, + tooltip: props.tooltip, + themeStyle: halfStyle, + ), SizedBox( width: effectiveSeparatorThickness, height: effectiveSeparatorHeight, 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 be8faf27..45f4dd18 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 @@ -1,5 +1,3 @@ -// ignore_for_file: avoid_redundant_argument_values - import 'dart:io'; import 'package:alchemist/alchemist.dart'; @@ -48,33 +46,6 @@ void main() { ), ); - goldenTest( - 'renders a labelled primary half', - fileName: 'stream_split_button_label', - builder: () => GoldenTestGroup( - columns: 2, - scenarioConstraints: const BoxConstraints(maxWidth: 260), - children: [ - GoldenTestScenario( - name: 'label and icon', - child: _buildInTheme(_labelledSplitButton()), - ), - GoldenTestScenario( - name: 'label only', - child: _buildInTheme(_labelledSplitButton(icon: null)), - ), - GoldenTestScenario( - name: 'truncated label', - child: _buildInTheme(_labelledSplitButton(maxWidth: 110)), - ), - GoldenTestScenario( - name: 'outline', - child: _buildInTheme(_labelledSplitButton(type: .outline)), - ), - ], - ), - ); - goldenTest( 'renders the pressed leading half per size', fileName: 'stream_split_button_pressed', @@ -154,26 +125,6 @@ StreamSplitButton _splitButton({ ); } -StreamSplitButton _labelledSplitButton({ - StreamButtonType type = StreamButtonType.solid, - Widget? icon = const Icon(StreamIconData.voiceFill), - double maxWidth = double.infinity, -}) { - return StreamSplitButton( - icon: icon, - trailingIcon: const Icon(StreamIconData.caretDown), - style: StreamButtonStyle.secondary, - type: type, - size: StreamButtonSize.small, - onPressed: _noop, - onTrailingPressed: _noop, - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: maxWidth), - child: const Text('MacBook Pro Microphone', overflow: TextOverflow.ellipsis), - ), - ); -} - void _noop() {} Widget _buildInTheme( 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 f58414c0..8d9fcc04 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 @@ -1,5 +1,3 @@ -// ignore_for_file: avoid_redundant_argument_values - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stream_core_flutter/video.dart'; @@ -52,24 +50,6 @@ ButtonStyle _halfStyleOf(WidgetTester tester, int index) { return button.style!; } -StreamSplitButton _labelledSplitButton({ - Widget? icon = const Icon(StreamIconData.voiceFill), - VoidCallback? onPressed, - VoidCallback? onTrailingPressed, - String? trailingTooltip, -}) { - return StreamSplitButton( - icon: icon, - trailingIcon: const Icon(StreamIconData.caretDown), - style: StreamButtonStyle.secondary, - size: StreamButtonSize.small, - onPressed: onPressed, - onTrailingPressed: onTrailingPressed, - trailingTooltip: trailingTooltip, - child: const Text('MacBook Pro Microphone', overflow: TextOverflow.ellipsis), - ); -} - void main() { group('StreamSplitButton surface', () { testWidgets('paints the background a StreamButton of the same variant would', (tester) async { @@ -233,110 +213,6 @@ void main() { }); }); - group('StreamSplitButton label', () { - testWidgets('renders the child between the leading icon and the divider', (tester) async { - await tester.pumpWidget( - _withStreamTheme(_labelledSplitButton(onPressed: () {}, onTrailingPressed: () {})), - ); - - final icon = tester.getCenter(find.byIcon(StreamIconData.voiceFill)); - final label = tester.getCenter(find.text('MacBook Pro Microphone')); - final caret = tester.getCenter(find.byIcon(StreamIconData.caretDown)); - expect(icon.dx, lessThan(label.dx)); - expect(label.dx, lessThan(caret.dx)); - }); - - testWidgets('renders without a leading icon', (tester) async { - await tester.pumpWidget( - _withStreamTheme(_labelledSplitButton(icon: null, onPressed: () {}, onTrailingPressed: () {})), - ); - - expect(find.byIcon(StreamIconData.voiceFill), findsNothing); - expect(find.text('MacBook Pro Microphone'), findsOneWidget); - }); - - testWidgets('takes its accessibility label from the child', (tester) async { - final handle = tester.ensureSemantics(); - - await tester.pumpWidget( - _withStreamTheme( - _labelledSplitButton(onPressed: () {}, onTrailingPressed: () {}, trailingTooltip: 'Audio settings'), - ), - ); - - expect( - tester.getSemantics(find.byType(StreamButton).first), - isSemantics( - label: 'MacBook Pro Microphone', - isButton: true, - isEnabled: true, - hasEnabledState: true, - hasTapAction: true, - ), - ); - - handle.dispose(); - }); - - testWidgets('hugs its content when there is room', (tester) async { - await tester.pumpWidget( - _withStreamTheme( - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 600), - child: _labelledSplitButton(onPressed: () {}, onTrailingPressed: () {}), - ), - ), - ); - - expect(tester.getSize(find.byType(StreamSplitButton)).width, lessThan(600)); - }); - - testWidgets('fills the width it is given', (tester) async { - await tester.pumpWidget( - _withStreamTheme( - SizedBox( - width: 600, - child: _labelledSplitButton(onPressed: () {}, onTrailingPressed: () {}), - ), - ), - ); - - expect(tester.getSize(find.byType(StreamSplitButton)).width, 600); - }); - - testWidgets('gives up width to the label rather than overflowing', (tester) async { - // A device picker names whatever the OS reports, so the label has to - // truncate inside the space on offer instead of blowing out the row. - await tester.pumpWidget( - _withStreamTheme( - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 200), - child: _labelledSplitButton(onPressed: () {}, onTrailingPressed: () {}), - ), - ), - ); - - expect(tester.takeException(), isNull); - expect(tester.getSize(find.byType(StreamSplitButton)).width, 200); - // The trailing half never gives up its tap target to the label. - expect(tester.getSize(find.byType(StreamButton).last), const Size(48, 48)); - }); - - testWidgets('lays out in an unbounded row', (tester) async { - await tester.pumpWidget( - _withStreamTheme( - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: _labelledSplitButton(onPressed: () {}, onTrailingPressed: () {}), - ), - ), - ); - - expect(tester.takeException(), isNull); - expect(find.text('MacBook Pro Microphone'), findsOneWidget); - }); - }); - group('StreamSplitButton icons', () { testWidgets('renders the leading icon before the trailing one', (tester) async { await tester.pumpWidget( diff --git a/scripts/check_barrels.dart b/scripts/check_barrels.dart index 818e2919..e2452a7f 100644 --- a/scripts/check_barrels.dart +++ b/scripts/check_barrels.dart @@ -179,6 +179,10 @@ Map> _buildExportIndex(String root, _Config config, List<_I // every src file must be exported by exactly one barrel. void _checkCoverage(Set srcFiles, Map> exportedBy, List<_Issue> issues) { for (final entry in exportedBy.entries) { + // Only `lib/src/` files are owned by exactly one barrel. A barrel that + // re-exports another barrel (`video.dart` -> `core.dart`) is composition, + // not a duplicate. + if (!srcFiles.contains(entry.key)) continue; if (entry.value.length > 1) { issues.add( _Issue( From 1347a525981b0e65b57dc20c065bbb37a549b873 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 18 Aug 2026 19:50:29 +0200 Subject: [PATCH 4/5] simplify split button sizes --- .../lib/components/buttons/split_button.dart | 46 ---- .../buttons/stream_split_button.dart | 235 ++++++++++++++---- .../components/stream_split_button_theme.dart | 7 +- .../stream_split_button_golden_test.dart | 33 +-- .../buttons/stream_split_button_test.dart | 106 ++++++-- 5 files changed, 284 insertions(+), 143 deletions(-) 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 7250df63..870cbb9f 100644 --- a/apps/design_system_gallery/lib/components/buttons/split_button.dart +++ b/apps/design_system_gallery/lib/components/buttons/split_button.dart @@ -33,14 +33,6 @@ Widget buildStreamSplitButtonPlayground(BuildContext context) { description: 'Split button type variant. Outline draws one border around both halves.', ); - final size = context.knobs.object.dropdown( - label: 'Size', - options: StreamButtonSize.values, - initialOption: StreamButtonSize.small, - labelBuilder: (option) => option.name, - description: 'Painted area of each half. The tap target stays accessible regardless.', - ); - final caretUp = context.knobs.boolean( label: 'Caret Up', description: 'Point the trailing caret up, as when the menu it opens is already showing.', @@ -71,7 +63,6 @@ Widget buildStreamSplitButtonPlayground(BuildContext context) { trailingIcon: Icon(caretUp ? icons.caretUp : icons.caretDown), style: style, type: type, - size: size, tooltip: 'Mute', trailingTooltip: 'Audio settings', onPressed: leadingEnabled ? () {} : null, @@ -104,7 +95,6 @@ Widget buildStreamSplitButtonShowcase(BuildContext context) { spacing: spacing.xl, children: const [ _StyleTypeMatrixSection(), - _SizeScaleSection(), _DisabledSection(), _CallControlSection(), ], @@ -139,7 +129,6 @@ class _StyleTypeMatrixSection extends StatelessWidget { trailingIcon: Icon(icons.caretDown), style: style, type: type, - size: StreamButtonSize.small, tooltip: 'Mute', trailingTooltip: 'Audio settings', onPressed: () {}, @@ -153,39 +142,6 @@ class _StyleTypeMatrixSection extends StatelessWidget { } } -class _SizeScaleSection extends StatelessWidget { - const _SizeScaleSection(); - - @override - Widget build(BuildContext context) { - final icons = context.streamIcons; - final spacing = context.streamSpacing; - - return _ExampleCard( - title: 'Sizes', - description: - 'Size sets the area a half highlights on hover and press — press one to see it. ' - 'The surface itself always hugs the tap targets.', - child: Row( - spacing: spacing.md, - children: [ - for (final size in StreamButtonSize.values) - StreamSplitButton.icon( - icon: Icon(icons.voiceFill), - trailingIcon: Icon(icons.caretDown), - style: StreamButtonStyle.secondary, - size: size, - tooltip: size.name, - trailingTooltip: 'Audio settings', - onPressed: () {}, - onTrailingPressed: () {}, - ), - ], - ), - ); - } -} - class _DisabledSection extends StatelessWidget { const _DisabledSection(); @@ -212,7 +168,6 @@ class _DisabledSection extends StatelessWidget { icon: Icon(icons.voiceFill), trailingIcon: Icon(icons.caretDown), style: StreamButtonStyle.secondary, - size: StreamButtonSize.small, onPressed: leading ? () {} : null, onTrailingPressed: trailing ? () {} : null, ), @@ -252,7 +207,6 @@ class _CallControlSectionState extends State<_CallControlSection> { icon: Icon(_isMuted ? icons.voiceOffFill : icons.voiceFill), trailingIcon: Icon(_isSettingsOpen ? icons.caretUp : icons.caretDown), style: _isMuted ? StreamButtonStyle.destructive : StreamButtonStyle.secondary, - size: StreamButtonSize.small, tooltip: _isMuted ? 'Unmute' : 'Mute', trailingTooltip: 'Audio settings', onPressed: () => setState(() => _isMuted = !_isMuted), 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 64ed6fc7..bda3dabc 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 @@ -1,4 +1,7 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:meta/meta.dart'; import '../../factory/stream_component_factory.dart'; @@ -18,6 +21,11 @@ import 'stream_button.dart'; /// [StreamButton.icon]s painted on a single background, so the control reads /// as one pill rather than two adjacent buttons. /// +/// The background is as tall as the one a [StreamButtonSize.medium] +/// [StreamButton] paints, and the halves paint smaller still — the surface +/// 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 @@ -82,7 +90,6 @@ class StreamSplitButton extends StatelessWidget { VoidCallback? onTrailingPressed, StreamButtonStyle style = .primary, StreamButtonType type = .solid, - StreamButtonSize size = .medium, String? tooltip, String? trailingTooltip, StreamSplitButtonStyle? themeStyle, @@ -93,7 +100,6 @@ class StreamSplitButton extends StatelessWidget { onTrailingPressed: onTrailingPressed, style: style, type: type, - size: size, tooltip: tooltip, trailingTooltip: trailingTooltip, themeStyle: themeStyle, @@ -128,7 +134,6 @@ class StreamSplitButtonProps { this.onTrailingPressed, this.style = .primary, this.type = .solid, - this.size = .medium, this.tooltip, this.trailingTooltip, this.themeStyle, @@ -163,12 +168,6 @@ class StreamSplitButtonProps { /// button draws a single border around both halves. final StreamButtonType type; - /// The size of each half. - /// - /// Sets the painted area of a half — the surface it highlights on hover and - /// press. Each half keeps an accessible tap target regardless of this value. - final StreamButtonSize size; - /// Text shown in a [Tooltip] on hover / long-press of the primary half, and /// used as its accessibility label. /// @@ -188,6 +187,11 @@ class StreamSplitButtonProps { final StreamSplitButtonStyle? themeStyle; } +// The halves are always small buttons: the design draws the surface at the +// height of a medium button with a pair of small ones inside it, and never +// scales the control. +const _halfButtonSize = StreamButtonSize.small; + /// Default implementation of [StreamSplitButton]. /// /// Renders a [Row] of two [StreamButton.icon] halves over a shared surface, @@ -206,8 +210,9 @@ 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, size: props.size); + final defaults = _StreamSplitButtonDefaults(context); // Resolved once and shared: the surface below and the halves above are the // same button style, so they cannot render as different colors. @@ -216,7 +221,7 @@ class DefaultStreamSplitButton extends StatelessWidget { style: props.style, type: props.type, isFloating: false, - themeStyle: defaults.buttonStyle.merge(themeStyle?.buttonStyle), + themeStyle: themeStyle?.buttonStyle, ); final isEnabled = props.onPressed != null || props.onTrailingPressed != null; @@ -229,50 +234,184 @@ class DefaultStreamSplitButton extends StatelessWidget { final effectiveSeparatorThickness = themeStyle?.separatorThickness ?? defaults.separatorThickness; final effectiveSeparatorHeight = themeStyle?.separatorHeight ?? defaults.separatorHeight; + // The halves are inset by [inset] on every edge of the surface, which puts + // a pair of small buttons under a surface the height of a medium one. + final inset = spacing.xxs; + // The halves sit on the shared surface, so they paint neither their own - // background nor their own border. + // background nor their own border. Their box is the painted circle; the tap + // target around it comes from [_HitTarget], since MaterialTapTargetSize can + // only grow both axes at once and the design grows only the height. final halfStyle = buttonStyle.copyWith( backgroundColor: const WidgetStatePropertyAll(StreamColors.transparent), borderColor: const WidgetStatePropertyAll(null), elevation: const WidgetStatePropertyAll(0), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, ); - return DecoratedBox( - decoration: ShapeDecoration( - color: buttonStyle.backgroundColor?.resolve(states), - shape: switch (borderColor) { - final color? => shape.copyWith(side: BorderSide(color: color)), - _ => shape, - }, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - StreamButton.icon( - icon: props.icon, - onPressed: props.onPressed, + Widget half({required Widget icon, required VoidCallback? onPressed, required String? tooltip}) { + // The merge lifts the half's semantics node up to the tap target, so + // assistive tech reports the region that actually responds to a tap + // rather than the smaller square the button paints. + return MergeSemantics( + child: _HitTarget( + minSize: Size(_halfButtonSize.value, kMinInteractiveDimension), + child: StreamButton.icon( + icon: icon, + onPressed: onPressed, style: props.style, type: props.type, - size: props.size, - tooltip: props.tooltip, + size: _halfButtonSize, + tooltip: tooltip, themeStyle: halfStyle, ), - SizedBox( - width: effectiveSeparatorThickness, - height: effectiveSeparatorHeight, - child: ColoredBox(color: effectiveSeparatorColor ?? StreamColors.transparent), + ), + ); + } + + return Stack( + alignment: Alignment.center, + children: [ + // Painted behind the halves rather than around them: their tap targets + // are taller than the surface and overhang it top and bottom. + Positioned.fill( + child: Center( + child: SizedBox( + width: double.infinity, + height: _halfButtonSize.value + inset * 2, + child: DecoratedBox( + decoration: ShapeDecoration( + color: buttonStyle.backgroundColor?.resolve(states), + shape: switch (borderColor) { + final color? => shape.copyWith(side: BorderSide(color: color)), + _ => shape, + }, + ), + ), + ), ), - StreamButton.icon( - icon: props.trailingIcon, - onPressed: props.onTrailingPressed, - style: props.style, - type: props.type, - size: props.size, - tooltip: props.trailingTooltip, - themeStyle: halfStyle, + ), + Padding( + padding: EdgeInsets.symmetric(horizontal: inset), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: inset, + children: [ + half(icon: props.icon, onPressed: props.onPressed, tooltip: props.tooltip), + SizedBox( + width: effectiveSeparatorThickness, + height: effectiveSeparatorHeight, + child: ColoredBox(color: effectiveSeparatorColor ?? StreamColors.transparent), + ), + half( + icon: props.trailingIcon, + onPressed: props.onTrailingPressed, + tooltip: props.trailingTooltip, + ), + ], ), - ], - ), + ), + ], + ); + } +} + +// Grows the tap target around [child] to at least [minSize] without growing +// what the child paints. +// +// This mirrors the `_InputPadding` that [MaterialTapTargetSize.padded] installs +// inside every Material button. That knob is all-or-nothing at 48x48; a split +// button half is narrower than it is tall, so it needs the same trick with its +// own size. +class _HitTarget extends SingleChildRenderObjectWidget { + const _HitTarget({required this.minSize, required super.child}); + + final Size minSize; + + @override + _RenderHitTarget createRenderObject(BuildContext context) => _RenderHitTarget(minSize); + + @override + void updateRenderObject(BuildContext context, _RenderHitTarget renderObject) { + renderObject.minSize = minSize; + } +} + +class _RenderHitTarget extends RenderShiftedBox { + _RenderHitTarget(this._minSize) : super(null); + + Size get minSize => _minSize; + Size _minSize; + set minSize(Size value) { + if (_minSize == value) return; + _minSize = value; + markNeedsLayout(); + } + + @override + double computeMinIntrinsicWidth(double height) => switch (child) { + final child? => math.max(child.getMinIntrinsicWidth(height), minSize.width), + _ => 0, + }; + + @override + double computeMinIntrinsicHeight(double width) => switch (child) { + final child? => math.max(child.getMinIntrinsicHeight(width), minSize.height), + _ => 0, + }; + + @override + double computeMaxIntrinsicWidth(double height) => switch (child) { + final child? => math.max(child.getMaxIntrinsicWidth(height), minSize.width), + _ => 0, + }; + + @override + double computeMaxIntrinsicHeight(double width) => switch (child) { + final child? => math.max(child.getMaxIntrinsicHeight(width), minSize.height), + _ => 0, + }; + + Size _computeSize({required BoxConstraints constraints, required ChildLayouter layoutChild}) { + if (child case final child?) { + final childSize = layoutChild(child, constraints); + return constraints.constrain( + Size(math.max(childSize.width, minSize.width), math.max(childSize.height, minSize.height)), + ); + } + return Size.zero; + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + return _computeSize(constraints: constraints, layoutChild: ChildLayoutHelper.dryLayoutChild); + } + + @override + void performLayout() { + size = _computeSize(constraints: constraints, layoutChild: ChildLayoutHelper.layoutChild); + if (child case final child?) { + final childParentData = child.parentData! as BoxParentData; + childParentData.offset = Alignment.center.alongOffset(size - child.size as Offset); + } + } + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + // Material's own version of this skips the bounds check, which is why two + // `padded` buttons side by side fight over taps that belong to neither. + // Two halves in a row is exactly that case, so check bounds first. + if (!size.contains(position)) return false; + if (super.hitTest(result, position: position)) return true; + + // Anything else inside the grown box counts as a hit on the child's centre, + // so the overhang taps through to the button rather than falling to + // whatever is behind it. + final center = child!.size.center(Offset.zero); + return result.addWithRawTransform( + transform: MatrixUtils.forceToPoint(center), + position: center, + hitTest: (result, position) => child!.hitTest(result, position: center), ); } } @@ -282,20 +421,13 @@ class DefaultStreamSplitButton extends StatelessWidget { // These defaults are used when no explicit value is provided via // [StreamSplitButtonStyle] or [StreamSplitButtonThemeData]. class _StreamSplitButtonDefaults extends StreamSplitButtonStyle { - _StreamSplitButtonDefaults(this.context, {required this.size}); + _StreamSplitButtonDefaults(this.context); final BuildContext context; - final StreamButtonSize size; late final StreamSpacing _spacing = context.streamSpacing; late final StreamColorScheme _colorScheme = context.streamColorScheme; - // Forced onto both halves and the surface, above the inherited - // [StreamButtonTheme] but below the caller's own overrides: a split button - // whose halves lost their tap target is not worth shipping. - @override - StreamButtonThemeStyle get buttonStyle => const StreamButtonThemeStyle(tapTargetSize: MaterialTapTargetSize.padded); - @override WidgetStateProperty get separatorColor => WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.disabled)) return _colorScheme.borderDisabled; @@ -305,6 +437,7 @@ class _StreamSplitButtonDefaults extends StreamSplitButtonStyle { @override double get separatorThickness => 1; + // Inset from the halves by as much as the halves are inset from the surface. @override - double get separatorHeight => size.value - _spacing.xxs * 2; + double get separatorHeight => _halfButtonSize.value - _spacing.xxs * 2; } 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 ac10945f..24d58dba 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 @@ -170,9 +170,10 @@ class StreamSplitButtonStyle with _$StreamSplitButtonStyle { /// The height of the divider between the two halves, in logical pixels. /// - /// The divider is shorter than the control so it does not run into the - /// rounded ends. Defaults to the button size inset by [StreamSpacing.xxs] on - /// both ends — 24 for a [StreamButtonSize.small] split button. + /// 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. final double? separatorHeight; /// Linearly interpolate between two [StreamSplitButtonStyle] objects. 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 45f4dd18..54210caf 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 @@ -30,36 +30,15 @@ void main() { ); goldenTest( - 'renders sizes', - fileName: 'stream_split_button_sizes', - builder: () => GoldenTestGroup( - columns: StreamButtonSize.values.length, - children: [ - for (final size in StreamButtonSize.values) - GoldenTestScenario( - name: size.name, - child: _buildInTheme( - _splitButton(style: .secondary, size: size), - ), - ), - ], - ), - ); - - goldenTest( - 'renders the pressed leading half per size', + 'renders the pressed leading half', fileName: 'stream_split_button_pressed', - // The highlight is the only place `size` shows up: the surface always - // hugs the halves' tap targets, so at rest every size looks the same. whilePerforming: press(find.byIcon(StreamIconData.voiceFill)), builder: () => GoldenTestGroup( - columns: StreamButtonSize.values.length, children: [ - for (final size in StreamButtonSize.values) - GoldenTestScenario( - name: size.name, - child: _buildInTheme(_splitButton(style: .secondary, size: size)), - ), + GoldenTestScenario( + name: 'pressed', + child: _buildInTheme(_splitButton(style: .secondary)), + ), ], ), ); @@ -110,7 +89,6 @@ GoldenTestGroup _buildMatrix({Brightness brightness = Brightness.light}) { StreamSplitButton _splitButton({ StreamButtonStyle style = StreamButtonStyle.primary, StreamButtonType type = StreamButtonType.solid, - StreamButtonSize size = StreamButtonSize.small, VoidCallback? onPressed = _noop, VoidCallback? onTrailingPressed = _noop, }) { @@ -119,7 +97,6 @@ StreamSplitButton _splitButton({ trailingIcon: const Icon(StreamIconData.caretDown), style: style, type: type, - size: size, onPressed: onPressed, 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 8d9fcc04..cac65821 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 @@ -12,7 +12,6 @@ Widget _withStreamTheme(Widget child, {StreamTheme? streamTheme}) { StreamSplitButton _splitButton({ StreamButtonStyle style = StreamButtonStyle.primary, StreamButtonType type = StreamButtonType.solid, - StreamButtonSize size = StreamButtonSize.small, IconData trailingIcon = StreamIconData.caretDown, VoidCallback? onPressed, VoidCallback? onTrailingPressed, @@ -25,7 +24,6 @@ StreamSplitButton _splitButton({ trailingIcon: Icon(trailingIcon), style: style, type: type, - size: size, onPressed: onPressed, onTrailingPressed: onTrailingPressed, tooltip: tooltip, @@ -34,12 +32,14 @@ StreamSplitButton _splitButton({ ); } +/// The shared surface both halves sit on. +Finder _surfaceFinder() { + return find.descendant(of: find.byType(StreamSplitButton), matching: find.byType(DecoratedBox)).first; +} + /// The [ShapeDecoration] of the shared surface both halves sit on. ShapeDecoration _surfaceOf(WidgetTester tester) { - final decorated = tester.widget( - find.descendant(of: find.byType(StreamSplitButton), matching: find.byType(DecoratedBox)).first, - ); - return decorated.decoration as ShapeDecoration; + return tester.widget(_surfaceFinder()).decoration as ShapeDecoration; } /// The resolved [ButtonStyle] of the half at [index] (0 leading, 1 trailing). @@ -152,9 +152,49 @@ void main() { }); group('StreamSplitButton layout', () { - testWidgets('keeps a tap target per half whatever the button theme asks for', (tester) async { + testWidgets('matches the design: 81x48 control over an 81x40 surface', (tester) async { + await tester.pumpWidget( + _withStreamTheme(_splitButton(style: .secondary, onPressed: () {}, onTrailingPressed: () {})), + ); + + expect(tester.getSize(find.byType(StreamSplitButton)), const Size(81, 48)); + expect(tester.getSize(_surfaceFinder()), const Size(81, 40)); + + final halves = find.descendant(of: find.byType(StreamSplitButton), matching: find.byType(StreamButton)); + expect(tester.getSize(halves.at(0)), const Size(32, 32)); + expect(tester.getSize(halves.at(1)), const Size(32, 32)); + + // The icons are drawn at 20 in a 40-tall surface. Get either number + // wrong and the glyphs read as too big for the control. + expect(_halfStyleOf(tester, 0).iconSize!.resolve({}), 20); + expect(_halfStyleOf(tester, 1).iconSize!.resolve({}), 20); + }); + + testWidgets('paints a surface as tall as a lone medium StreamButton', (tester) async { + // The reason the surface is not simply the height of the tap targets: + // side by side with a plain icon button, the two have to line up. + await tester.pumpWidget( + _withStreamTheme( + Column( + children: [ + _splitButton(style: .secondary, onPressed: () {}, onTrailingPressed: () {}), + StreamButton.icon(icon: const Icon(Icons.mic), style: .secondary, onPressed: () {}), + ], + ), + ), + ); + + final reference = tester.getSize( + find.descendant(of: find.byType(StreamButton).last, matching: find.byType(Material)).first, + ); + expect(tester.getSize(_surfaceFinder()).height, reference.height); + }); + + testWidgets('keeps a full-height tap target per half whatever the button theme asks for', (tester) async { // A theme that shrink-wraps every button must not shrink the halves - // below the platform tap target. + // below the platform tap target. Note the design makes the halves + // narrower than tall, so they clear the height but not the width the + // platform guidelines ask for. await tester.pumpWidget( _withStreamTheme( streamTheme: StreamTheme( @@ -164,20 +204,56 @@ void main() { ), ), ), - _splitButton(onPressed: () {}, onTrailingPressed: () {}), + _splitButton( + onPressed: () {}, + onTrailingPressed: () {}, + tooltip: 'Mute', + trailingTooltip: 'Audio settings', + ), ), ); - final halves = find.descendant(of: find.byType(StreamSplitButton), matching: find.byType(StreamButton)); - expect(tester.getSize(halves.at(0)), const Size(48, 48)); - expect(tester.getSize(halves.at(1)), const Size(48, 48)); - final handle = tester.ensureSemantics(); - await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); - await expectLater(tester, meetsGuideline(iOSTapTargetGuideline)); + for (final tooltip in ['Mute', 'Audio settings']) { + final node = tester.getSemantics(find.byTooltip(tooltip)); + expect(node.rect.height, kMinInteractiveDimension, reason: '$tooltip tap target height'); + } handle.dispose(); }); + testWidgets('taps land on the half they overhang, not its neighbour', (tester) async { + // Each half's target is taller than what it paints, so the two overhang + // the surface. Material's own tap-target padding answers every hit test + // regardless of position, which would let the trailing half swallow taps + // meant for the leading one. + var pressed = 0; + var trailingPressed = 0; + + await tester.pumpWidget( + _withStreamTheme( + _splitButton( + onPressed: () => pressed++, + onTrailingPressed: () => trailingPressed++, + tooltip: 'Mute', + trailingTooltip: 'Audio settings', + ), + ), + ); + + final control = tester.getRect(find.byType(StreamSplitButton)); + final leading = tester.getRect(find.byTooltip('Mute')); + final trailing = tester.getRect(find.byTooltip('Audio settings')); + expect(leading.top, greaterThan(control.top), reason: 'the paint should sit inside the target'); + + await tester.tapAt(Offset(leading.center.dx, control.top + 2)); + await tester.pumpAndSettle(); + expect((pressed, trailingPressed), (1, 0)); + + await tester.tapAt(Offset(trailing.center.dx, control.bottom - 2)); + await tester.pumpAndSettle(); + expect((pressed, trailingPressed), (1, 1)); + }); + testWidgets('separates the halves with a divider inset from the rounded ends', (tester) async { final streamTheme = StreamTheme(); await tester.pumpWidget( From 705af8eaacc9c0f513c3e0a19ae4093427790c36 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 19 Aug 2026 09:00:29 +0200 Subject: [PATCH 5/5] Add horizontal padding to the button --- .../buttons/stream_split_button.dart | 80 ++++++++++--------- 1 file changed, 42 insertions(+), 38 deletions(-) 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 bda3dabc..cb5d95cd 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 @@ -269,49 +269,53 @@ class DefaultStreamSplitButton extends StatelessWidget { ); } - return Stack( - alignment: Alignment.center, - children: [ - // Painted behind the halves rather than around them: their tap targets - // are taller than the surface and overhang it top and bottom. - Positioned.fill( - child: Center( - child: SizedBox( - width: double.infinity, - height: _halfButtonSize.value + inset * 2, - child: DecoratedBox( - decoration: ShapeDecoration( - color: buttonStyle.backgroundColor?.resolve(states), - shape: switch (borderColor) { - final color? => shape.copyWith(side: BorderSide(color: color)), - _ => shape, - }, + return Padding( + // We only add some horizontal padding to match the extra vertical padding from the _HitTarget + padding: EdgeInsets.symmetric(horizontal: inset), + child: Stack( + alignment: Alignment.center, + children: [ + // Painted behind the halves rather than around them: their tap targets + // are taller than the surface and overhang it top and bottom. + Positioned.fill( + child: Center( + child: SizedBox( + width: double.infinity, + height: _halfButtonSize.value + inset * 2, + child: DecoratedBox( + decoration: ShapeDecoration( + color: buttonStyle.backgroundColor?.resolve(states), + shape: switch (borderColor) { + final color? => shape.copyWith(side: BorderSide(color: color)), + _ => shape, + }, + ), ), ), ), ), - ), - Padding( - padding: EdgeInsets.symmetric(horizontal: inset), - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: inset, - children: [ - half(icon: props.icon, onPressed: props.onPressed, tooltip: props.tooltip), - SizedBox( - width: effectiveSeparatorThickness, - height: effectiveSeparatorHeight, - child: ColoredBox(color: effectiveSeparatorColor ?? StreamColors.transparent), - ), - half( - icon: props.trailingIcon, - onPressed: props.onTrailingPressed, - tooltip: props.trailingTooltip, - ), - ], + Padding( + padding: EdgeInsets.symmetric(horizontal: inset), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: inset, + children: [ + half(icon: props.icon, onPressed: props.onPressed, tooltip: props.tooltip), + SizedBox( + width: effectiveSeparatorThickness, + height: effectiveSeparatorHeight, + child: ColoredBox(color: effectiveSeparatorColor ?? StreamColors.transparent), + ), + half( + icon: props.trailingIcon, + onPressed: props.onTrailingPressed, + tooltip: props.trailingTooltip, + ), + ], + ), ), - ), - ], + ], + ), ); } }