From 5dc3700d1f0e59d45fde51d4249a68b376ea83bc Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 18 Aug 2026 15:18:35 +0200 Subject: [PATCH 1/6] 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 1a68df6dab15d4eab81ab8466180c461b92783c4 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 18 Aug 2026 15:44:28 +0200 Subject: [PATCH 2/6] 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 d22726a1dd00a438cc009f0878b0dba1e890797d Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 18 Aug 2026 17:15:59 +0200 Subject: [PATCH 3/6] 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 84c4480cbeeb09ff1d986d7e6b21f53190d803a0 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Tue, 18 Aug 2026 19:50:29 +0200 Subject: [PATCH 4/6] 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 d4eaeaf08b55d77611ab3b1d3d928187fcf938f0 Mon Sep 17 00:00:00 2001 From: Rene Floor Date: Wed, 19 Aug 2026 09:00:29 +0200 Subject: [PATCH 5/6] 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, + ), + ], + ), ), - ), - ], + ], + ), ); } } From c9d5e29180bad94f1b11c3efd741ed6538b2897e Mon Sep 17 00:00:00 2001 From: renefloor <15101411+renefloor@users.noreply.github.com> Date: Thu, 27 Aug 2026 08:17:34 +0000 Subject: [PATCH 6/6] chore: Update Goldens --- .../goldens/ci/stream_split_button_dark.png | Bin 0 -> 10841 bytes .../goldens/ci/stream_split_button_disabled.png | Bin 0 -> 2430 bytes .../goldens/ci/stream_split_button_light.png | Bin 0 -> 10060 bytes .../goldens/ci/stream_split_button_pressed.png | Bin 0 -> 1716 bytes 4 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_split_button_dark.png create mode 100644 packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_split_button_disabled.png create mode 100644 packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_split_button_light.png create mode 100644 packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_split_button_pressed.png diff --git a/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_split_button_dark.png b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_split_button_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..c599f31e7270a49e8a80a3b7b795bc789345651d GIT binary patch literal 10841 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 literal 0 HcmV?d00001 diff --git a/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_split_button_disabled.png b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_split_button_disabled.png new file mode 100644 index 0000000000000000000000000000000000000000..f39ee7eb1b9afa2e2fecdeff13c7c3b663380277 GIT binary patch literal 2430 zcmb_edpK0<9$sXl+@f;HWt1tgCyko0OHE3~97fw^Tw<_I!sJdisTr4$XeKF&8l9tv z(b!`&j61m`_gk28zm7|hao4byea;`}&+|O{tmk{y`rfs^-?!fP_k7>``{IAKKuPU8 zybk~XDYU7v6#(op1^dq8qTpRA0#OA%M1n4(ZN$OruK0}yAQlR;LR|*vttvABu=fPo z*vKaI<=4R`vWA_K*kZm8tBFc&#P+L0YfneGMm|0HSz89MvORLD>dYe4p-Ns2(3?*x zFd8lR=?GLV@@eyOAx_8cPy+qF)6FL=+Prot38|HyII$f(!Dnwz+r0Wmh2Z@{p%A=4 z!6o$M(X{pT66%sL0Pw_jrKt+-1%THY>OuezECL092=f17Fm!lpCZEGd0PHVrs~>H26D+PU z%!Cwc^M1e|YLXH(^S5uzTSc7TZ1N*f`;7iKE*lvnerw;^MX8D%rV9lp{Id4c(6(h6 z{~H&dTHV`DaYg2n)}DnfTpy+xXDT4^2h*KKNCzI2|;PzPRW4bX1 zFhShmEFJXGPQ=$cj)%ii>b+A?Dc2i3R8Uf;n6CPg0TV2iK}*MP=VTJNdLg$}Em6iR zJ3BiwF=)nfR9cBaq*fOt!mNuWn;xjl&3#C)qu^SrIeP!1r@yQ0PVYi|-Q>WQV9M_= ze#^)dd6VUZFW15|2kVt!pVYkVdKt7i`&9RX31Kh=35DZu-A!44FOHrYIN**owNAR@ zpJ`d~81^Hufg*YWkDLgQixz#e6?kOH-A9=*G+;5}rb}^WJm~(a1J@RqBBRWLT%tOd z-ZUK8nmxh{?d6l)eJjU9MFk;)-k_y$t_R;!2`dWXsW zeU!&%_2|(3zO3DmG^j8Vw&JqBHzsE@w2H3W9q8vi<*rXOb}>Uolyx2vm#=QhI>pYV zW1NP)>viyQxj0n_l3j66?Eu;Jal~@tN{a(hB~-G9PHyPc$EOOdk%`M#5NTfP1(6DG zrlgR^KL)0FeC{@)WkapxWAKW!X|H~IA6qdji(u#YHS$0wg}SiHIw%lt#jxMIl?Tw~ znF!1QN`CL!sx5(>ZFgyN`9_k~2CouA!PO|32PBTjVFKtgs7sL267rmKX{AepVt}NW z7?}3)84h6rXNVhK5Yry*Th1))qKw>iLJsz+vZDhX>sXveI8lX(5qyQ8-ej)@XIL;w zi6btbDPNgVjwU)A*UFF<$BmE+*C1s(%{!#YI}A0EOR+`hDMXYGiq+tuBh|rbIN7437Q;2@(C~fEG21%)_Tmgisj_z`6kKN|@(viWg2A+~z1o|_%NEo(g2 z$qrDvY1bRlpj0~~lmjldTeQP*@DMmGXx6`8I_s7r7N6?RN>shlcV2yCYz6nJ6?QC4C+$m?9^WEznn<~X{bpk2sV6P=a=uXx;3`y)>-qP_tY;nSN$+^ zMUli+Dqm3+A4C5P#i*6X7cS9LTs>SF%Tc z-?5ix#~b#qZr^^{cC#U8g8E-yr(fV6t4%TQ&dU`8;bL!KW*W@d(EWI^kEgSxJ?33Y zV9qM7eY*FfiK^4=V4`@kd~7yE)dUn@AQ7lo>L)q*I>P2)Q2rH;^%G;zRr=hk>N+T^ z*^uhO77wb@-wWG}xAx8S;d*RuIr>#}WYd$^yTleUCKjM*xu0nt{1L<56pORHTL##+Zpw%u zxSgAqV7WL!GYJ_tmuLN@`1lt_n<7ijgKM#&hu~E) zWW;#h(9C})`L0)r4mx4Vfzo0A2Rmn#()*|E=!Vj8OTEyW~vP^j0CQG7(kq%Y)lUk4pIH26dCh$Kq)6V^Bl`h7{l&Jt$0 zt}JT_ySWR#DaV4_!o>zeof;sS!A#mS$HsGE)H*YYnhBMSj~G`B{$-4SHKx7|xW`ml z!nb|yAt=?=T-b)|{pEbtu&E?>2g8brux~l9QhjwZ`emniqQm)uWoXiEhx)9%*zNU3 zZF#1>j(k!WL)kp`FYkLX=}`8g2AkL9lc(QCDzAR~qGieckOgfVL0&r9RDht{HrXFa zDDL;p9Pk~NoA!GG9gO)ja7G4hx$D)w!yc>$lWuD11rKjKm~=V0;B0CF$@$_S#8ha^!Pa*!N{oDn2v7(jARKtMo}Qb7i)=%FV-&`n55PFm+t*7CT2 znyK}J)%8iE1H>EK?aw(6ZDcNcFqx>Hijrn`5D$wUu}smkCxk0TFVBQWk64v#ZkFtC z8obhzGA83ZvretCRjq66Tv}^TV^$}3(4daz$^Bc4)}v%G1<*H(Vmrm__Rs?h&PBr z{<|6+-^)m2Rh4$cImS=QHb1|rUVOee_Ea#zQ7>#^3#G_;(1ybX5kBbRuYXkzXCIr0rMZ*2<|DJT)RbiiaTz+vyS{}J~~EnL#` z!!V-Fsw;tB!NVpu^KH$VodEx0@R57sFqCVmU z>}&^GBUpbwH4^RYf8DpRBjb}2)#`ah+-RN2{@fPc-;6E$t0SBt#QUn^cVA6d#`EoG z$`lP~vXWQyzgCiIQQ_gzV(|O)9GB0>kSXAl&oJy9B~;efAl*!qJPPleXAa(udd*17 z9dz*pto^kPyUMpFLG9$AebqGQZ>Q|{WJ)Sa)k#4JhrH;2j+}W{x4G`B;@eHtehJk!QJXQgnd;OYC?|}3jc~d!DfGDB;(TPP=W`O zezMESvK#g;G&rh}dww{7cPqxmmBIc>dh%vN4Sq!y@57kHBPP2*5X>f`7VD7pPEvmv zs`JaTsU7`l+`)(SYhN$}nvKk5Mu{wz5{nmZg*!v^Y`yjQOaGt3mfb-G|%1wpXIsO*hJ#C1qVrNrG$ zreSJgKf+#@ao3|x-~yxr&rGdlOztssLUbO^iQuuJZX~)uR&^?NXMT5WxG;b)TY1(; z|DcEMr-}CDj`kISpV4^k$wZff^8FiBQj7jS3<+VnY{AVF^BsP%?6Z~+@|0J0x3ot zh|WvL!i6OEy*f7*88Iny(RhUx7X%rUxg>Ivayw1^L3|63y^|5lHd7zCW385M$@&#< z(UPv69KmcTgqxg&ps8n+$d~<*r}PZkHd3_+Oeyt7$HYO*OUiHG!sQ5>{R$ypP()dURUCB8{Mh0Y+S56~me#reZg$X%Ji6{s9GA3!n{tma=oZ26C z1a!1g%YT@J+j>{fDq~1 zhw}&}R&|^vfu7J0{5fe%2W9f~jA~EJpfw{gQ-AisMxgsi$rN6(Yna@;*-qA)C z59Yquz99@YviE|D?S(XTFlC5M33jmV7|B6b8l0y0;+Ya--DjYUu%S3Rr5e#UfPTE~ z#A7PN8>w4T_1e7QIChI_psOOdK=8S6_NfpxTHC_TER@9<>aytJCLJNG{U4{xA3FXK zIl(o)o{N`T`B*7BJop@|l##+WertU5=TCYHDaZP?3xvjaC0FszWykm9U(|K#Z61!@ zqh7g|q$G4~09nB1gCG$MHXW8YIB+ZQC&L!y09L|>l5?H3SRH5aWR5}`)zgg|`iBtsk(G}y+euhJX)WKltCe?C{K z(txf4ORF2(_HYYuX*^CCR%RnBom#L_j& z7p^)b@km5&MFUyiO-+?4m7*2Uy)8@uatqrP-cfbF^gR)x@0S=;C)_Fq)m{R@ecKDi{RgJ0im6Si`yX4CcMjj`a19@v+tA>;ZF&SJ z#Pg)~m_0;LhIqfqmkbcwZbGfb7Y;qf(>+F-D|;CIs(tHZ21`%O`OeESgZJ(J@GTUm z6av$7P@Fn@&^pd_^vxKZ*~&4#706-k@G;@DGE3oPtHJ&|Ydl_Jz#ghD@G6Ro&z7A! zdm>(dWTkm=z0=^4zx+o^+xYf^DTJh!zQ@>FZyd9aw|92ItBdY7t?u+n@0TcWEm@jw zo94I{s<3akg!xmQxq<45T{$}2ZSw#OH2;cObCL)M+_}X8J3s8yVCS`aLX3L)%9nSQ zxR#TS_ZQaIXR`77E2%5Nda%rd4h(voPP8lTq3Xi&1;#)Q!jP@9YpYa)TMFSJbkRus znuM9Se`bfAdIpbTQvF(QyGPv_h0dggM7)7_t%asRz%va-eK~d~7C%JW#%6-vbX|{3 zbIF=*oExfa>(}zRc!CH{IWec_PK@Y*iM~u%YFik~0NwGuOO)Ny6%QXdc78`s?Og>L zilk%857)YerbwYagFsX80!NaUb3-8;Xh6^_1~d^kK;!cNQ3HwUHr>hS3Au+-6?Z|L za?|-CH+wpVa7~XR6|-6H8l*razWbDzHy$w=dZ~(AA>>uav(mEi9wN^4q53~Ot(Dj; z7VEvs&<-CT=TK8qySx0z#Fo^XVr=C0{Fu-Tls>=pbiNoR zNym_TjW3%NT7Wm(WP@oga$exbWYBMU7tRd{b_Iy;I0C-F6z>=?{Tu|L=G~1Ilf_>* zTM@v^!K0Ty_~-qf(1^yE?_C<8gYxFQDjN0HcV!0nAV@7)WeO>;0KFaHswfs`oEuh_1iFfqB@nLY@H&mku%@DL+aa7dGlNb%}a;48TRQ0 z4^xGNr=@nx`H#5ZICoVA(vYj2b1`u07O#1B#`5qI`mKS|}$hIBQIr94{$}B13tqGA1#W&CCY>Ra! zp~i#`K~@X~dvSnd}N5R&QGjtX{p$t-~MB6*XNTJ8VMR71%wo zm#P}6w40b35W(1qqD!_FGqw)Do>+IPOp?^wJ&;Fx7}lF;9Yj)b!rW_aCh1|BzJ*i+ z0k5=7Rbst41cJ;iJ{f4<2%qksjBXgqIF%R5iow(b@4lj?+O+?&V2zm(RON!{mWg6f zK1ILV6C>n11Zg`RI)BKxX8;_QGQCQk-FY>cvs(Y9dlS=KhiZidf;@z_7R=zWm)~Pp zqa!}fG2ouTN*8axmq>zcMAZ0CJ||~093bN1asdxo{^IG3v znj|CE!!5VGSZ9m|bL~glqX$g0jjB+Z{!};eM~KVg>BS+$`dR><((Koy+s&RLu4ZEk zICr1S_6>Sn)^YR%DwV*#@qw;eeW33p-`wxdaX_4%zECRcTY6I$AP+il|E``{VoaRc zri{gkrZ|<3s;RfXZYTXNDu_jvI3b8h+lAti!B1DCH&$LGP4&AETr#teh`R(eK8w?U zr$6n@l(9mj4zC2ZVXWwD{JvQ(Gr)uxNFk$%E22ATaS+4{GM1@>P>kni84()5GXiqA zgdm*?9r)s!oF0W=qnbkcTY81ALhbL80VAMP0+K|-A>)RgSqk3z?#_KrbFF3L+&-q! z8)b_JB~f&3rds66UaX6LVo>?&yH5|YzfSt#hf}%4ODk}rZ+-ktCpFZ%Mlx6MQ&Eo< z-hMq3qSsN+2*s5+U?S!Z#*kVs1FAkbQ9?AG>hsj`xWW6k{FI5Cj0)&NT_DQV(nWWu z#-;q^q&YpcL<@=NS|u5x5$uBMy8M^)TQnd3ArQhKP1M>RSuTX3`AauQMTBzZ9a+sa zeXwmP>e7dJQ&{Hbj5uNz#9C4}xF%+vGk=#=EyijI%EE$}p?!$QSQ!-E%|<5tfgR_9 zX=H)Hv}F}7F>B__?&iEv?#De}JJ(PVZ5{uIKd6DEIJs-g4;jNRAOvNFnY0wgF#`fo zzyzYLy$p!@F4K;VI6)rNS*o{al#u*D`{ooq;=*1N5W?cWC1_TCgvkT*mh*liA3G~K z%5a>v;6c9zJuDFsHPij)$oPR9XBO8Do~zR=JCD8?Qoq~6yWa~Fi4!}n0NJ)3MT#q7 zL4@-BDJX4DB!8gzMz9KyS{)y)LODF=@kjM~Ir`M&x-&r+SHi%Ekgxi_CGS(b|L~P4 zc~jbkGU@sy4w(`#tdAC(R`NdFlAFk7qU3G^h)ZD?#_|(Qx0=5O277C+vn|;@p_;1! zG$bn;z*N0VKk5P}04FczX^a)HCGR}^_z6RNJ1nVCjYuZ$W0^X+*>%?f&DZU8)a!t>7iU7W zX`<>DR9OXuvIRgAFq34sg=qvq1(qjCN7sQl$-VY^+4C7i2BnqtGj0p~1Rk~d-(WkL zBpYAIhZ$>tj^E{V(efX?QdF52axvj7SI*aZl4&MX=tM@hC%zV-zFuT`vi4wW%RBRL z7s2jJB=$Ts0jMs0F>7s0p;p{Lr2=E8Cy#RmciLwQ_u2l5?2Ix@xya@9G(_UYqHuC3Su=*INlJnL{n0q&fFB+xm#HdT zFR>rcD*U!=lgdkX2h<0$8+RND`Qcy(q`HmY_yO_(t=$${<;_@7WT%ZCWK)ZY!sRP_ zeXhix4augZ;^|Z6sa!89131(LFv7^sNHUm;=r|=ADwIkKD+|esU+2n=A{HAK)Vqg(VSl@@NGLL(%Z|sxHO3l2Epd5 zk0ro)<+t#y8J3&HxacG(%VR!R5hx4ru*Q;*H(iCjy9T^iDyH~=<^c$O0RI)5nk|~x zozY(7PB~SEs3*5CxwMh=JrW$1Iu@@$f zE255-6YLu^?N~?PbqH8G*fg^7ecq_ZQVOB5$navxM$L*ZOk_ zTEQ3vfIYEs8V+#L0y!}W<2wq}@%QIM;=l=eX*gv@#DhWKgPC}^C(_?jzjLrP;y>&` zF`i&-^?`TF|EzSLRH)5~*2UAm1Ik@99&xT95-UIOTHea`e1pjd-XqhwcTcg>5APL z_@HXke{S9o=$U0Q;G!5O&y)=I^>n9PmLajEY)3IB09*<@WpYGx`M+#Xk9!O^V+1Vz zV$hDyjDzTDL@wP6wM9hecA+@}7c(dOWvL`A5#;KYCyIIu+P!IU{fVd8SlQUq<46Qe ztMBBOQ;EZSH`ElWlzn`ZwY3M-L~jg#3mHFkxAxOLpD&-K;GK$(6Vv-!-GNL2+A zPPKV;C95y0boe^h3 zXs1Q+Ui!{`7{>~J1~T5zMW=T`pa1;?ELwO`YT~7A#@;bia>`%-VliUTD*h+4y z;v?H@I_%_V?=dL(&=$|m?oZxj-QIi8I9%baQYf!)%c;rK0P`r%;qcyEDa&yqnj8Ly zA1lj!)5$f&xp7`-ML7=!mai4M>x5;Gd@T-`u2Va?FB6joB2l@*oY!^s`Iu^@Wf8A# zVXOK2P-%)mt6Bd5tffwwA8#UWpack=;v5f{QgQ-@m#O zwY8Yiu!P)d*~pIOfeF5#lFHl&jGe5xU(a}~seQ63#A3cq&SKsoop2n)@-@4Bma|8n zIVmAiy<3TO*4tY-p*YJ>R8WMa2YxmTj-cS|6w;rIy2j9oErmKoP?QR}3RHu@UwHIF%kO2Q?W2S0wF1WeDUAs% zX=bIaCOkckct7Kt=OQ2!W~o*1b}T=t@n@&&c}fwa%kEy*8JBuPbukyji^M>obH4AT(6e(jXx|}?A#;bLC7`6ZQMEBrsvn+61 zeg<%OLuZCe98RMXw~zL9yN))G_n93o)#pxB9<}a`swSM#ynD9dNPNp_qAW*EKQoS> zx2*1j4c0=?NNznL8PFnnog%m^Sv%`G8}^&|&mv1cDj?nzzI*R;+=6s%(jiRN;Ze!H z)&$9;$A2BJi0XcSgZslmu4!tQ@VO7uss%g<8uvg>>^}u1nmg;^++#0D^H{BdkLXy0 z%k=l}292YrmGFQKj~_&+;Y1wT+o%XXYFgO8 z6l{Sn^8)AjcDAD>}qjJT(sp#0=Xk>TXN5Gg=>OQ9R zh=Ik-yB1--gBZ{%yR$|Z6*~SV3OFK2hOX%9Iy{>=bno0CX0~MYP4ccS?SMDY(w!Wd zE(9z)s^MPAdmQ<~#eP2}*ATP+JIO4>BAHI!J>Js5pYf2~eF-u~uBq5KIQXR8OyCDX z5+{I$t9yxHV}QYl(JHai+YIoPk^VG6?ut6A$mmfzQOL79=;(S^(@7waJIym>>IC>T z{%gQeYrvaxP8WcdXBLS61>jTqetPEY51r2etF6?=En}z)i*+T*%KqD$Eg-C_qkxP< zQYt}KrA&6GQ5(c5h+>xUI~D*aumI^X)15N8FF-6(fi#0A6*l*Bs)O17%G@n)DUvmO zlWZilaSgI+Km(Ksm9r~-V*zqFf+`j`qw=PaZ8NgIYGTT_AWwD%k>Zip)<9v-*N>_W zoxc7}F{S4Ogd+Ad%_u<4D^2Cjy^=f~eug$9bure}wY0xY3Inu?Ex}IpG9>lM{li&o z#~Lwbjs#l&`e9*}SbZK`G?|R8R8z-h#62j_(@7r91eHagfSM*CJaqJSs)@pXZg)`Y zw^Mvpg~9Z5fd_NmKL2nI3ygdcUl8>3vf(Q!v}xntPR}{lazYAD2J8Pag~@%TK2{0O z3TRr#RGkIPA7AZoLebu}SUttQY#`W6J7`Ldd z3-k&y5X;qnl#^Uk<)JO5mfTQn@3mZlK9?^>zqMPc)ku$fkR8?7TD*I29`FDFN<{Ab zym0WknfVE`bBG}fQr9i4)L^)J6!v;@Umv&)zk6@$;2VO&-}}U@n*wWI^-axsm=F9p zElHIfL=+%y7700ycO8HFpI9Rzgg(>=npN!Hv(1bnGE`P@FZ7h#yyA1?r0IzuH`m6B z4<~-o6__7W$kVKY&K4Cq8b7(i&`vHJye45iq2dcl1byn@(#K+waUyTMvE^p4$L}YV zt{S!uokP@Lx_){4l&%H`<#FIn^%uI(sxWf}pc_IYKBZ-D%hXOEgNv zhcFrGD?34kkKax@{6paukg|yll>v?WE{}qb@PpE*89z{?$y<%sDLrs*wINz#J#oZC zljCQU6zo$;U{5U6Q9yR5UPy~`AN0H~?65v=4Av>+#ao*#evk(JxbFZLVz!^kJt{$% z9bxwi?fFVnCiB9Bw~GMlvm=RF*?ndcv?i-|IGzeL(%){`(y!TFZ7KYf*iOvTpm!mF z+tpXlPI|u0S4sWKqFMTs$25PSc8sOIhKdies~t&e*fCQJ2sJ#PSnW7N2HV}`R(2V) z`(w|Al5VyY#o$&wiNoi972_uHZ{L{piVEA$wHeQ02EhDweBe62w@b1?tu1(08ekAX zL|!#$uGH^1))hZp8DO~nruYA!9nP@OEAftNUo{-SPdP!cdEzTi!qYR7fsMpp)}t{L z4B~cJtJQ$}_TT?rZ)siEBY!b1ZjgGfTykKe_0Z-^z=0^@XtD*pyD)@d0$%yocaFsbqt7>!5>Ag!(oywT9K~J zx>t4rl%!kwp32hAd3$4RH-x%OcFbr?NG(2wT|ZHza5-dBbE-GC3f`czZat=C`C6_$ zJ$Id&;g^2EmczZy!ZB6(1eB3FKL^_o2QF5zpO_;>x6g{i%{EMJtSsd+CAiT(oq2Nh zV6PHiWq4b9EsTySbY3Ya@7)gbWNO$=E|V^6GHVU;fIR_$DQ>~J#I81`-yI#Z@v)`5 zW1fkHCBeH2^(Dy-m5)kk$JY&6dQ68uCV*fMp@bnF=mR%cVuaSBS_j{;9V7P+82bh# zkCT2=i8H$fm>locHAS80?D~r>U9+%A!9^qfcKWl#tEPo3oD=!8d-ItfHAyVY`7&G# zj7}^(Y@;gOoz;df`D?Jqk^{YSvfQMj|z; x>@t!!8$tP}GzC%O)H=bA0)P28{+a89an=-et$iUI6z?HO>At#Lg^Wem{{zOQj*1z#@#wfwp7)bx-}aT8kSNGRpQQm zv~g`WUa7h^N`$zUzKtu@RJ0pxvyPJL%lH0&$9%t;-#6dPH}lQ>F>}w`%S{t>3qr0#nvU{DSHGNi+JoQR)C3$L{6*rw_S3QBglQLe&eoZ$V zD@j$IqDtrLGYx?y-sHI9H8#f|m~oDSs)gtnJ_I{11g-xaNfueEYbU2eD>r4`qPArhy?R z{(DD>FOY}Cls3AnEB#Hsvyc+0Z!7Z2tw&B0bXrm6^#pByO>p%?|>Od_^~) zml&zzOWD@5L3L%Pjg<`mrC*Ad^TUGA5J=XWK$orvXi{a_duA9K-2Ao<`GZfS%FS)nXcY zUH@f9Z)P1>R9rlDyK8E5cQVQ*pfTWFI4lQ3F%yViHjJRj>wLG$Cvqfx>#%0G*iWA2 zaYkE#XF|c&M=`j9HG2A2o;#j0_NdtJLEQKLOx21{BD^>+JU3;N)@1s{_QB{`jb%+J zZHl+nQfXvd@hN;U+HnMTB-iIfvOtRh7!pCSr3(i=_2`5`(H10pFEyN5-DZB`n++2~ zCeqA>;lt12g0M5Nyu7>CZ29q0qv0tLblQfPy+N|pa*fol8LFSo0yr1KY3YFsQB-Q~ z+wPPvL%YsjVYg_Z*`M_lyBk;;Cy(0qWglohkS%Pjh2C9Aliy5+)D4J6Y6)PM&Rfla z4)!O*PwqYa*j@g7b9HYoQkoE;J#1CZb~&dc59|0EPJ?<~{?(TkW#fvFWrX&Eacx1K zOGslE96aN51IYB$eAnZtC_=Oc+z7D~3Ie;htCVK6CuJYrBYJ)NX7$zkh*sV;qQ4y# zn4k1_`5$|TK)=t<7*K@<<`Yojt)>e8qStw90tHv$`3}f|`^-u`DvZH$-gZ zPwjegiU@3vO$xu*pMXfd*rPF%UU9ptC*eu14lw@KVWPP)nbp#679)Cnc$jUxnmAEF)$b8utHKrJ5ScvU#4I%K)EzpeDNDznSpp|5{wVkhWL zImQR(7?ajycQ5`E%Nygv;5geC<@l?rT59fiRucnJsMPH+v5da<+rkDUsQH_lk5mvD z9j=I1!;ia;+92*|qkp)3G-u$$+3|}X?`%78fCZ~rMW)Noju%mFJayr8Nq5nJP|2y# zDLVbxo4J>4Dy zQ3fw3AykEII7cXqvbBq&@7i*R{YjtNGTXDA$!ot&Uf5pJEMd>VgV<&OivXG!Bp`(a z%a>#_%TxalGSt7GhoYf_`E5GwAAaFzpcvdm!e+!%;=9W;gw)of^PH7Dt9pw+A6n7t zulm1u-jVh2XRpQP7Led)(R17BG6TAHW3E91?9aYF(s%tTnCC6GFg~>=pyX>gM7>E8 xdt=s*)p^0UyEO|6+-d4~BxU64|IZUX(PwI8#ecnnT2Z_m02bqgZgmZ#{0mhYA0_|* literal 0 HcmV?d00001