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..870cbb9f --- /dev/null +++ b/apps/design_system_gallery/lib/components/buttons/split_button.dart @@ -0,0 +1,298 @@ +// ignore_for_file: experimental_member_use + +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/video.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 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, + 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(), + _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, + tooltip: 'Mute', + 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, + 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: + '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, + child: StreamSplitButton.icon( + icon: Icon(_isMuted ? icons.voiceOffFill : icons.voiceFill), + trailingIcon: Icon(_isSettingsOpen ? icons.caretUp : icons.caretDown), + style: _isMuted ? StreamButtonStyle.destructive : StreamButtonStyle.secondary, + 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..23a5d1e1 100644 --- a/packages/stream_core_flutter/CHANGELOG.md +++ b/packages/stream_core_flutter/CHANGELOG.md @@ -3,6 +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`. - 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..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,9 +22,11 @@ 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. internal_dirs: - lib/src/theme/primitives/internal - lib/src/cache/internal + - lib/src/components/buttons/internal 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..cb5d95cd --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/buttons/stream_split_button.dart @@ -0,0 +1,447 @@ +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'; +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]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 +/// 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. +@experimental +class StreamSplitButton extends StatelessWidget { + /// Creates a split button with an icon in each half. + /// + /// [icon] labels the primary half and [trailingIcon] the secondary one. + /// 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. + @experimental + StreamSplitButton.icon({ + super.key, + required Widget icon, + required Widget trailingIcon, + VoidCallback? onPressed, + VoidCallback? onTrailingPressed, + StreamButtonStyle style = .primary, + StreamButtonType type = .solid, + String? tooltip, + String? trailingTooltip, + StreamSplitButtonStyle? themeStyle, + }) : props = .new( + icon: icon, + trailingIcon: trailingIcon, + onPressed: onPressed, + onTrailingPressed: onTrailingPressed, + style: style, + type: type, + 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.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; + + /// 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; +} + +// 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, +/// 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 spacing = context.streamSpacing; + final themeStyle = context.streamSplitButtonTheme.style?.merge(props.themeStyle) ?? props.themeStyle; + 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. + final buttonStyle = resolveStreamButtonThemeStyle( + context, + style: props.style, + type: props.type, + isFloating: false, + themeStyle: 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 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. 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, + ); + + 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: _halfButtonSize, + tooltip: tooltip, + themeStyle: halfStyle, + ), + ), + ); + } + + 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, + ), + ], + ), + ), + ], + ), + ); + } +} + +// 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), + ); + } +} + +// 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); + + final BuildContext context; + + late final StreamSpacing _spacing = context.streamSpacing; + late final StreamColorScheme _colorScheme = context.streamColorScheme; + + @override + WidgetStateProperty get separatorColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return _colorScheme.borderDisabled; + return _colorScheme.borderDefault; + }); + + @override + double get separatorThickness => 1; + + // Inset from the halves by as much as the halves are inset from the surface. + @override + double get separatorHeight => _halfButtonSize.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..24d58dba --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_split_button_theme.dart @@ -0,0 +1,185 @@ +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'; +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. +@experimental +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 +@experimental +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 +@experimental +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 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. + 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/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 new file mode 100644 index 00000000..54210caf --- /dev/null +++ b/packages/stream_core_flutter/test/components/buttons/stream_split_button_golden_test.dart @@ -0,0 +1,127 @@ +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/video.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 the pressed leading half', + fileName: 'stream_split_button_pressed', + whilePerforming: press(find.byIcon(StreamIconData.voiceFill)), + builder: () => GoldenTestGroup( + children: [ + GoldenTestScenario( + name: 'pressed', + child: _buildInTheme(_splitButton(style: .secondary)), + ), + ], + ), + ); + + 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, + VoidCallback? onPressed = _noop, + VoidCallback? onTrailingPressed = _noop, +}) { + return StreamSplitButton.icon( + icon: const Icon(StreamIconData.voiceFill), + trailingIcon: const Icon(StreamIconData.caretDown), + style: style, + type: type, + 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..cac65821 --- /dev/null +++ b/packages/stream_core_flutter/test/components/buttons/stream_split_button_test.dart @@ -0,0 +1,425 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_core_flutter/video.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, + 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, + onPressed: onPressed, + onTrailingPressed: onTrailingPressed, + tooltip: tooltip, + trailingTooltip: trailingTooltip, + themeStyle: themeStyle, + ); +} + +/// 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) { + return tester.widget(_surfaceFinder()).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('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. 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( + buttonTheme: StreamButtonThemeData.all( + StreamButtonTypeStyle.all( + StreamButtonThemeStyle.from(tapTargetSize: MaterialTapTargetSize.shrinkWrap), + ), + ), + ), + _splitButton( + onPressed: () {}, + onTrailingPressed: () {}, + tooltip: 'Mute', + trailingTooltip: 'Audio settings', + ), + ), + ); + + final handle = tester.ensureSemantics(); + 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( + _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); + }); + }); +} 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(