From c153df875706c0b13274091a9aeede293bef62b9 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Sun, 8 Feb 2026 19:11:44 +0530 Subject: [PATCH 01/17] feat(ui): add StreamEmoji component --- melos.yaml | 1 + .../lib/src/components.dart | 1 + .../components/accessories/stream_emoji.dart | 137 ++++++++++++++++++ 3 files changed, 139 insertions(+) create mode 100644 packages/stream_core_flutter/lib/src/components/accessories/stream_emoji.dart diff --git a/melos.yaml b/melos.yaml index 2cd6403..ebe1e90 100644 --- a/melos.yaml +++ b/melos.yaml @@ -35,6 +35,7 @@ command: svg_icon_widget: ^0.0.1+1 synchronized: ^3.3.0 theme_extensions_builder_annotation: ^7.1.0 + unicode_emojis: ^0.5.1 uuid: ^4.5.1 web: ^1.1.1 web_socket_channel: ^3.0.1 diff --git a/packages/stream_core_flutter/lib/src/components.dart b/packages/stream_core_flutter/lib/src/components.dart index e13aac4..786a673 100644 --- a/packages/stream_core_flutter/lib/src/components.dart +++ b/packages/stream_core_flutter/lib/src/components.dart @@ -1,3 +1,4 @@ +export 'components/accessories/stream_emoji.dart'; export 'components/accessories/stream_file_type_icon.dart' hide DefaultStreamFileTypeIcon; export 'components/avatar/stream_avatar.dart' hide DefaultStreamAvatar; export 'components/avatar/stream_avatar_group.dart' hide DefaultStreamAvatarGroup; diff --git a/packages/stream_core_flutter/lib/src/components/accessories/stream_emoji.dart b/packages/stream_core_flutter/lib/src/components/accessories/stream_emoji.dart new file mode 100644 index 0000000..81d3eba --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/accessories/stream_emoji.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; + +/// Predefined sizes for emoji display. +/// +/// Each size corresponds to a specific dimension in logical pixels, +/// optimized for rendering emoji at common scales. +/// +/// See also: +/// +/// * [StreamEmoji], which uses these size variants. +enum StreamEmojiSize { + /// Small emoji (16px). + sm(16), + + /// Medium emoji (24px). + md(24), + + /// Large emoji (32px). + lg(32), + + /// Extra large emoji (48px). + xl(48), + + /// Extra extra large emoji (64px). + xxl(64) + ; + + /// Constructs a [StreamEmojiSize] with the given dimension. + const StreamEmojiSize(this.value); + + /// The dimension of the emoji in logical pixels. + final double value; +} + +/// A widget that displays an emoji or icon at a consistent size. +/// +/// [StreamEmoji] renders emoji characters or icon widgets within a fixed +/// square container. It handles platform-specific emoji font fallbacks, +/// prevents text scaling, and ensures emoji render without clipping. +/// +/// The widget accepts any [Widget] as the [emoji] parameter, making it +/// suitable for both Unicode emoji text and Material Icons. +/// +/// {@tool snippet} +/// +/// Display a Unicode emoji: +/// +/// ```dart +/// StreamEmoji( +/// size: StreamEmojiSize.lg, +/// emoji: Text('πŸ‘'), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Display a Material Icon: +/// +/// ```dart +/// StreamEmoji( +/// size: StreamEmojiSize.md, +/// emoji: Icon(Icons.favorite), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Default size (medium): +/// +/// ```dart +/// StreamEmoji(emoji: Text('πŸ”₯')) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamEmojiSize], which defines the available size variants. +class StreamEmoji extends StatelessWidget { + /// Creates an emoji display widget. + /// + /// The [emoji] parameter is required and can be any widget (typically + /// [Text] for emoji characters or [Icon] for Material icons). + /// + /// If [size] is not provided, defaults to [StreamEmojiSize.md]. + const StreamEmoji({ + super.key, + this.size = .md, + required this.emoji, + }); + + /// The size of the emoji container. + /// + /// Determines the width and height of the square container. + /// Defaults to [StreamEmojiSize.md] (24px). + final StreamEmojiSize size; + + /// The emoji or icon widget to display. + /// + /// Typically a [Text] widget containing a Unicode emoji character, + /// or an [Icon] widget for Material Design icons. + final Widget emoji; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: size.value, + height: size.value, + child: Center( + child: MediaQuery.withNoTextScaling( + child: FittedBox( + fit: .scaleDown, + child: DefaultTextStyle.merge( + textAlign: .center, + style: TextStyle( + fontSize: size.value, + height: 1, + // Commonly available fallback fonts for emoji rendering. + fontFamilyFallback: const [ + 'Apple Color Emoji', // iOS and macOS. + 'Noto Color Emoji', // Android, ChromeOS, Ubuntu, Linux. + 'Segoe UI Emoji', // Windows. + ], + ), + textHeightBehavior: const TextHeightBehavior( + applyHeightToFirstAscent: false, + applyHeightToLastDescent: false, + ), + child: emoji, + ), + ), + ), + ), + ); + } +} From aa959e2af50566c1df6f3376017bcd987eafa897 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 9 Feb 2026 03:22:00 +0530 Subject: [PATCH 02/17] feat(ui): add StreamEmojiButton component  Conflicts:  packages/stream_core_flutter/lib/src/theme/stream_theme.dart  packages/stream_core_flutter/lib/src/theme/stream_theme.g.theme.dart  packages/stream_core_flutter/lib/src/theme/stream_theme_extensions.dart --- .../lib/app/gallery_app.directories.g.dart | 37 +++ .../lib/src/components.dart | 1 + .../buttons/stream_emoji_button.dart | 235 ++++++++++++++++++ .../src/factory/stream_component_factory.dart | 6 + .../stream_component_factory.g.theme.dart | 6 + .../stream_core_flutter/lib/src/theme.dart | 1 + .../components/stream_emoji_button_theme.dart | 178 +++++++++++++ .../stream_emoji_button_theme.g.theme.dart | 77 ++++++ .../lib/src/theme/stream_theme.dart | 8 + .../lib/src/theme/stream_theme.g.theme.dart | 9 + .../src/theme/stream_theme_extensions.dart | 4 + 11 files changed, 562 insertions(+) create mode 100644 packages/stream_core_flutter/lib/src/components/buttons/stream_emoji_button.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_emoji_button_theme.dart create mode 100644 packages/stream_core_flutter/lib/src/theme/components/stream_emoji_button_theme.g.theme.dart diff --git a/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart b/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart index 6ca8e76..a131822 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 @@ -14,6 +14,8 @@ import 'package:design_system_gallery/components/accessories/stream_file_type_ic as _design_system_gallery_components_accessories_stream_file_type_icons; import 'package:design_system_gallery/components/button.dart' as _design_system_gallery_components_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/message_composer/message_composer.dart' as _design_system_gallery_components_message_composer_message_composer; import 'package:design_system_gallery/components/message_composer/message_composer_attachment_link_preview.dart' @@ -32,6 +34,8 @@ import 'package:design_system_gallery/components/stream_badge_count.dart' as _design_system_gallery_components_stream_badge_count; import 'package:design_system_gallery/components/stream_online_indicator.dart' as _design_system_gallery_components_stream_online_indicator; +import 'package:design_system_gallery/components/stream_reaction_picker_sheet.dart' + as _design_system_gallery_components_stream_reaction_picker_sheet; import 'package:design_system_gallery/primitives/colors.dart' as _design_system_gallery_primitives_colors; import 'package:design_system_gallery/primitives/icons.dart' @@ -280,6 +284,23 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookComponent( + name: 'StreamEmojiButton', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_buttons_stream_emoji_button + .buildStreamEmojiButtonPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_buttons_stream_emoji_button + .buildStreamEmojiButtonShowcase, + ), + ], + ), ], ), _widgetbook.WidgetbookFolder( @@ -359,6 +380,22 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookFolder( + name: 'Reaction', + children: [ + _widgetbook.WidgetbookComponent( + name: 'Widget', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_stream_reaction_picker_sheet + .buildStreamReactionPickerSheetDefault, + ), + ], + ), + ], + ), ], ), ]; diff --git a/packages/stream_core_flutter/lib/src/components.dart b/packages/stream_core_flutter/lib/src/components.dart index 786a673..5a055ad 100644 --- a/packages/stream_core_flutter/lib/src/components.dart +++ b/packages/stream_core_flutter/lib/src/components.dart @@ -6,6 +6,7 @@ export 'components/avatar/stream_avatar_stack.dart' hide DefaultStreamAvatarStac export 'components/badge/stream_badge_count.dart' hide DefaultStreamBadgeCount; export 'components/badge/stream_online_indicator.dart' hide DefaultStreamOnlineIndicator; export 'components/buttons/stream_button.dart' hide DefaultStreamButton; +export 'components/buttons/stream_emoji_button.dart' hide DefaultStreamEmojiButton; export 'components/message_composer.dart'; export 'factory/stream_component_factory.dart'; diff --git a/packages/stream_core_flutter/lib/src/components/buttons/stream_emoji_button.dart b/packages/stream_core_flutter/lib/src/components/buttons/stream_emoji_button.dart new file mode 100644 index 0000000..eaa614e --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/buttons/stream_emoji_button.dart @@ -0,0 +1,235 @@ +import 'package:flutter/material.dart'; + +import '../../factory/stream_component_factory.dart'; +import '../../theme/components/stream_emoji_button_theme.dart'; +import '../../theme/primitives/stream_colors.dart'; +import '../../theme/semantics/stream_color_scheme.dart'; +import '../../theme/stream_theme_extensions.dart'; +import '../accessories/stream_emoji.dart'; + +/// A tappable circular button that displays an emoji or icon. +/// +/// Used within reaction pickers and other emoji selectors to render +/// individual emoji options with consistent sizing and styling. +/// +/// The button adapts its appearance based on interaction state (hover, +/// pressed, disabled, selected, focused). All states can be customized +/// via [StreamEmojiButtonTheme]. +/// +/// The button size can be controlled via [size] or globally through +/// the theme. +/// +/// {@tool snippet} +/// +/// Display an emoji button: +/// +/// ```dart +/// StreamEmojiButton( +/// emoji: Text('πŸ‘'), +/// onPressed: () => print('thumbs up selected'), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Display a selected emoji button: +/// +/// ```dart +/// StreamEmojiButton( +/// emoji: Text('❀️'), +/// isSelected: true, +/// onPressed: () => print('heart selected'), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// With long press for skin tone variants: +/// +/// ```dart +/// StreamEmojiButton( +/// emoji: Text('πŸ‘'), +/// onPressed: () => addReaction('πŸ‘'), +/// onLongPress: () => showSkinTonePicker('πŸ‘'), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamEmojiButtonTheme], for customizing emoji button appearance. +/// * [StreamEmojiButtonSize], for available size variants. +/// * [StreamEmoji], the component used to render the emoji. +class StreamEmojiButton extends StatelessWidget { + /// Creates an emoji button. + StreamEmojiButton({ + super.key, + StreamEmojiButtonSize? size, + required Widget emoji, + VoidCallback? onPressed, + VoidCallback? onLongPress, + bool? isSelected, + }) : props = .new( + size: size, + emoji: emoji, + onPressed: onPressed, + onLongPress: onLongPress, + isSelected: isSelected, + ); + + /// The props controlling the appearance and behavior of this emoji button. + final StreamEmojiButtonProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.maybeOf(context)?.emojiButton; + if (builder != null) return builder(context, props); + return DefaultStreamEmojiButton(props: props); + } +} + +/// Properties for configuring a [StreamEmojiButton]. +/// +/// This class holds all the configuration options for an emoji button, +/// allowing them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamEmojiButton], which uses these properties. +/// * [DefaultStreamEmojiButton], the default implementation. +class StreamEmojiButtonProps { + /// Creates properties for an emoji button. + const StreamEmojiButtonProps({ + this.size, + required this.emoji, + this.onPressed, + this.onLongPress, + this.isSelected, + }); + + /// The size of the emoji button. + /// + /// If null, falls back to [StreamEmojiButtonThemeStyle.size], then + /// [StreamEmojiButtonSize.xl]. + final StreamEmojiButtonSize? size; + + /// The emoji or icon widget to display. + final Widget emoji; + + /// Called when the emoji button is pressed. + /// + /// If null, the button will be disabled. + final VoidCallback? onPressed; + + /// Called when the emoji button is long-pressed. + /// + /// Commonly used to show skin tone variants or emoji details. + final VoidCallback? onLongPress; + + /// Whether the button is in a selected state. + /// + /// When true, the button displays selected styling. + /// When false or null, the button is not selected. + final bool? isSelected; +} + +/// Default implementation of [StreamEmojiButton]. +/// +/// Renders the emoji using [StreamEmoji] with theme-aware styling and +/// state-based visual feedback. +class DefaultStreamEmojiButton extends StatelessWidget { + /// Creates a default emoji button. + const DefaultStreamEmojiButton({super.key, required this.props}); + + /// The props controlling the appearance and behavior of this emoji button. + final StreamEmojiButtonProps props; + + @override + Widget build(BuildContext context) { + final emojiButtonStyle = context.streamEmojiButtonTheme.style; + final defaults = _StreamEmojiButtonThemeDefaults(context); + + final effectiveSize = props.size ?? emojiButtonStyle?.size ?? defaults.size; + final effectiveBackgroundColor = emojiButtonStyle?.backgroundColor ?? defaults.backgroundColor; + final effectiveForegroundColor = emojiButtonStyle?.foregroundColor ?? defaults.foregroundColor; + final effectiveOverlayColor = emojiButtonStyle?.overlayColor ?? defaults.overlayColor; + final effectiveSide = emojiButtonStyle?.side ?? defaults.side; + + final emojiSize = _emojiSizeForButtonSize(effectiveSize); + + return IconButton( + onPressed: props.onPressed, + onLongPress: props.onLongPress, + isSelected: props.isSelected, + icon: StreamEmoji(size: emojiSize, emoji: props.emoji), + style: ButtonStyle( + fixedSize: .all(.square(effectiveSize.value)), + minimumSize: .all(.square(effectiveSize.value)), + maximumSize: .all(.square(effectiveSize.value)), + padding: .all(EdgeInsets.zero), + shape: .all(const CircleBorder()), + backgroundColor: effectiveBackgroundColor, + foregroundColor: effectiveForegroundColor, + overlayColor: effectiveOverlayColor, + side: effectiveSide, + ), + ); + } + + // Returns the appropriate emoji size for the given button size. + StreamEmojiSize _emojiSizeForButtonSize( + StreamEmojiButtonSize buttonSize, + ) => switch (buttonSize) { + .md => StreamEmojiSize.sm, + .lg => StreamEmojiSize.md, + .xl => StreamEmojiSize.lg, + }; +} + +// Provides default values for [StreamEmojiButtonThemeStyle] based on +// the current [StreamColorScheme]. +class _StreamEmojiButtonThemeDefaults extends StreamEmojiButtonThemeStyle { + _StreamEmojiButtonThemeDefaults( + this.context, + ) : _colorScheme = context.streamColorScheme; + + final BuildContext context; + final StreamColorScheme _colorScheme; + + @override + StreamEmojiButtonSize get size => StreamEmojiButtonSize.xl; + + @override + WidgetStateProperty get backgroundColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return StreamColors.transparent; + if (states.contains(WidgetState.selected)) return _colorScheme.stateSelected; + return StreamColors.transparent; + }); + + @override + WidgetStateProperty get foregroundColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return _colorScheme.stateDisabled; + return null; // Let emoji/icon use its natural color + }); + + @override + WidgetStateProperty get overlayColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) return _colorScheme.statePressed; + if (states.contains(WidgetState.hovered)) return _colorScheme.stateHover; + return StreamColors.transparent; + }); + + @override + WidgetStateBorderSide? get side => WidgetStateBorderSide.resolveWith((states) { + if (states.contains(WidgetState.focused)) { + return BorderSide( + width: 2, + color: _colorScheme.borderFocus, + strokeAlign: BorderSide.strokeAlignOutside, + ); + } + return BorderSide.none; + }); +} 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 f254c4f..96f310d 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 @@ -155,6 +155,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { this.avatarStack, this.badgeCount, this.button, + this.emojiButton, this.fileTypeIcon, this.onlineIndicator, }); @@ -184,6 +185,11 @@ class StreamComponentBuilders with _$StreamComponentBuilders { /// When null, [StreamButton] uses [DefaultStreamButton]. final StreamComponentBuilder? button; + /// Custom builder for emoji button widgets. + /// + /// When null, [StreamEmojiButton] uses [DefaultStreamEmojiButton]. + final StreamComponentBuilder? emojiButton; + /// Custom builder for file type icon widgets. /// /// When null, [StreamFileTypeIcon] uses [DefaultStreamFileTypeIcon]. 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 ed21e32..189f254 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 @@ -35,6 +35,7 @@ mixin _$StreamComponentBuilders { avatarStack: t < 0.5 ? a.avatarStack : b.avatarStack, badgeCount: t < 0.5 ? a.badgeCount : b.badgeCount, button: t < 0.5 ? a.button : b.button, + emojiButton: t < 0.5 ? a.emojiButton : b.emojiButton, fileTypeIcon: t < 0.5 ? a.fileTypeIcon : b.fileTypeIcon, onlineIndicator: t < 0.5 ? a.onlineIndicator : b.onlineIndicator, ); @@ -46,6 +47,7 @@ mixin _$StreamComponentBuilders { Widget Function(BuildContext, StreamAvatarStackProps)? avatarStack, Widget Function(BuildContext, StreamBadgeCountProps)? badgeCount, Widget Function(BuildContext, StreamButtonProps)? button, + Widget Function(BuildContext, StreamEmojiButtonProps)? emojiButton, Widget Function(BuildContext, StreamFileTypeIconProps)? fileTypeIcon, Widget Function(BuildContext, StreamOnlineIndicatorProps)? onlineIndicator, }) { @@ -57,6 +59,7 @@ mixin _$StreamComponentBuilders { avatarStack: avatarStack ?? _this.avatarStack, badgeCount: badgeCount ?? _this.badgeCount, button: button ?? _this.button, + emojiButton: emojiButton ?? _this.emojiButton, fileTypeIcon: fileTypeIcon ?? _this.fileTypeIcon, onlineIndicator: onlineIndicator ?? _this.onlineIndicator, ); @@ -79,6 +82,7 @@ mixin _$StreamComponentBuilders { avatarStack: other.avatarStack, badgeCount: other.badgeCount, button: other.button, + emojiButton: other.emojiButton, fileTypeIcon: other.fileTypeIcon, onlineIndicator: other.onlineIndicator, ); @@ -102,6 +106,7 @@ mixin _$StreamComponentBuilders { _other.avatarStack == _this.avatarStack && _other.badgeCount == _this.badgeCount && _other.button == _this.button && + _other.emojiButton == _this.emojiButton && _other.fileTypeIcon == _this.fileTypeIcon && _other.onlineIndicator == _this.onlineIndicator; } @@ -117,6 +122,7 @@ mixin _$StreamComponentBuilders { _this.avatarStack, _this.badgeCount, _this.button, + _this.emojiButton, _this.fileTypeIcon, _this.onlineIndicator, ); diff --git a/packages/stream_core_flutter/lib/src/theme.dart b/packages/stream_core_flutter/lib/src/theme.dart index d360881..28c9752 100644 --- a/packages/stream_core_flutter/lib/src/theme.dart +++ b/packages/stream_core_flutter/lib/src/theme.dart @@ -3,6 +3,7 @@ export 'factory/stream_component_factory.dart'; export 'theme/components/stream_avatar_theme.dart'; export 'theme/components/stream_badge_count_theme.dart'; export 'theme/components/stream_button_theme.dart'; +export 'theme/components/stream_emoji_button_theme.dart'; export 'theme/components/stream_online_indicator_theme.dart'; export 'theme/primitives/stream_colors.dart'; diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_button_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_button_theme.dart new file mode 100644 index 0000000..9ed3029 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_button_theme.dart @@ -0,0 +1,178 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; + +part 'stream_emoji_button_theme.g.theme.dart'; + +/// Predefined sizes for the emoji button. +/// +/// Each size corresponds to a specific diameter in logical pixels. +/// +/// See also: +/// +/// * [StreamEmojiButtonThemeStyle.size], for setting a global default size. +enum StreamEmojiButtonSize { + /// Medium button (32px diameter). + md(32), + + /// Large button (40px diameter). + lg(40), + + /// Extra large button (48px diameter). + xl(48) + ; + + /// Constructs a [StreamEmojiButtonSize] with the given diameter. + const StreamEmojiButtonSize(this.value); + + /// The diameter of the button in logical pixels. + final double value; +} + +/// Applies an emoji button theme to descendant emoji button widgets. +/// +/// Wrap a subtree with [StreamEmojiButtonTheme] to override emoji button +/// styling. Access the merged theme using +/// [BuildContext.streamEmojiButtonTheme]. +/// +/// {@tool snippet} +/// +/// Override emoji button styling for a specific section: +/// +/// ```dart +/// StreamEmojiButtonTheme( +/// data: StreamEmojiButtonThemeData( +/// style: StreamEmojiButtonThemeStyle( +/// size: StreamEmojiButtonSize.lg, +/// ), +/// ), +/// child: StreamEmojiButton( +/// emoji: Text('πŸ‘'), +/// onPressed: () {}, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamEmojiButtonThemeData], which describes the emoji button theme. +class StreamEmojiButtonTheme extends InheritedTheme { + /// Creates an emoji button theme that controls descendant emoji buttons. + const StreamEmojiButtonTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The emoji button theme data for descendant widgets. + final StreamEmojiButtonThemeData data; + + /// Returns the [StreamEmojiButtonThemeData] from the current theme context. + /// + /// This merges the local theme (if any) with the global theme from [StreamTheme]. + static StreamEmojiButtonThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).emojiButtonTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamEmojiButtonTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamEmojiButtonTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing emoji button widgets. +/// +/// {@tool snippet} +/// +/// Customize emoji button appearance globally: +/// +/// ```dart +/// StreamTheme( +/// emojiButtonTheme: StreamEmojiButtonThemeData( +/// style: StreamEmojiButtonThemeStyle( +/// size: StreamEmojiButtonSize.lg, +/// backgroundColor: WidgetStateProperty.resolveWith((states) { +/// if (states.contains(WidgetState.hovered)) { +/// return Colors.grey.shade200; +/// } +/// return Colors.transparent; +/// }), +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamEmojiButtonTheme], for overriding theme in a widget subtree. +@themeGen +@immutable +class StreamEmojiButtonThemeData with _$StreamEmojiButtonThemeData { + /// Creates an emoji button theme with optional style overrides. + const StreamEmojiButtonThemeData({this.style}); + + /// The visual styling for emoji buttons. + /// + /// Contains size, background, foreground, overlay colors and border styling. + final StreamEmojiButtonThemeStyle? style; + + /// Linearly interpolate between two [StreamEmojiButtonThemeData] objects. + static StreamEmojiButtonThemeData? lerp( + StreamEmojiButtonThemeData? a, + StreamEmojiButtonThemeData? b, + double t, + ) => _$StreamEmojiButtonThemeData.lerp(a, b, t); +} + +/// Visual styling properties for emoji buttons. +/// +/// Defines the appearance of emoji buttons including size, colors, and borders. +/// All color properties support state-based styling for interactive feedback. +/// +/// See also: +/// +/// * [StreamEmojiButtonThemeData], which wraps this style for theming. +/// * [StreamEmojiButton], which uses this styling. +class StreamEmojiButtonThemeStyle { + /// Creates emoji button style properties. + const StreamEmojiButtonThemeStyle({ + this.size, + this.backgroundColor, + this.foregroundColor, + this.overlayColor, + this.side, + }); + + /// The size of emoji buttons. + /// + /// Falls back to [StreamEmojiButtonSize.xl]. + final StreamEmojiButtonSize? size; + + /// The background color for emoji buttons. + /// + /// Supports state-based colors for different interaction states + /// (default, hover, pressed, disabled, selected). + final WidgetStateProperty? backgroundColor; + + /// The foreground color for emoji/icon content. + /// + /// Supports state-based colors for different interaction states. + final WidgetStateProperty? foregroundColor; + + /// The overlay color for the button's interaction feedback. + /// + /// Supports state-based colors for hover and press states. + final WidgetStateProperty? overlayColor; + + /// The border for the button. + /// + /// Supports state-based borders for different interaction states. + final WidgetStateBorderSide? side; +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_button_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_button_theme.g.theme.dart new file mode 100644 index 0000000..6c11f9b --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_button_theme.g.theme.dart @@ -0,0 +1,77 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_emoji_button_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamEmojiButtonThemeData { + bool get canMerge => true; + + static StreamEmojiButtonThemeData? lerp( + StreamEmojiButtonThemeData? a, + StreamEmojiButtonThemeData? 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 StreamEmojiButtonThemeData(style: t < 0.5 ? a.style : b.style); + } + + StreamEmojiButtonThemeData copyWith({StreamEmojiButtonThemeStyle? style}) { + final _this = (this as StreamEmojiButtonThemeData); + + return StreamEmojiButtonThemeData(style: style ?? _this.style); + } + + StreamEmojiButtonThemeData merge(StreamEmojiButtonThemeData? other) { + final _this = (this as StreamEmojiButtonThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith(style: other.style); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamEmojiButtonThemeData); + final _other = (other as StreamEmojiButtonThemeData); + + return _other.style == _this.style; + } + + @override + int get hashCode { + final _this = (this as StreamEmojiButtonThemeData); + + return Object.hash(runtimeType, _this.style); + } +} 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 0497c6b..d2fed3e 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -7,6 +7,7 @@ import 'package:theme_extensions_builder_annotation/theme_extensions_builder_ann import 'components/stream_avatar_theme.dart'; import 'components/stream_badge_count_theme.dart'; import 'components/stream_button_theme.dart'; +import 'components/stream_emoji_button_theme.dart'; import 'components/stream_input_theme.dart'; import 'components/stream_message_theme.dart'; import 'components/stream_online_indicator_theme.dart'; @@ -87,6 +88,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { StreamAvatarThemeData? avatarTheme, StreamBadgeCountThemeData? badgeCountTheme, StreamButtonThemeData? buttonTheme, + StreamEmojiButtonThemeData? emojiButtonTheme, StreamMessageThemeData? messageTheme, StreamInputThemeData? inputTheme, StreamOnlineIndicatorThemeData? onlineIndicatorTheme, @@ -109,6 +111,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { avatarTheme ??= const StreamAvatarThemeData(); badgeCountTheme ??= const StreamBadgeCountThemeData(); buttonTheme ??= const StreamButtonThemeData(); + emojiButtonTheme ??= const StreamEmojiButtonThemeData(); messageTheme ??= const StreamMessageThemeData(); inputTheme ??= const StreamInputThemeData(); onlineIndicatorTheme ??= const StreamOnlineIndicatorThemeData(); @@ -125,6 +128,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { avatarTheme: avatarTheme, badgeCountTheme: badgeCountTheme, buttonTheme: buttonTheme, + emojiButtonTheme: emojiButtonTheme, messageTheme: messageTheme, inputTheme: inputTheme, onlineIndicatorTheme: onlineIndicatorTheme, @@ -155,6 +159,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { required this.avatarTheme, required this.badgeCountTheme, required this.buttonTheme, + required this.emojiButtonTheme, required this.messageTheme, required this.inputTheme, required this.onlineIndicatorTheme, @@ -227,6 +232,9 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// The button theme for this theme. final StreamButtonThemeData buttonTheme; + /// The emoji button theme for this theme. + final StreamEmojiButtonThemeData emojiButtonTheme; + /// The message theme for this theme. final StreamMessageThemeData messageTheme; 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 c4c1290..5de1b43 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 @@ -23,6 +23,7 @@ mixin _$StreamTheme on ThemeExtension { StreamAvatarThemeData? avatarTheme, StreamBadgeCountThemeData? badgeCountTheme, StreamButtonThemeData? buttonTheme, + StreamEmojiButtonThemeData? emojiButtonTheme, StreamMessageThemeData? messageTheme, StreamInputThemeData? inputTheme, StreamOnlineIndicatorThemeData? onlineIndicatorTheme, @@ -41,6 +42,7 @@ mixin _$StreamTheme on ThemeExtension { avatarTheme: avatarTheme ?? _this.avatarTheme, badgeCountTheme: badgeCountTheme ?? _this.badgeCountTheme, buttonTheme: buttonTheme ?? _this.buttonTheme, + emojiButtonTheme: emojiButtonTheme ?? _this.emojiButtonTheme, messageTheme: messageTheme ?? _this.messageTheme, inputTheme: inputTheme ?? _this.inputTheme, onlineIndicatorTheme: onlineIndicatorTheme ?? _this.onlineIndicatorTheme, @@ -79,6 +81,11 @@ mixin _$StreamTheme on ThemeExtension { t, )!, buttonTheme: t < 0.5 ? _this.buttonTheme : other.buttonTheme, + emojiButtonTheme: StreamEmojiButtonThemeData.lerp( + _this.emojiButtonTheme, + other.emojiButtonTheme, + t, + )!, messageTheme: t < 0.5 ? _this.messageTheme : other.messageTheme, inputTheme: t < 0.5 ? _this.inputTheme : other.inputTheme, onlineIndicatorTheme: StreamOnlineIndicatorThemeData.lerp( @@ -113,6 +120,7 @@ mixin _$StreamTheme on ThemeExtension { _other.avatarTheme == _this.avatarTheme && _other.badgeCountTheme == _this.badgeCountTheme && _other.buttonTheme == _this.buttonTheme && + _other.emojiButtonTheme == _this.emojiButtonTheme && _other.messageTheme == _this.messageTheme && _other.inputTheme == _this.inputTheme && _other.onlineIndicatorTheme == _this.onlineIndicatorTheme; @@ -135,6 +143,7 @@ mixin _$StreamTheme on ThemeExtension { _this.avatarTheme, _this.badgeCountTheme, _this.buttonTheme, + _this.emojiButtonTheme, _this.messageTheme, _this.inputTheme, _this.onlineIndicatorTheme, 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 dbc6cab..6f71715 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 @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart'; import 'components/stream_avatar_theme.dart'; import 'components/stream_badge_count_theme.dart'; import 'components/stream_button_theme.dart'; +import 'components/stream_emoji_button_theme.dart'; import 'components/stream_input_theme.dart'; import 'components/stream_message_theme.dart'; import 'components/stream_online_indicator_theme.dart'; @@ -69,6 +70,9 @@ extension StreamThemeExtension on BuildContext { /// Returns the [StreamButtonThemeData] from the nearest ancestor. StreamButtonThemeData get streamButtonTheme => StreamButtonTheme.of(this); + /// Returns the [StreamEmojiButtonThemeData] from the nearest ancestor. + StreamEmojiButtonThemeData get streamEmojiButtonTheme => StreamEmojiButtonTheme.of(this); + /// Returns the [StreamMessageThemeData] from the nearest ancestor. StreamMessageThemeData get streamMessageTheme => StreamMessageTheme.of(this); From 7d03ac3b98392467ef233dcb58980630bdd4bf23 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Mon, 9 Feb 2026 04:11:22 +0530 Subject: [PATCH 03/17] feat(ui): enhance StreamEmojiButtonTheme with interpolation and merging capabilities --- .../components/stream_emoji_button_theme.dart | 11 +- .../stream_emoji_button_theme.g.theme.dart | 123 +++++++++++++++++- 2 files changed, 131 insertions(+), 3 deletions(-) diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_button_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_button_theme.dart index 9ed3029..2dd7c2d 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_button_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_button_theme.dart @@ -140,7 +140,9 @@ class StreamEmojiButtonThemeData with _$StreamEmojiButtonThemeData { /// /// * [StreamEmojiButtonThemeData], which wraps this style for theming. /// * [StreamEmojiButton], which uses this styling. -class StreamEmojiButtonThemeStyle { +@themeGen +@immutable +class StreamEmojiButtonThemeStyle with _$StreamEmojiButtonThemeStyle { /// Creates emoji button style properties. const StreamEmojiButtonThemeStyle({ this.size, @@ -175,4 +177,11 @@ class StreamEmojiButtonThemeStyle { /// /// Supports state-based borders for different interaction states. final WidgetStateBorderSide? side; + + /// Linearly interpolate between two [StreamEmojiButtonThemeStyle] objects. + static StreamEmojiButtonThemeStyle? lerp( + StreamEmojiButtonThemeStyle? a, + StreamEmojiButtonThemeStyle? b, + double t, + ) => _$StreamEmojiButtonThemeStyle.lerp(a, b, t); } diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_button_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_button_theme.g.theme.dart index 6c11f9b..aaa1978 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_button_theme.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_button_theme.g.theme.dart @@ -29,7 +29,9 @@ mixin _$StreamEmojiButtonThemeData { return t == 0.0 ? a : null; } - return StreamEmojiButtonThemeData(style: t < 0.5 ? a.style : b.style); + return StreamEmojiButtonThemeData( + style: StreamEmojiButtonThemeStyle.lerp(a.style, b.style, t), + ); } StreamEmojiButtonThemeData copyWith({StreamEmojiButtonThemeStyle? style}) { @@ -49,7 +51,7 @@ mixin _$StreamEmojiButtonThemeData { return other; } - return copyWith(style: other.style); + return copyWith(style: _this.style?.merge(other.style) ?? other.style); } @override @@ -75,3 +77,120 @@ mixin _$StreamEmojiButtonThemeData { return Object.hash(runtimeType, _this.style); } } + +mixin _$StreamEmojiButtonThemeStyle { + bool get canMerge => true; + + static StreamEmojiButtonThemeStyle? lerp( + StreamEmojiButtonThemeStyle? a, + StreamEmojiButtonThemeStyle? 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 StreamEmojiButtonThemeStyle( + size: t < 0.5 ? a.size : b.size, + backgroundColor: WidgetStateProperty.lerp( + a.backgroundColor, + b.backgroundColor, + t, + Color.lerp, + ), + foregroundColor: WidgetStateProperty.lerp( + a.foregroundColor, + b.foregroundColor, + t, + Color.lerp, + ), + overlayColor: WidgetStateProperty.lerp( + a.overlayColor, + b.overlayColor, + t, + Color.lerp, + ), + side: WidgetStateBorderSide.lerp(a.side, b.side, t), + ); + } + + StreamEmojiButtonThemeStyle copyWith({ + StreamEmojiButtonSize? size, + WidgetStateProperty? backgroundColor, + WidgetStateProperty? foregroundColor, + WidgetStateProperty? overlayColor, + WidgetStateBorderSide? side, + }) { + final _this = (this as StreamEmojiButtonThemeStyle); + + return StreamEmojiButtonThemeStyle( + size: size ?? _this.size, + backgroundColor: backgroundColor ?? _this.backgroundColor, + foregroundColor: foregroundColor ?? _this.foregroundColor, + overlayColor: overlayColor ?? _this.overlayColor, + side: side ?? _this.side, + ); + } + + StreamEmojiButtonThemeStyle merge(StreamEmojiButtonThemeStyle? other) { + final _this = (this as StreamEmojiButtonThemeStyle); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + size: other.size, + backgroundColor: other.backgroundColor, + foregroundColor: other.foregroundColor, + overlayColor: other.overlayColor, + side: other.side, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamEmojiButtonThemeStyle); + final _other = (other as StreamEmojiButtonThemeStyle); + + return _other.size == _this.size && + _other.backgroundColor == _this.backgroundColor && + _other.foregroundColor == _this.foregroundColor && + _other.overlayColor == _this.overlayColor && + _other.side == _this.side; + } + + @override + int get hashCode { + final _this = (this as StreamEmojiButtonThemeStyle); + + return Object.hash( + runtimeType, + _this.size, + _this.backgroundColor, + _this.foregroundColor, + _this.overlayColor, + _this.side, + ); + } +} From f85b6786145a70e0333486c2beddc0246efbc6e9 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 11 Feb 2026 22:03:59 +0530 Subject: [PATCH 04/17] refactor(ui): enhance StreamEmoji with factory support and flexible sizing - Refactors `StreamEmoji` to integrate with `StreamComponentFactory`, introducing `StreamEmojiProps` and `DefaultStreamEmoji` for customization. - Improves sizing logic to respect the ambient `IconTheme.of(context).size`, simplifying its use within `IconButton`. - Updates documentation with new usage examples and best practices. --- .../components/accessories/stream_emoji.dart | 96 ++++++++++++++++--- 1 file changed, 81 insertions(+), 15 deletions(-) diff --git a/packages/stream_core_flutter/lib/src/components/accessories/stream_emoji.dart b/packages/stream_core_flutter/lib/src/components/accessories/stream_emoji.dart index 81d3eba..f9d24e7 100644 --- a/packages/stream_core_flutter/lib/src/components/accessories/stream_emoji.dart +++ b/packages/stream_core_flutter/lib/src/components/accessories/stream_emoji.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import '../../../stream_core_flutter.dart'; +import '../../factory/stream_component_factory.dart'; + /// Predefined sizes for emoji display. /// /// Each size corresponds to a specific dimension in logical pixels, @@ -67,46 +70,108 @@ enum StreamEmojiSize { /// /// {@tool snippet} /// -/// Default size (medium): +/// Default size (uses IconTheme or medium): /// /// ```dart /// StreamEmoji(emoji: Text('πŸ”₯')) /// ``` /// {@end-tool} /// +/// {@tool snippet} +/// +/// Use with IconButton (size controlled via iconSize): +/// +/// ```dart +/// IconButton( +/// iconSize: 32, // Size applied to StreamEmoji via IconTheme +/// icon: StreamEmoji(emoji: Text('πŸ‘')), +/// onPressed: () {}, +/// ) +/// ``` +/// {@end-tool} +/// +/// **Best Practice:** When using `StreamEmoji` inside an `IconButton`, set the +/// size using `IconButton.iconSize` instead of `StreamEmoji.size`. The emoji +/// will automatically inherit the size from the button's `IconTheme`. +/// /// See also: /// /// * [StreamEmojiSize], which defines the available size variants. class StreamEmoji extends StatelessWidget { /// Creates an emoji display widget. - /// - /// The [emoji] parameter is required and can be any widget (typically - /// [Text] for emoji characters or [Icon] for Material icons). - /// - /// If [size] is not provided, defaults to [StreamEmojiSize.md]. - const StreamEmoji({ + StreamEmoji({ super.key, - this.size = .md, + StreamEmojiSize? size, + required Widget emoji, + }) : props = .new(size: size, emoji: emoji); + + /// The props controlling the appearance of this emoji. + final StreamEmojiProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.maybeOf(context)?.emoji; + if (builder != null) return builder(context, props); + return DefaultStreamEmoji(props: props); + } +} + +/// Properties for configuring a [StreamEmoji]. +/// +/// This class holds all the configuration options for an emoji display, +/// allowing them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamEmoji], which uses these properties. +@immutable +class StreamEmojiProps { + /// Creates emoji properties. + const StreamEmojiProps({ + required this.size, required this.emoji, }); /// The size of the emoji container. /// - /// Determines the width and height of the square container. - /// Defaults to [StreamEmojiSize.md] (24px). - final StreamEmojiSize size; + /// If null, uses [IconTheme.of(context).size] if available, + /// otherwise defaults to [StreamEmojiSize.md] (24px). + final StreamEmojiSize? size; /// The emoji or icon widget to display. /// /// Typically a [Text] widget containing a Unicode emoji character, /// or an [Icon] widget for Material Design icons. final Widget emoji; +} + +/// Default implementation of [StreamEmoji]. +/// +/// This is the standard emoji display widget used when no custom builder +/// is provided via [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamEmoji], which delegates to this widget by default. +/// * [StreamComponentFactory], for providing custom emoji builders. +class DefaultStreamEmoji extends StatelessWidget { + /// Creates a default emoji display widget. + const DefaultStreamEmoji({ + super.key, + required this.props, + }); + + /// The props controlling the appearance of this emoji. + final StreamEmojiProps props; @override Widget build(BuildContext context) { + final iconTheme = IconTheme.of(context); + final effectiveSize = props.size?.value ?? iconTheme.size ?? StreamEmojiSize.md.value; + return SizedBox( - width: size.value, - height: size.value, + width: effectiveSize, + height: effectiveSize, child: Center( child: MediaQuery.withNoTextScaling( child: FittedBox( @@ -114,8 +179,9 @@ class StreamEmoji extends StatelessWidget { child: DefaultTextStyle.merge( textAlign: .center, style: TextStyle( - fontSize: size.value, height: 1, + decoration: .none, + fontSize: effectiveSize, // Commonly available fallback fonts for emoji rendering. fontFamilyFallback: const [ 'Apple Color Emoji', // iOS and macOS. @@ -127,7 +193,7 @@ class StreamEmoji extends StatelessWidget { applyHeightToFirstAscent: false, applyHeightToLastDescent: false, ), - child: emoji, + child: props.emoji, ), ), ), From 521a48ffeed10391fff32551714087eeabbec04a Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 11 Feb 2026 22:06:07 +0530 Subject: [PATCH 05/17] fix(ui): adjust StreamEmojiButton to use correct icon size and emoji size mapping --- .../lib/src/components/buttons/stream_emoji_button.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/stream_core_flutter/lib/src/components/buttons/stream_emoji_button.dart b/packages/stream_core_flutter/lib/src/components/buttons/stream_emoji_button.dart index eaa614e..00776d4 100644 --- a/packages/stream_core_flutter/lib/src/components/buttons/stream_emoji_button.dart +++ b/packages/stream_core_flutter/lib/src/components/buttons/stream_emoji_button.dart @@ -163,7 +163,8 @@ class DefaultStreamEmojiButton extends StatelessWidget { onPressed: props.onPressed, onLongPress: props.onLongPress, isSelected: props.isSelected, - icon: StreamEmoji(size: emojiSize, emoji: props.emoji), + iconSize: emojiSize.value, + icon: StreamEmoji(emoji: props.emoji), style: ButtonStyle( fixedSize: .all(.square(effectiveSize.value)), minimumSize: .all(.square(effectiveSize.value)), @@ -182,7 +183,7 @@ class DefaultStreamEmojiButton extends StatelessWidget { StreamEmojiSize _emojiSizeForButtonSize( StreamEmojiButtonSize buttonSize, ) => switch (buttonSize) { - .md => StreamEmojiSize.sm, + .md => StreamEmojiSize.md, .lg => StreamEmojiSize.md, .xl => StreamEmojiSize.lg, }; From 888fc668442432cd9c9302b7f852dd442cc134af Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 11 Feb 2026 22:06:29 +0530 Subject: [PATCH 06/17] feat(ui): add xxxs spacing option for very tight gaps --- apps/design_system_gallery/lib/primitives/spacing.dart | 9 +++++---- .../lib/src/theme/primitives/stream_spacing.dart | 6 ++++++ .../lib/src/theme/primitives/stream_spacing.g.theme.dart | 6 ++++++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/apps/design_system_gallery/lib/primitives/spacing.dart b/apps/design_system_gallery/lib/primitives/spacing.dart index 996dbd6..f6b2e25 100644 --- a/apps/design_system_gallery/lib/primitives/spacing.dart +++ b/apps/design_system_gallery/lib/primitives/spacing.dart @@ -42,6 +42,7 @@ class _SpacingCardsList extends StatelessWidget { final allSpacing = [ _SpacingData(name: 'none', value: spacing.none, usage: 'No spacing, tight joins'), + _SpacingData(name: 'xxxs', value: spacing.xxxs, usage: 'Very tight gaps (tab icons)'), _SpacingData(name: 'xxs', value: spacing.xxs, usage: 'Minimal gaps (icon+text)'), _SpacingData(name: 'xs', value: spacing.xs, usage: 'Inline elements, small gaps'), _SpacingData(name: 'sm', value: spacing.sm, usage: 'Button padding, list gaps'), @@ -269,22 +270,22 @@ class _QuickReference extends StatelessWidget { Divider(color: colorScheme.borderSubtle), SizedBox(height: spacing.md), Text( - '8px Grid System', + 'Spacing Scale', style: textTheme.captionEmphasis.copyWith( color: colorScheme.textPrimary, ), ), SizedBox(height: spacing.sm), Text( - 'All spacing values follow a consistent 4/8px grid for visual harmony.', + 'Spacing values follow a consistent scale (2, 4, 8, 12, 16...) for visual hierarchy.', style: textTheme.captionDefault.copyWith( color: colorScheme.textSecondary, ), ), SizedBox(height: spacing.sm), const _UsageHint( - token: 'xxs-xs', - description: 'Fine adjustments (2-4px)', + token: 'xxxs-xs', + description: 'Fine adjustments (2-8px)', ), SizedBox(height: spacing.sm), const _UsageHint( diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/stream_spacing.dart b/packages/stream_core_flutter/lib/src/theme/primitives/stream_spacing.dart index 91aa48a..b56d5a3 100644 --- a/packages/stream_core_flutter/lib/src/theme/primitives/stream_spacing.dart +++ b/packages/stream_core_flutter/lib/src/theme/primitives/stream_spacing.dart @@ -29,6 +29,7 @@ class StreamSpacing with _$StreamSpacing { /// Creates a [StreamSpacing] with the default values. const StreamSpacing({ this.none = 0, + this.xxxs = 2, this.xxs = 4, this.xs = 8, this.sm = 12, @@ -44,6 +45,11 @@ class StreamSpacing with _$StreamSpacing { /// Used for tight component joins. final double none; + /// Extra extra extra small spacing. + /// + /// Used for very tight spacing between closely related elements. + final double xxxs; + /// Base unit spacing. /// /// Used for minimal padding and tight gaps. diff --git a/packages/stream_core_flutter/lib/src/theme/primitives/stream_spacing.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/primitives/stream_spacing.g.theme.dart index 1c959a2..a2bfcdc 100644 --- a/packages/stream_core_flutter/lib/src/theme/primitives/stream_spacing.g.theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/primitives/stream_spacing.g.theme.dart @@ -27,6 +27,7 @@ mixin _$StreamSpacing { return StreamSpacing( none: lerpDouble$(a.none, b.none, t)!, + xxxs: lerpDouble$(a.xxxs, b.xxxs, t)!, xxs: lerpDouble$(a.xxs, b.xxs, t)!, xs: lerpDouble$(a.xs, b.xs, t)!, sm: lerpDouble$(a.sm, b.sm, t)!, @@ -40,6 +41,7 @@ mixin _$StreamSpacing { StreamSpacing copyWith({ double? none, + double? xxxs, double? xxs, double? xs, double? sm, @@ -53,6 +55,7 @@ mixin _$StreamSpacing { return StreamSpacing( none: none ?? _this.none, + xxxs: xxxs ?? _this.xxxs, xxs: xxs ?? _this.xxs, xs: xs ?? _this.xs, sm: sm ?? _this.sm, @@ -77,6 +80,7 @@ mixin _$StreamSpacing { return copyWith( none: other.none, + xxxs: other.xxxs, xxs: other.xxs, xs: other.xs, sm: other.sm, @@ -102,6 +106,7 @@ mixin _$StreamSpacing { final _other = (other as StreamSpacing); return _other.none == _this.none && + _other.xxxs == _this.xxxs && _other.xxs == _this.xxs && _other.xs == _this.xs && _other.sm == _this.sm && @@ -119,6 +124,7 @@ mixin _$StreamSpacing { return Object.hash( runtimeType, _this.none, + _this.xxxs, _this.xxs, _this.xs, _this.sm, From d8bb5fa7b665291b633a901df5d0572eac585539 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 11 Feb 2026 22:07:19 +0530 Subject: [PATCH 07/17] feat(ui): add custom emoji builder to StreamComponent and update exports --- packages/stream_core_flutter/lib/src/components.dart | 2 +- .../lib/src/factory/stream_component_factory.dart | 6 ++++++ .../lib/src/theme/components/stream_emoji_button_theme.dart | 5 +++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/stream_core_flutter/lib/src/components.dart b/packages/stream_core_flutter/lib/src/components.dart index 5a055ad..b735a36 100644 --- a/packages/stream_core_flutter/lib/src/components.dart +++ b/packages/stream_core_flutter/lib/src/components.dart @@ -1,4 +1,4 @@ -export 'components/accessories/stream_emoji.dart'; +export 'components/accessories/stream_emoji.dart' hide DefaultStreamEmoji; export 'components/accessories/stream_file_type_icon.dart' hide DefaultStreamFileTypeIcon; export 'components/avatar/stream_avatar.dart' hide DefaultStreamAvatar; export 'components/avatar/stream_avatar_group.dart' hide DefaultStreamAvatarGroup; 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 96f310d..209705a 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 @@ -155,6 +155,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { this.avatarStack, this.badgeCount, this.button, + this.emoji, this.emojiButton, this.fileTypeIcon, this.onlineIndicator, @@ -185,6 +186,11 @@ class StreamComponentBuilders with _$StreamComponentBuilders { /// When null, [StreamButton] uses [DefaultStreamButton]. final StreamComponentBuilder? button; + /// Custom builder for emoji widgets. + /// + /// When null, [StreamEmoji] uses [DefaultStreamEmoji]. + final StreamComponentBuilder? emoji; + /// Custom builder for emoji button widgets. /// /// When null, [StreamEmojiButton] uses [DefaultStreamEmojiButton]. diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_button_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_button_theme.dart index 2dd7c2d..4c90ede 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_button_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_button_theme.dart @@ -149,8 +149,9 @@ class StreamEmojiButtonThemeStyle with _$StreamEmojiButtonThemeStyle { this.backgroundColor, this.foregroundColor, this.overlayColor, - this.side, - }); + WidgetStateProperty? side, + }) // TODO: Fix this or try to find something better + : side = side as WidgetStateBorderSide?; /// The size of emoji buttons. /// From 286e9bde548c7a61aeba4459bc23f652acee01da Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 11 Feb 2026 22:07:37 +0530 Subject: [PATCH 08/17] feat(ui): add custom emoji builder to StreamComponent and update exports --- .../lib/src/factory/stream_component_factory.g.theme.dart | 6 ++++++ 1 file changed, 6 insertions(+) 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 189f254..6edc707 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 @@ -35,6 +35,7 @@ mixin _$StreamComponentBuilders { avatarStack: t < 0.5 ? a.avatarStack : b.avatarStack, badgeCount: t < 0.5 ? a.badgeCount : b.badgeCount, button: t < 0.5 ? a.button : b.button, + emoji: t < 0.5 ? a.emoji : b.emoji, emojiButton: t < 0.5 ? a.emojiButton : b.emojiButton, fileTypeIcon: t < 0.5 ? a.fileTypeIcon : b.fileTypeIcon, onlineIndicator: t < 0.5 ? a.onlineIndicator : b.onlineIndicator, @@ -47,6 +48,7 @@ mixin _$StreamComponentBuilders { Widget Function(BuildContext, StreamAvatarStackProps)? avatarStack, Widget Function(BuildContext, StreamBadgeCountProps)? badgeCount, Widget Function(BuildContext, StreamButtonProps)? button, + Widget Function(BuildContext, StreamEmojiProps)? emoji, Widget Function(BuildContext, StreamEmojiButtonProps)? emojiButton, Widget Function(BuildContext, StreamFileTypeIconProps)? fileTypeIcon, Widget Function(BuildContext, StreamOnlineIndicatorProps)? onlineIndicator, @@ -59,6 +61,7 @@ mixin _$StreamComponentBuilders { avatarStack: avatarStack ?? _this.avatarStack, badgeCount: badgeCount ?? _this.badgeCount, button: button ?? _this.button, + emoji: emoji ?? _this.emoji, emojiButton: emojiButton ?? _this.emojiButton, fileTypeIcon: fileTypeIcon ?? _this.fileTypeIcon, onlineIndicator: onlineIndicator ?? _this.onlineIndicator, @@ -82,6 +85,7 @@ mixin _$StreamComponentBuilders { avatarStack: other.avatarStack, badgeCount: other.badgeCount, button: other.button, + emoji: other.emoji, emojiButton: other.emojiButton, fileTypeIcon: other.fileTypeIcon, onlineIndicator: other.onlineIndicator, @@ -106,6 +110,7 @@ mixin _$StreamComponentBuilders { _other.avatarStack == _this.avatarStack && _other.badgeCount == _this.badgeCount && _other.button == _this.button && + _other.emoji == _this.emoji && _other.emojiButton == _this.emojiButton && _other.fileTypeIcon == _this.fileTypeIcon && _other.onlineIndicator == _this.onlineIndicator; @@ -122,6 +127,7 @@ mixin _$StreamComponentBuilders { _this.avatarStack, _this.badgeCount, _this.button, + _this.emoji, _this.emojiButton, _this.fileTypeIcon, _this.onlineIndicator, From a8ad73dc8fdc8835a499c8849f2354873342dfeb Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 11 Feb 2026 22:25:03 +0530 Subject: [PATCH 09/17] feat: add StreamReactionPickerSheet and StreamEmoji components with playground examples --- .../lib/app/gallery_app.directories.g.dart | 99 ++-- .../components/accessories/stream_emoji.dart | 387 ++++++++++++ .../{ => avatar}/stream_avatar.dart | 0 .../{ => avatar}/stream_avatar_group.dart | 0 .../{ => avatar}/stream_avatar_stack.dart | 0 .../{ => badge}/stream_badge_count.dart | 0 .../{ => badge}/stream_online_indicator.dart | 0 .../lib/components/{ => buttons}/button.dart | 0 .../buttons/stream_emoji_button.dart | 552 ++++++++++++++++++ .../picker/stream_reaction_picker_sheet.dart | 45 ++ .../lib/core/preview_wrapper.dart | 8 +- .../lib/widgets/toolbar/toolbar.dart | 36 ++ apps/design_system_gallery/pubspec.yaml | 1 + .../lib/src/components.dart | 1 + .../picker/stream_reaction_picker_sheet.dart | 535 +++++++++++++++++ packages/stream_core_flutter/pubspec.yaml | 1 + 16 files changed, 1627 insertions(+), 38 deletions(-) create mode 100644 apps/design_system_gallery/lib/components/accessories/stream_emoji.dart rename apps/design_system_gallery/lib/components/{ => avatar}/stream_avatar.dart (100%) rename apps/design_system_gallery/lib/components/{ => avatar}/stream_avatar_group.dart (100%) rename apps/design_system_gallery/lib/components/{ => avatar}/stream_avatar_stack.dart (100%) rename apps/design_system_gallery/lib/components/{ => badge}/stream_badge_count.dart (100%) rename apps/design_system_gallery/lib/components/{ => badge}/stream_online_indicator.dart (100%) rename apps/design_system_gallery/lib/components/{ => buttons}/button.dart (100%) create mode 100644 apps/design_system_gallery/lib/components/buttons/stream_emoji_button.dart create mode 100644 apps/design_system_gallery/lib/components/reaction/picker/stream_reaction_picker_sheet.dart create mode 100644 packages/stream_core_flutter/lib/src/components/reaction/picker/stream_reaction_picker_sheet.dart diff --git a/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart b/apps/design_system_gallery/lib/app/gallery_app.directories.g.dart index a131822..668538d 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 @@ -10,10 +10,22 @@ // ************************************************************************** // ignore_for_file: no_leading_underscores_for_library_prefixes +import 'package:design_system_gallery/components/accessories/stream_emoji.dart' + as _design_system_gallery_components_accessories_stream_emoji; import 'package:design_system_gallery/components/accessories/stream_file_type_icons.dart' as _design_system_gallery_components_accessories_stream_file_type_icons; -import 'package:design_system_gallery/components/button.dart' - as _design_system_gallery_components_button; +import 'package:design_system_gallery/components/avatar/stream_avatar.dart' + as _design_system_gallery_components_avatar_stream_avatar; +import 'package:design_system_gallery/components/avatar/stream_avatar_group.dart' + as _design_system_gallery_components_avatar_stream_avatar_group; +import 'package:design_system_gallery/components/avatar/stream_avatar_stack.dart' + as _design_system_gallery_components_avatar_stream_avatar_stack; +import 'package:design_system_gallery/components/badge/stream_badge_count.dart' + as _design_system_gallery_components_badge_stream_badge_count; +import 'package:design_system_gallery/components/badge/stream_online_indicator.dart' + as _design_system_gallery_components_badge_stream_online_indicator; +import 'package:design_system_gallery/components/buttons/button.dart' + as _design_system_gallery_components_buttons_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/message_composer/message_composer.dart' @@ -24,18 +36,8 @@ import 'package:design_system_gallery/components/message_composer/message_compos as _design_system_gallery_components_message_composer_message_composer_attachment_media_file; import 'package:design_system_gallery/components/message_composer/message_composer_attachment_reply.dart' as _design_system_gallery_components_message_composer_message_composer_attachment_reply; -import 'package:design_system_gallery/components/stream_avatar.dart' - as _design_system_gallery_components_stream_avatar; -import 'package:design_system_gallery/components/stream_avatar_group.dart' - as _design_system_gallery_components_stream_avatar_group; -import 'package:design_system_gallery/components/stream_avatar_stack.dart' - as _design_system_gallery_components_stream_avatar_stack; -import 'package:design_system_gallery/components/stream_badge_count.dart' - as _design_system_gallery_components_stream_badge_count; -import 'package:design_system_gallery/components/stream_online_indicator.dart' - as _design_system_gallery_components_stream_online_indicator; -import 'package:design_system_gallery/components/stream_reaction_picker_sheet.dart' - as _design_system_gallery_components_stream_reaction_picker_sheet; +import 'package:design_system_gallery/components/reaction/picker/stream_reaction_picker_sheet.dart' + as _design_system_gallery_components_reaction_picker_stream_reaction_picker_sheet; import 'package:design_system_gallery/primitives/colors.dart' as _design_system_gallery_primitives_colors; import 'package:design_system_gallery/primitives/icons.dart' @@ -162,6 +164,23 @@ final directories = <_widgetbook.WidgetbookNode>[ _widgetbook.WidgetbookFolder( name: 'Accessories', children: [ + _widgetbook.WidgetbookComponent( + name: 'StreamEmoji', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_accessories_stream_emoji + .buildStreamEmojiPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_accessories_stream_emoji + .buildStreamEmojiShowcase, + ), + ], + ), _widgetbook.WidgetbookComponent( name: 'StreamFileTypeIcon', useCases: [ @@ -189,12 +208,12 @@ final directories = <_widgetbook.WidgetbookNode>[ useCases: [ _widgetbook.WidgetbookUseCase( name: 'Playground', - builder: _design_system_gallery_components_stream_avatar + builder: _design_system_gallery_components_avatar_stream_avatar .buildStreamAvatarPlayground, ), _widgetbook.WidgetbookUseCase( name: 'Showcase', - builder: _design_system_gallery_components_stream_avatar + builder: _design_system_gallery_components_avatar_stream_avatar .buildStreamAvatarShowcase, ), ], @@ -204,13 +223,15 @@ final directories = <_widgetbook.WidgetbookNode>[ useCases: [ _widgetbook.WidgetbookUseCase( name: 'Playground', - builder: _design_system_gallery_components_stream_avatar_group - .buildStreamAvatarGroupPlayground, + builder: + _design_system_gallery_components_avatar_stream_avatar_group + .buildStreamAvatarGroupPlayground, ), _widgetbook.WidgetbookUseCase( name: 'Showcase', - builder: _design_system_gallery_components_stream_avatar_group - .buildStreamAvatarGroupShowcase, + builder: + _design_system_gallery_components_avatar_stream_avatar_group + .buildStreamAvatarGroupShowcase, ), ], ), @@ -219,13 +240,15 @@ final directories = <_widgetbook.WidgetbookNode>[ useCases: [ _widgetbook.WidgetbookUseCase( name: 'Playground', - builder: _design_system_gallery_components_stream_avatar_stack - .buildStreamAvatarStackPlayground, + builder: + _design_system_gallery_components_avatar_stream_avatar_stack + .buildStreamAvatarStackPlayground, ), _widgetbook.WidgetbookUseCase( name: 'Showcase', - builder: _design_system_gallery_components_stream_avatar_stack - .buildStreamAvatarStackShowcase, + builder: + _design_system_gallery_components_avatar_stream_avatar_stack + .buildStreamAvatarStackShowcase, ), ], ), @@ -239,13 +262,15 @@ final directories = <_widgetbook.WidgetbookNode>[ useCases: [ _widgetbook.WidgetbookUseCase( name: 'Playground', - builder: _design_system_gallery_components_stream_badge_count - .buildStreamBadgeCountPlayground, + builder: + _design_system_gallery_components_badge_stream_badge_count + .buildStreamBadgeCountPlayground, ), _widgetbook.WidgetbookUseCase( name: 'Showcase', - builder: _design_system_gallery_components_stream_badge_count - .buildStreamBadgeCountShowcase, + builder: + _design_system_gallery_components_badge_stream_badge_count + .buildStreamBadgeCountShowcase, ), ], ), @@ -259,27 +284,27 @@ final directories = <_widgetbook.WidgetbookNode>[ useCases: [ _widgetbook.WidgetbookUseCase( name: 'Playground', - builder: _design_system_gallery_components_button + builder: _design_system_gallery_components_buttons_button .buildStreamButtonPlayground, ), _widgetbook.WidgetbookUseCase( name: 'Real-world Example', - builder: _design_system_gallery_components_button + builder: _design_system_gallery_components_buttons_button .buildStreamButtonExample, ), _widgetbook.WidgetbookUseCase( name: 'Size Variants', - builder: _design_system_gallery_components_button + builder: _design_system_gallery_components_buttons_button .buildStreamButtonSizes, ), _widgetbook.WidgetbookUseCase( name: 'Type Variants', - builder: _design_system_gallery_components_button + builder: _design_system_gallery_components_buttons_button .buildStreamButtonTypes, ), _widgetbook.WidgetbookUseCase( name: 'With Icons', - builder: _design_system_gallery_components_button + builder: _design_system_gallery_components_buttons_button .buildStreamButtonWithIcons, ), ], @@ -312,13 +337,13 @@ final directories = <_widgetbook.WidgetbookNode>[ _widgetbook.WidgetbookUseCase( name: 'Playground', builder: - _design_system_gallery_components_stream_online_indicator + _design_system_gallery_components_badge_stream_online_indicator .buildStreamOnlineIndicatorPlayground, ), _widgetbook.WidgetbookUseCase( name: 'Showcase', builder: - _design_system_gallery_components_stream_online_indicator + _design_system_gallery_components_badge_stream_online_indicator .buildStreamOnlineIndicatorShowcase, ), ], @@ -384,12 +409,12 @@ final directories = <_widgetbook.WidgetbookNode>[ name: 'Reaction', children: [ _widgetbook.WidgetbookComponent( - name: 'Widget', + name: 'StreamReactionPickerSheet', useCases: [ _widgetbook.WidgetbookUseCase( name: 'Playground', builder: - _design_system_gallery_components_stream_reaction_picker_sheet + _design_system_gallery_components_reaction_picker_stream_reaction_picker_sheet .buildStreamReactionPickerSheetDefault, ), ], diff --git a/apps/design_system_gallery/lib/components/accessories/stream_emoji.dart b/apps/design_system_gallery/lib/components/accessories/stream_emoji.dart new file mode 100644 index 0000000..74eda73 --- /dev/null +++ b/apps/design_system_gallery/lib/components/accessories/stream_emoji.dart @@ -0,0 +1,387 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:unicode_emojis/unicode_emojis.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +// ============================================================================= +// Playground +// ============================================================================= + +@widgetbook.UseCase( + name: 'Playground', + type: StreamEmoji, + path: '[Components]/Accessories', +) +Widget buildStreamEmojiPlayground(BuildContext context) { + final size = context.knobs.object.dropdown( + label: 'Size', + options: StreamEmojiSize.values, + initialOption: StreamEmojiSize.md, + labelBuilder: (option) => '${option.name.toUpperCase()} (${option.value.toInt()}px)', + description: 'Emoji size preset.', + ); + + final emoji = context.knobs.object.dropdown( + label: 'Emoji', + options: _sampleEmojis, + initialOption: _sampleEmojis.first, + labelBuilder: (option) => '${option.emoji} ${option.name}', + description: 'The emoji to display.', + ); + + final showBounds = context.knobs.boolean( + label: 'Show Bounds', + description: 'Show a border around the emoji bounding box.', + ); + + final emojiWidget = StreamEmoji( + size: size, + emoji: Text(emoji.emoji), + ); + + return Center( + child: switch (showBounds) { + true => DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.all(context.streamRadius.xs), + border: Border.all(color: context.streamColorScheme.borderDefault), + ), + child: emojiWidget, + ), + false => emojiWidget, + }, + ); +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamEmoji, + path: '[Components]/Accessories', +) +Widget buildStreamEmojiShowcase(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, + children: [ + const _SizeVariantsSection(), + SizedBox(height: spacing.xl), + const _EmojiSamplerSection(), + SizedBox(height: spacing.xl), + const _IconUsageSection(), + ], + ), + ), + ); +} + +// ============================================================================= +// Size Variants Section +// ============================================================================= + +class _SizeVariantsSection extends StatelessWidget { + const _SizeVariantsSection(); + + @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 Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel(label: 'SIZE VARIANTS'), + SizedBox(height: spacing.md), + Container( + width: double.infinity, + clipBehavior: Clip.antiAlias, + padding: EdgeInsets.all(spacing.md), + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + 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: [ + Text( + 'Emoji scales across predefined sizes', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + SizedBox(height: spacing.md), + Row( + children: [ + for (final size in StreamEmojiSize.values) ...[ + _SizeDemo(size: size), + if (size != StreamEmojiSize.values.last) SizedBox(width: spacing.xl), + ], + ], + ), + ], + ), + ), + ], + ); + } +} + +class _SizeDemo extends StatelessWidget { + const _SizeDemo({required this.size}); + + final StreamEmojiSize size; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Column( + children: [ + SizedBox( + width: 64, + height: 64, + child: Center( + child: StreamEmoji( + size: size, + emoji: const Text('πŸ”₯'), + ), + ), + ), + SizedBox(height: spacing.sm), + Text( + size.name.toUpperCase(), + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.accentPrimary, + fontFamily: 'monospace', + ), + ), + Text( + '${size.value.toInt()}px', + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textTertiary, + fontFamily: 'monospace', + fontSize: 10, + ), + ), + ], + ); + } +} + +// ============================================================================= +// Emoji Sampler Section +// ============================================================================= + +class _EmojiSamplerSection extends StatelessWidget { + const _EmojiSamplerSection(); + + @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 Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel(label: 'EMOJI SAMPLER'), + SizedBox(height: spacing.md), + Container( + width: double.infinity, + clipBehavior: Clip.antiAlias, + padding: EdgeInsets.all(spacing.md), + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + 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: [ + Text( + 'Various emojis rendered at consistent sizing', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + SizedBox(height: spacing.md), + Wrap( + spacing: spacing.sm, + runSpacing: spacing.sm, + children: [ + for (final emoji in _sampleEmojis) + StreamEmoji( + size: StreamEmojiSize.lg, + emoji: Text(emoji.emoji), + ), + ], + ), + ], + ), + ), + ], + ); + } +} + +// ============================================================================= +// Icon Usage Section +// ============================================================================= + +class _IconUsageSection extends StatelessWidget { + const _IconUsageSection(); + + @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 Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel(label: 'WITH ICONS'), + SizedBox(height: spacing.md), + Container( + width: double.infinity, + clipBehavior: Clip.antiAlias, + padding: EdgeInsets.all(spacing.md), + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + 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: [ + Text( + 'StreamEmoji can display any widget, not just emoji', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + SizedBox(height: spacing.md), + Wrap( + spacing: spacing.sm, + runSpacing: spacing.sm, + children: [ + for (final iconData in _sampleIcons) + StreamEmoji( + size: StreamEmojiSize.lg, + emoji: Icon(iconData, color: colorScheme.textPrimary), + ), + ], + ), + ], + ), + ), + ], + ); + } +} + +// ============================================================================= +// Shared Widgets +// ============================================================================= + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + return Container( + padding: EdgeInsets.symmetric( + horizontal: spacing.sm, + vertical: spacing.xs, + ), + decoration: BoxDecoration( + color: colorScheme.accentPrimary, + borderRadius: BorderRadius.all(radius.xs), + ), + child: Text( + label, + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.textOnAccent, + letterSpacing: 1, + fontSize: 9, + ), + ), + ); + } +} + +// ============================================================================= +// Helpers +// ============================================================================= + +Emoji _byName(String name) => UnicodeEmojis.allEmojis.firstWhere((e) => e.name == name); + +final _sampleEmojis = [ + _byName('thumbs up sign'), + _byName('heavy black heart'), + _byName('face with tears of joy'), + _byName('fire'), + _byName('clapping hands sign'), + _byName('thinking face'), + _byName('eyes'), + _byName('rocket'), + _byName('party popper'), + _byName('waving hand sign'), + _byName('white medium star'), + _byName('white heavy check mark'), +]; + +const _sampleIcons = [ + Icons.thumb_up, + Icons.favorite, + Icons.sentiment_very_satisfied, + Icons.local_fire_department, + Icons.celebration, + Icons.lightbulb, + Icons.visibility, + Icons.rocket_launch, + Icons.star, + Icons.check_circle, + Icons.emoji_emotions, + Icons.cake, +]; diff --git a/apps/design_system_gallery/lib/components/stream_avatar.dart b/apps/design_system_gallery/lib/components/avatar/stream_avatar.dart similarity index 100% rename from apps/design_system_gallery/lib/components/stream_avatar.dart rename to apps/design_system_gallery/lib/components/avatar/stream_avatar.dart diff --git a/apps/design_system_gallery/lib/components/stream_avatar_group.dart b/apps/design_system_gallery/lib/components/avatar/stream_avatar_group.dart similarity index 100% rename from apps/design_system_gallery/lib/components/stream_avatar_group.dart rename to apps/design_system_gallery/lib/components/avatar/stream_avatar_group.dart diff --git a/apps/design_system_gallery/lib/components/stream_avatar_stack.dart b/apps/design_system_gallery/lib/components/avatar/stream_avatar_stack.dart similarity index 100% rename from apps/design_system_gallery/lib/components/stream_avatar_stack.dart rename to apps/design_system_gallery/lib/components/avatar/stream_avatar_stack.dart diff --git a/apps/design_system_gallery/lib/components/stream_badge_count.dart b/apps/design_system_gallery/lib/components/badge/stream_badge_count.dart similarity index 100% rename from apps/design_system_gallery/lib/components/stream_badge_count.dart rename to apps/design_system_gallery/lib/components/badge/stream_badge_count.dart diff --git a/apps/design_system_gallery/lib/components/stream_online_indicator.dart b/apps/design_system_gallery/lib/components/badge/stream_online_indicator.dart similarity index 100% rename from apps/design_system_gallery/lib/components/stream_online_indicator.dart rename to apps/design_system_gallery/lib/components/badge/stream_online_indicator.dart diff --git a/apps/design_system_gallery/lib/components/button.dart b/apps/design_system_gallery/lib/components/buttons/button.dart similarity index 100% rename from apps/design_system_gallery/lib/components/button.dart rename to apps/design_system_gallery/lib/components/buttons/button.dart diff --git a/apps/design_system_gallery/lib/components/buttons/stream_emoji_button.dart b/apps/design_system_gallery/lib/components/buttons/stream_emoji_button.dart new file mode 100644 index 0000000..234d1e3 --- /dev/null +++ b/apps/design_system_gallery/lib/components/buttons/stream_emoji_button.dart @@ -0,0 +1,552 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:unicode_emojis/unicode_emojis.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +// ============================================================================= +// Playground +// ============================================================================= + +@widgetbook.UseCase( + name: 'Playground', + type: StreamEmojiButton, + path: '[Components]/Button', +) +Widget buildStreamEmojiButtonPlayground(BuildContext context) { + return const _PlaygroundDemo(); +} + +class _PlaygroundDemo extends StatefulWidget { + const _PlaygroundDemo(); + + @override + State<_PlaygroundDemo> createState() => _PlaygroundDemoState(); +} + +class _PlaygroundDemoState extends State<_PlaygroundDemo> { + var _isSelected = false; + + @override + Widget build(BuildContext context) { + final size = context.knobs.object.dropdown( + label: 'Size', + options: StreamEmojiButtonSize.values, + initialOption: StreamEmojiButtonSize.lg, + labelBuilder: (option) => '${option.name.toUpperCase()} (${option.value.toInt()}px)', + description: 'Button size preset.', + ); + + final emoji = context.knobs.object.dropdown( + label: 'Emoji', + options: _sampleEmojis, + initialOption: _sampleEmojis.first, + labelBuilder: (option) => '${option.emoji} ${option.name}', + description: 'The emoji to display.', + ); + + final isDisabled = context.knobs.boolean( + label: 'Disabled', + description: 'Whether the button is disabled (non-interactive).', + ); + + return Center( + child: StreamEmojiButton( + size: size, + emoji: Text(emoji.emoji), + isSelected: _isSelected, + onPressed: isDisabled + ? null + : () { + setState(() => _isSelected = !_isSelected); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text('${emoji.emoji} ${emoji.name} ${_isSelected ? 'selected' : 'deselected'}'), + duration: const Duration(seconds: 1), + ), + ); + }, + ), + ); + } +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamEmojiButton, + path: '[Components]/Button', +) +Widget buildStreamEmojiButtonShowcase(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, + children: [ + const _SizeVariantsSection(), + SizedBox(height: spacing.xl), + const _StateVariantsSection(), + SizedBox(height: spacing.xl), + const _EmojiGridSection(), + SizedBox(height: spacing.xl), + const _WithIconsSection(), + ], + ), + ), + ); +} + +// ============================================================================= +// Size Variants Section +// ============================================================================= + +class _SizeVariantsSection extends StatelessWidget { + const _SizeVariantsSection(); + + @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 Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel(label: 'SIZE VARIANTS'), + SizedBox(height: spacing.md), + Container( + width: double.infinity, + clipBehavior: Clip.antiAlias, + padding: EdgeInsets.all(spacing.md), + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + 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: [ + Text( + 'Button sizes with embedded emoji scaling', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + SizedBox(height: spacing.md), + Row( + children: [ + for (final size in StreamEmojiButtonSize.values) ...[ + _SizeDemo(size: size), + if (size != StreamEmojiButtonSize.values.last) + SizedBox(width: spacing.xl), + ], + ], + ), + ], + ), + ), + ], + ); + } +} + +class _SizeDemo extends StatelessWidget { + const _SizeDemo({required this.size}); + + final StreamEmojiButtonSize size; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Column( + children: [ + StreamEmojiButton( + size: size, + emoji: const Text('πŸ‘'), + onPressed: () {}, + ), + SizedBox(height: spacing.sm), + Text( + size.name.toUpperCase(), + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.accentPrimary, + fontFamily: 'monospace', + ), + ), + Text( + '${size.value.toInt()}px', + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textTertiary, + fontFamily: 'monospace', + fontSize: 10, + ), + ), + ], + ); + } +} + +// ============================================================================= +// State Variants Section +// ============================================================================= + +class _StateVariantsSection extends StatelessWidget { + const _StateVariantsSection(); + + @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 Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel(label: 'STATE VARIANTS'), + SizedBox(height: spacing.md), + Container( + width: double.infinity, + clipBehavior: Clip.antiAlias, + padding: EdgeInsets.all(spacing.md), + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + 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: [ + Text( + 'Interactive states: default, hover, pressed, disabled, selected', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + SizedBox(height: spacing.xs), + Text( + 'Focused state (blue border) appears during keyboard navigation', + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textTertiary, + ), + ), + SizedBox(height: spacing.md), + Wrap( + spacing: spacing.lg, + runSpacing: spacing.md, + children: const [ + _StateDemo( + label: 'Default', + enabled: true, + initialSelected: false, + ), + _StateDemo( + label: 'Disabled', + enabled: false, + initialSelected: false, + ), + _StateDemo( + label: 'Selected', + enabled: true, + initialSelected: true, + ), + ], + ), + ], + ), + ), + ], + ); + } +} + +class _StateDemo extends StatefulWidget { + const _StateDemo({ + required this.label, + required this.enabled, + required this.initialSelected, + }); + + final String label; + final bool enabled; + final bool initialSelected; + + @override + State<_StateDemo> createState() => _StateDemoState(); +} + +class _StateDemoState extends State<_StateDemo> { + late bool _isSelected; + + @override + void initState() { + super.initState(); + _isSelected = widget.initialSelected; + } + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Column( + children: [ + StreamEmojiButton( + size: StreamEmojiButtonSize.lg, + emoji: const Text('πŸ‘'), + isSelected: _isSelected, + onPressed: widget.enabled + ? () { + if (widget.label != 'Disabled') { + setState(() => _isSelected = !_isSelected); + } + } + : null, + ), + SizedBox(height: spacing.sm), + Text( + widget.label, + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.accentPrimary, + fontFamily: 'monospace', + ), + ), + ], + ); + } +} + +// ============================================================================= +// Emoji Grid Section +// ============================================================================= + +class _EmojiGridSection extends StatelessWidget { + const _EmojiGridSection(); + + @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 Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel(label: 'EMOJI GRID'), + SizedBox(height: spacing.md), + Container( + width: double.infinity, + clipBehavior: Clip.antiAlias, + padding: EdgeInsets.all(spacing.md), + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + 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: [ + Text( + 'Interactive emoji picker pattern', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + SizedBox(height: spacing.md), + Wrap( + spacing: spacing.xs, + runSpacing: spacing.xs, + children: [ + for (final emoji in _sampleEmojis) + StreamEmojiButton( + size: StreamEmojiButtonSize.lg, + emoji: Text(emoji.emoji), + onPressed: () { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text('${emoji.emoji} ${emoji.name}'), + duration: const Duration(seconds: 1), + ), + ); + }, + ), + ], + ), + ], + ), + ), + ], + ); + } +} + +// ============================================================================= +// With Icons Section +// ============================================================================= + +class _WithIconsSection extends StatelessWidget { + const _WithIconsSection(); + + @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 Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _SectionLabel(label: 'WITH ICONS'), + SizedBox(height: spacing.md), + Container( + width: double.infinity, + clipBehavior: Clip.antiAlias, + padding: EdgeInsets.all(spacing.md), + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + 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: [ + Text( + 'Using Material Icons instead of emoji', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + SizedBox(height: spacing.md), + Wrap( + spacing: spacing.xs, + runSpacing: spacing.xs, + children: [ + for (final iconData in _sampleIcons) + StreamEmojiButton( + size: StreamEmojiButtonSize.lg, + emoji: Icon(iconData, color: colorScheme.textPrimary), + onPressed: () {}, + ), + ], + ), + ], + ), + ), + ], + ); + } +} + +// ============================================================================= +// Shared Widgets +// ============================================================================= + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + return Container( + padding: EdgeInsets.symmetric( + horizontal: spacing.sm, + vertical: spacing.xs, + ), + decoration: BoxDecoration( + color: colorScheme.accentPrimary, + borderRadius: BorderRadius.all(radius.xs), + ), + child: Text( + label, + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.textOnAccent, + letterSpacing: 1, + fontSize: 9, + ), + ), + ); + } +} + +// ============================================================================= +// Helpers +// ============================================================================= + +Emoji _byName(String name) => + UnicodeEmojis.allEmojis.firstWhere((e) => e.name == name); + +final _sampleEmojis = [ + _byName('thumbs up sign'), + _byName('heavy black heart'), + _byName('face with tears of joy'), + _byName('fire'), + _byName('clapping hands sign'), + _byName('thinking face'), + _byName('eyes'), + _byName('rocket'), + _byName('party popper'), + _byName('waving hand sign'), + _byName('white medium star'), + _byName('white heavy check mark'), +]; + +const _sampleIcons = [ + Icons.thumb_up, + Icons.favorite, + Icons.sentiment_very_satisfied, + Icons.local_fire_department, + Icons.celebration, + Icons.lightbulb, + Icons.visibility, + Icons.rocket_launch, + Icons.star, + Icons.check_circle, + Icons.emoji_emotions, + Icons.cake, +]; diff --git a/apps/design_system_gallery/lib/components/reaction/picker/stream_reaction_picker_sheet.dart b/apps/design_system_gallery/lib/components/reaction/picker/stream_reaction_picker_sheet.dart new file mode 100644 index 0000000..8d125bb --- /dev/null +++ b/apps/design_system_gallery/lib/components/reaction/picker/stream_reaction_picker_sheet.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core_flutter/stream_core_flutter.dart'; +import 'package:widgetbook/widgetbook.dart'; +import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; + +// ============================================================================= +// Playground +// ============================================================================= + +@widgetbook.UseCase( + name: 'Playground', + type: StreamReactionPickerSheet, + path: '[Components]/Reaction', +) +Widget buildStreamReactionPickerSheetDefault(BuildContext context) { + final reactionButtonSize = context.knobs.object.dropdown( + label: 'Reaction Button Size', + options: StreamEmojiButtonSize.values, + initialOption: StreamEmojiButtonSize.xl, + labelBuilder: (option) => option.name, + description: 'The size of each reaction button in the grid.', + ); + + return Center( + child: StreamButton( + label: 'Show Reaction Picker', + onTap: () async { + final emoji = await StreamReactionPickerSheet.show( + context: context, + reactionButtonSize: reactionButtonSize, + ); + if (emoji != null && context.mounted) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text('Selected ${emoji.emoji} ${emoji.name}'), + duration: const Duration(seconds: 2), + ), + ); + } + }, + ), + ); +} diff --git a/apps/design_system_gallery/lib/core/preview_wrapper.dart b/apps/design_system_gallery/lib/core/preview_wrapper.dart index a3b78b0..3cb69e0 100644 --- a/apps/design_system_gallery/lib/core/preview_wrapper.dart +++ b/apps/design_system_gallery/lib/core/preview_wrapper.dart @@ -31,7 +31,13 @@ class PreviewWrapper extends StatelessWidget { ), child: ColoredBox( color: colorScheme.backgroundApp, - child: child, + child: ScaffoldMessenger( + child: Navigator( + onGenerateRoute: (_) => MaterialPageRoute( + builder: (_) => Scaffold(body: child), + ), + ), + ), ), ), ); diff --git a/apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart b/apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart index 39c0eb1..ea8c8b2 100644 --- a/apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart +++ b/apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:provider/provider.dart'; import 'package:stream_core_flutter/stream_core_flutter.dart'; import 'package:svg_icon_widget/svg_icon_widget.dart'; @@ -89,6 +90,10 @@ class GalleryToolbar extends StatelessWidget { options: PreviewConfiguration.textDirectionOptions, onChanged: previewConfig.setTextDirection, ), + SizedBox(width: spacing.sm), + + // Debug paint toggle + const _DebugPaintToggle(), ], ), ), @@ -156,3 +161,34 @@ class _StreamBranding extends StatelessWidget { ); } } + +/// Debug paint toggle button for visualizing layout bounds. +class _DebugPaintToggle extends StatefulWidget { + const _DebugPaintToggle(); + + @override + State<_DebugPaintToggle> createState() => _DebugPaintToggleState(); +} + +class _DebugPaintToggleState extends State<_DebugPaintToggle> { + var _isActive = false; + + void _toggleDebugPaint() { + setState(() { + _isActive = !_isActive; + debugPaintSizeEnabled = _isActive; + }); + // Force a repaint to show/hide the debug bounds immediately + WidgetsBinding.instance.performReassemble(); + } + + @override + Widget build(BuildContext context) { + return ToolbarButton( + icon: _isActive ? Icons.grid_on : Icons.grid_off, + tooltip: 'Layout Bounds', + isActive: _isActive, + onTap: _toggleDebugPaint, + ); + } +} diff --git a/apps/design_system_gallery/pubspec.yaml b/apps/design_system_gallery/pubspec.yaml index 8f34e37..f816ad5 100644 --- a/apps/design_system_gallery/pubspec.yaml +++ b/apps/design_system_gallery/pubspec.yaml @@ -17,6 +17,7 @@ dependencies: stream_core_flutter: path: ../../packages/stream_core_flutter svg_icon_widget: ^0.0.1+1 + unicode_emojis: ^0.5.1 widgetbook: ^3.20.2 widgetbook_annotation: ^3.9.0 diff --git a/packages/stream_core_flutter/lib/src/components.dart b/packages/stream_core_flutter/lib/src/components.dart index b735a36..46cb141 100644 --- a/packages/stream_core_flutter/lib/src/components.dart +++ b/packages/stream_core_flutter/lib/src/components.dart @@ -8,5 +8,6 @@ export 'components/badge/stream_online_indicator.dart' hide DefaultStreamOnlineI export 'components/buttons/stream_button.dart' hide DefaultStreamButton; export 'components/buttons/stream_emoji_button.dart' hide DefaultStreamEmojiButton; export 'components/message_composer.dart'; +export 'components/reaction/picker/stream_reaction_picker_sheet.dart'; export 'factory/stream_component_factory.dart'; diff --git a/packages/stream_core_flutter/lib/src/components/reaction/picker/stream_reaction_picker_sheet.dart b/packages/stream_core_flutter/lib/src/components/reaction/picker/stream_reaction_picker_sheet.dart new file mode 100644 index 0000000..161657d --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/reaction/picker/stream_reaction_picker_sheet.dart @@ -0,0 +1,535 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:unicode_emojis/unicode_emojis.dart'; + +import '../../../components.dart'; +import '../../../theme/components/stream_emoji_button_theme.dart'; +import '../../../theme/stream_theme_extensions.dart'; + +// Emojis grouped by their [Category], computed once and reused. +final _emojisByCategory = () { + final map = >{}; + for (final emoji in UnicodeEmojis.allEmojis) { + map.putIfAbsent(emoji.category, () => []).add(emoji); + } + return map; +}(); + +/// Extension on [Category] to provide representative icons. +extension CategoryIcon on Category { + /// Returns the representative icon for this category from [StreamIcons]. + IconData icon(BuildContext context) => switch (this) { + .smileysAndEmotion => context.streamIcons.emojiSmile, + .peopleAndBody => context.streamIcons.people, + .animalsAndNature => context.streamIcons.cat, + .foodAndDrink => context.streamIcons.apples, + .travelAndPlaces => context.streamIcons.car1, + .activities => context.streamIcons.tennis, + .objects => context.streamIcons.lightBulbSimple, + .symbols => context.streamIcons.heart2, + .flags => context.streamIcons.flag2, + }; +} + +/// A scrollable sheet that displays Unicode emojis organized by category. +/// +/// [StreamReactionPickerSheet] provides a full reaction picker interface with +/// category tabs for quick navigation. Users can scroll through categories +/// or tap a category tab to jump to that section. The active category tab +/// updates automatically as the user scrolls. +/// +/// The sheet automatically handles: +/// - Category-based organization of all Unicode emojis +/// - Smooth scrolling between categories +/// - Active tab highlighting during scroll +/// - Layout adaptation for different screen sizes +/// +/// ## Usage +/// +/// The recommended way to display the reaction picker is using the [show] +/// method, which presents it as a modal bottom sheet: +/// +/// {@tool snippet} +/// +/// Basic usage: +/// +/// ```dart +/// final emoji = await StreamReactionPickerSheet.show( +/// context: context, +/// ); +/// if (emoji != null) { +/// sendReaction(emoji); +/// } +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// With customization: +/// +/// ```dart +/// final emoji = await StreamReactionPickerSheet.show( +/// context: context, +/// reactionButtonSize: StreamEmojiButtonSize.lg, +/// showDragHandle: false, +/// backgroundColor: Colors.white, +/// ); +/// ``` +/// {@end-tool} +/// +/// ## Theming +/// +/// The reaction buttons use [StreamEmojiButtonTheme] for styling. Category +/// tabs and headers respect the current [StreamColorScheme] and +/// [StreamTextTheme]. +/// +/// See also: +/// +/// * [StreamEmojiButton], which displays individual reaction options. +/// * [StreamEmojiButtonSize], which defines button size variants. +/// * [Category], which defines the emoji categories from `unicode_emojis` package. +class StreamReactionPickerSheet extends StatefulWidget { + /// Creates a reaction picker sheet. + /// + /// This constructor is private. Use [StreamReactionPickerSheet.show] to + /// display the picker as a modal bottom sheet. + const StreamReactionPickerSheet._({ + required this.scrollController, + this.onReactionSelected, + this.reactionButtonSize, + }); + + /// Called when a reaction is tapped. + final ValueChanged? onReactionSelected; + + /// The size of each reaction button in the grid. + /// + /// Defaults to [StreamEmojiButtonSize.xl] (48px buttons). + final StreamEmojiButtonSize? reactionButtonSize; + + /// Scroll controller for the emoji grid. + /// + /// This is required and provided by [DraggableScrollableSheet] when using + /// the [show] method. + final ScrollController scrollController; + + /// Shows the reaction picker sheet as a modal bottom sheet. + /// + /// The sheet appears as a draggable bottom sheet that can be expanded + /// or collapsed. + /// + /// Parameters: + /// - [context]: The build context for showing the modal. + /// - [reactionButtonSize]: Size of each reaction button. Defaults to [StreamEmojiButtonSize.xl]. + /// - [backgroundColor]: Background color of the sheet. Defaults to `backgroundElevation2` from the current color scheme. + /// + /// Returns a [Future] that completes with the selected [Emoji] when a + /// reaction is tapped, or `null` if the sheet is dismissed without selection. + /// + /// {@tool snippet} + /// + /// Basic example: + /// + /// ```dart + /// final emoji = await StreamReactionPickerSheet.show( + /// context: context, + /// ); + /// if (emoji != null) { + /// sendReaction(emoji); + /// } + /// ``` + /// {@end-tool} + /// + /// {@tool snippet} + /// + /// With customization: + /// + /// ```dart + /// final emoji = await StreamReactionPickerSheet.show( + /// context: context, + /// reactionButtonSize: StreamEmojiButtonSize.lg, + /// backgroundColor: Colors.white, + /// ); + /// ``` + /// {@end-tool} + static Future show({ + required BuildContext context, + StreamEmojiButtonSize? reactionButtonSize, + Color? backgroundColor, + }) { + final radius = context.streamRadius; + final colorScheme = context.streamColorScheme; + + final effectiveBackgroundColor = backgroundColor ?? colorScheme.backgroundElevation2; + + return showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + backgroundColor: effectiveBackgroundColor, + shape: RoundedRectangleBorder( + borderRadius: .directional(topStart: radius.xl, topEnd: radius.xl), + ), + builder: (context) => DraggableScrollableSheet( + snap: true, + expand: false, + minChildSize: 0.5, + snapSizes: const [0.5, 1], + builder: (_, scrollController) => StreamReactionPickerSheet._( + scrollController: scrollController, + onReactionSelected: Navigator.of(context).pop, + reactionButtonSize: reactionButtonSize, + ), + ), + ); + } + + @override + State createState() => _StreamReactionPickerSheetState(); +} + +class _StreamReactionPickerSheetState extends State with WidgetsBindingObserver { + late final _scrollController = widget.scrollController; + + // Categories in stable order. + late final List _categories; + + // Keys for each category header. + late final Map _categoryKeys; + + // Cached scroll offsets for each category header. + final Map _categoryOffsets = {}; + + late var _activeCategory = _emojisByCategory.keys.first; + var _isProgrammaticScroll = false; + var _offsetsScheduled = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + + _scrollController.addListener(_handleScroll); + + _categories = _emojisByCategory.keys.toList(); + _categoryKeys = {for (final c in _categories) c: GlobalKey()}; + + _activeCategory = _categories.isNotEmpty ? _categories.first : _emojisByCategory.keys.first; + + // Compute offsets after first layout + _scheduleRecomputeOffsets(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _scrollController.removeListener(_handleScroll); + super.dispose(); + } + + @override + void didChangeMetrics() { + // Recompute on rotation, keyboard show/hide, etc. + _scheduleRecomputeOffsets(); + } + + Future _scrollToCategory(Category category) async { + if (!_scrollController.hasClients) return; + + _isProgrammaticScroll = true; + setState(() => _activeCategory = category); + + // Use cached offset if available, otherwise fallback to ensureVisible + final cachedOffset = _categoryOffsets[category]; + if (cachedOffset != null && cachedOffset.isFinite) { + await _scrollController.animateTo( + cachedOffset, + duration: const .new(milliseconds: 300), + curve: Curves.easeInOut, + ); + _isProgrammaticScroll = false; + return; + } + + final keyContext = _categoryKeys[category]?.currentContext; + if (keyContext == null) { + _isProgrammaticScroll = false; + return; + } + + await Scrollable.ensureVisible( + keyContext, + duration: const .new(milliseconds: 300), + curve: Curves.easeInOut, + ); + + _isProgrammaticScroll = false; + } + + void _handleScroll() { + if (_isProgrammaticScroll || !_scrollController.hasClients) return; + if (_categories.isEmpty || _categoryOffsets.isEmpty) return; + + final position = _scrollController.position; + + // At bottom - activate last category + if (_isAtBottom(position)) { + _setActiveCategory(_categories.last); + return; + } + + // Find the category whose offset is closest to current scroll position + final currentScroll = position.pixels + 1; + Category? bestCategory; + var bestOffset = double.negativeInfinity; + + for (final category in _categories) { + final offset = _categoryOffsets[category]; + if (offset == null || !offset.isFinite) continue; + + // Find the last offset that is <= current scroll position + if (offset <= currentScroll && offset > bestOffset) { + bestOffset = offset; + bestCategory = category; + } + } + + if (bestCategory != null) { + _setActiveCategory(bestCategory); + } + } + + bool _isAtBottom(ScrollPosition position) { + return position.pixels >= position.maxScrollExtent - 1; + } + + void _setActiveCategory(Category category) { + if (category == _activeCategory) return; + setState(() => _activeCategory = category); + } + + void _scheduleRecomputeOffsets() { + if (_offsetsScheduled) return; + _offsetsScheduled = true; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _offsetsScheduled = false; + _recomputeOffsets(); + }); + } + + void _recomputeOffsets() { + if (_categories.isEmpty || !_scrollController.hasClients) return; + + final position = _scrollController.position; + _categoryOffsets.clear(); + + for (final category in _categories) { + final ctx = _categoryKeys[category]!.currentContext; + if (ctx == null) { + // Header not built yet, use infinity as placeholder + _categoryOffsets[category] = double.infinity; + continue; + } + + final ro = ctx.findRenderObject(); + if (ro == null || !ro.attached) { + _categoryOffsets[category] = double.infinity; + continue; + } + + final viewport = RenderAbstractViewport.of(ro); + final offset = viewport.getOffsetToReveal(ro, 0).offset; + + _categoryOffsets[category] = offset.clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + } + } + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final textTheme = context.streamTextTheme; + final colorScheme = context.streamColorScheme; + + final effectiveButtonSize = widget.reactionButtonSize ?? .xl; + + return Column( + crossAxisAlignment: .stretch, + children: [ + Expanded( + child: CustomScrollView( + controller: _scrollController, + slivers: [ + ..._categories + .map( + (category) => SliverPadding( + padding: .symmetric(horizontal: spacing.md), + sliver: SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + key: _categoryKeys[category], + child: Text( + category.description, + style: textTheme.headingXs.copyWith( + color: colorScheme.textTertiary, + ), + ), + ), + SliverToBoxAdapter(child: SizedBox(height: spacing.xs)), + SliverGrid.builder( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 7, + mainAxisSpacing: spacing.xxxs, + crossAxisSpacing: spacing.xxxs, + mainAxisExtent: effectiveButtonSize.value, + ), + itemCount: _emojisByCategory[category]!.length, + itemBuilder: (context, index) { + final emoji = _emojisByCategory[category]![index]; + return StreamEmojiButton( + size: effectiveButtonSize, + emoji: Text(emoji.emoji), + onPressed: () => widget.onReactionSelected?.call(emoji), + ); + }, + ), + ], + ), + ), + ) + .insertBetween( + SliverToBoxAdapter(child: SizedBox(height: spacing.md)), + ), + ], + ), + ), + _CategoryTabBar( + categories: _categories, + activeCategory: _activeCategory, + onCategorySelected: _scrollToCategory, + ), + ], + ); + } +} + +class _CategoryTabBar extends StatefulWidget { + const _CategoryTabBar({ + required this.categories, + required this.activeCategory, + required this.onCategorySelected, + }); + + final List categories; + final Category activeCategory; + final ValueChanged onCategorySelected; + + @override + State<_CategoryTabBar> createState() => _CategoryTabBarState(); +} + +class _CategoryTabBarState extends State<_CategoryTabBar> { + late final Map _tabKeys; + + @override + void initState() { + super.initState(); + _tabKeys = {for (final c in widget.categories) c: GlobalKey()}; + } + + @override + void didUpdateWidget(_CategoryTabBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.activeCategory != oldWidget.activeCategory) { + _scrollToActiveTab(); + } + } + + void _scrollToActiveTab() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + + final keyContext = _tabKeys[widget.activeCategory]?.currentContext; + if (keyContext == null) return; + + Scrollable.ensureVisible( + keyContext, + duration: const .new(milliseconds: 300), + curve: Curves.easeInOut, + alignment: 0.5, + ); + }); + } + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + final colorScheme = context.streamColorScheme; + + return DecoratedBox( + decoration: BoxDecoration( + border: Border(top: .new(color: colorScheme.borderDefault)), + ), + child: SingleChildScrollView( + scrollDirection: .horizontal, + padding: EdgeInsetsDirectional.only( + start: spacing.md, + end: spacing.md, + top: spacing.xs, + ), + child: Row( + spacing: spacing.xxxs, + children: [ + for (final category in widget.categories) + _CategoryTab( + key: _tabKeys[category], + icon: category.icon(context), + isActive: category == widget.activeCategory, + onTap: () => widget.onCategorySelected(category), + ), + ], + ), + ), + ); + } +} + +class _CategoryTab extends StatelessWidget { + const _CategoryTab({ + super.key, + required this.icon, + required this.isActive, + required this.onTap, + }); + + final IconData icon; + final bool isActive; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + + // TODO: Use StreamIconButton when available (with isSelected support) + return StreamEmojiButton( + size: .lg, + emoji: Icon(icon, color: colorScheme.textSecondary), + isSelected: isActive, + onPressed: onTap, + ); + } +} + +// Insert any item inBetween the iterable items. +extension _IterableExtension on Iterable { + Iterable insertBetween(T separator) { + return expand((element) sync* { + yield separator; + yield element; + }).skip(1); + } +} diff --git a/packages/stream_core_flutter/pubspec.yaml b/packages/stream_core_flutter/pubspec.yaml index 32cca40..20702ad 100644 --- a/packages/stream_core_flutter/pubspec.yaml +++ b/packages/stream_core_flutter/pubspec.yaml @@ -14,6 +14,7 @@ dependencies: flutter_svg: ^2.2.3 stream_core: ^0.4.0 theme_extensions_builder_annotation: ^7.1.0 + unicode_emojis: ^0.5.1 dev_dependencies: alchemist: ^0.13.0 From 2b040a720e97af68bdb770d4fff5a8519c50acfc Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 11 Feb 2026 22:35:03 +0530 Subject: [PATCH 10/17] chore: fix formatting --- .../lib/components/buttons/stream_emoji_button.dart | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/design_system_gallery/lib/components/buttons/stream_emoji_button.dart b/apps/design_system_gallery/lib/components/buttons/stream_emoji_button.dart index 234d1e3..b90df65 100644 --- a/apps/design_system_gallery/lib/components/buttons/stream_emoji_button.dart +++ b/apps/design_system_gallery/lib/components/buttons/stream_emoji_button.dart @@ -154,8 +154,7 @@ class _SizeVariantsSection extends StatelessWidget { children: [ for (final size in StreamEmojiButtonSize.values) ...[ _SizeDemo(size: size), - if (size != StreamEmojiButtonSize.values.last) - SizedBox(width: spacing.xl), + if (size != StreamEmojiButtonSize.values.last) SizedBox(width: spacing.xl), ], ], ), @@ -518,8 +517,7 @@ class _SectionLabel extends StatelessWidget { // Helpers // ============================================================================= -Emoji _byName(String name) => - UnicodeEmojis.allEmojis.firstWhere((e) => e.name == name); +Emoji _byName(String name) => UnicodeEmojis.allEmojis.firstWhere((e) => e.name == name); final _sampleEmojis = [ _byName('thumbs up sign'), From 4832403373a58a907876aff2bd21e449bba2c4cc Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 11 Feb 2026 22:44:02 +0530 Subject: [PATCH 11/17] feat: update component paths for StreamOnlineIndicator and StreamButton to use 'Badge' and 'Buttons' --- .../lib/app/gallery_app.directories.g.dart | 41 ++++++++----------- .../badge/stream_online_indicator.dart | 4 +- .../lib/components/buttons/button.dart | 10 ++--- .../buttons/stream_emoji_button.dart | 4 +- 4 files changed, 27 insertions(+), 32 deletions(-) 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 668538d..38caed3 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 @@ -274,10 +274,27 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookComponent( + name: 'StreamOnlineIndicator', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_badge_stream_online_indicator + .buildStreamOnlineIndicatorPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_badge_stream_online_indicator + .buildStreamOnlineIndicatorShowcase, + ), + ], + ), ], ), _widgetbook.WidgetbookFolder( - name: 'Button', + name: 'Buttons', children: [ _widgetbook.WidgetbookComponent( name: 'StreamButton', @@ -328,28 +345,6 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), - _widgetbook.WidgetbookFolder( - name: 'Indicator', - children: [ - _widgetbook.WidgetbookComponent( - name: 'StreamOnlineIndicator', - useCases: [ - _widgetbook.WidgetbookUseCase( - name: 'Playground', - builder: - _design_system_gallery_components_badge_stream_online_indicator - .buildStreamOnlineIndicatorPlayground, - ), - _widgetbook.WidgetbookUseCase( - name: 'Showcase', - builder: - _design_system_gallery_components_badge_stream_online_indicator - .buildStreamOnlineIndicatorShowcase, - ), - ], - ), - ], - ), _widgetbook.WidgetbookFolder( name: 'Message Composer', children: [ diff --git a/apps/design_system_gallery/lib/components/badge/stream_online_indicator.dart b/apps/design_system_gallery/lib/components/badge/stream_online_indicator.dart index 7135606..1dee61b 100644 --- a/apps/design_system_gallery/lib/components/badge/stream_online_indicator.dart +++ b/apps/design_system_gallery/lib/components/badge/stream_online_indicator.dart @@ -10,7 +10,7 @@ import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @widgetbook.UseCase( name: 'Playground', type: StreamOnlineIndicator, - path: '[Components]/Indicator', + path: '[Components]/Badge', ) Widget buildStreamOnlineIndicatorPlayground(BuildContext context) { final isOnline = context.knobs.boolean( @@ -105,7 +105,7 @@ Widget buildStreamOnlineIndicatorPlayground(BuildContext context) { @widgetbook.UseCase( name: 'Showcase', type: StreamOnlineIndicator, - path: '[Components]/Indicator', + path: '[Components]/Badge', ) Widget buildStreamOnlineIndicatorShowcase(BuildContext context) { final colorScheme = context.streamColorScheme; diff --git a/apps/design_system_gallery/lib/components/buttons/button.dart b/apps/design_system_gallery/lib/components/buttons/button.dart index dbdda8c..51447d7 100644 --- a/apps/design_system_gallery/lib/components/buttons/button.dart +++ b/apps/design_system_gallery/lib/components/buttons/button.dart @@ -10,7 +10,7 @@ import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @widgetbook.UseCase( name: 'Playground', type: StreamButton, - path: '[Components]/Button', + path: '[Components]/Buttons', ) Widget buildStreamButtonPlayground(BuildContext context) { final label = context.knobs.string( @@ -90,7 +90,7 @@ Widget buildStreamButtonPlayground(BuildContext context) { @widgetbook.UseCase( name: 'Type Variants', type: StreamButton, - path: '[Components]/Button', + path: '[Components]/Buttons', ) Widget buildStreamButtonTypes(BuildContext context) { final theme = StreamTheme.of(context); @@ -142,7 +142,7 @@ Widget buildStreamButtonTypes(BuildContext context) { @widgetbook.UseCase( name: 'Size Variants', type: StreamButton, - path: '[Components]/Button', + path: '[Components]/Buttons', ) Widget buildStreamButtonSizes(BuildContext context) { final theme = StreamTheme.of(context); @@ -194,7 +194,7 @@ Widget buildStreamButtonSizes(BuildContext context) { @widgetbook.UseCase( name: 'With Icons', type: StreamButton, - path: '[Components]/Button', + path: '[Components]/Buttons', ) Widget buildStreamButtonWithIcons(BuildContext context) { final theme = StreamTheme.of(context); @@ -316,7 +316,7 @@ Widget buildStreamButtonWithIcons(BuildContext context) { @widgetbook.UseCase( name: 'Real-world Example', type: StreamButton, - path: '[Components]/Button', + path: '[Components]/Buttons', ) Widget buildStreamButtonExample(BuildContext context) { final theme = StreamTheme.of(context); diff --git a/apps/design_system_gallery/lib/components/buttons/stream_emoji_button.dart b/apps/design_system_gallery/lib/components/buttons/stream_emoji_button.dart index b90df65..b6f92be 100644 --- a/apps/design_system_gallery/lib/components/buttons/stream_emoji_button.dart +++ b/apps/design_system_gallery/lib/components/buttons/stream_emoji_button.dart @@ -11,7 +11,7 @@ import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; @widgetbook.UseCase( name: 'Playground', type: StreamEmojiButton, - path: '[Components]/Button', + path: '[Components]/Buttons', ) Widget buildStreamEmojiButtonPlayground(BuildContext context) { return const _PlaygroundDemo(); @@ -80,7 +80,7 @@ class _PlaygroundDemoState extends State<_PlaygroundDemo> { @widgetbook.UseCase( name: 'Showcase', type: StreamEmojiButton, - path: '[Components]/Button', + path: '[Components]/Buttons', ) Widget buildStreamEmojiButtonShowcase(BuildContext context) { final colorScheme = context.streamColorScheme; From 5e1b5db82f854a78c1999f3a44e164b59e497405 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 13 Feb 2026 14:39:57 +0530 Subject: [PATCH 12/17] feat(ui): enhance StreamReactionPickerSheet with safe area and updated button styles --- .../picker/stream_reaction_picker_sheet.dart | 41 ++++--------------- 1 file changed, 9 insertions(+), 32 deletions(-) diff --git a/packages/stream_core_flutter/lib/src/components/reaction/picker/stream_reaction_picker_sheet.dart b/packages/stream_core_flutter/lib/src/components/reaction/picker/stream_reaction_picker_sheet.dart index 161657d..7d7f144 100644 --- a/packages/stream_core_flutter/lib/src/components/reaction/picker/stream_reaction_picker_sheet.dart +++ b/packages/stream_core_flutter/lib/src/components/reaction/picker/stream_reaction_picker_sheet.dart @@ -164,6 +164,7 @@ class StreamReactionPickerSheet extends StatefulWidget { return showModalBottomSheet( context: context, + useSafeArea: true, isScrollControlled: true, showDragHandle: true, backgroundColor: effectiveBackgroundColor, @@ -476,19 +477,21 @@ class _CategoryTabBarState extends State<_CategoryTabBar> { ), child: SingleChildScrollView( scrollDirection: .horizontal, - padding: EdgeInsetsDirectional.only( - start: spacing.md, - end: spacing.md, - top: spacing.xs, + padding: EdgeInsetsDirectional.symmetric( + vertical: spacing.xs, + horizontal: spacing.md, ), child: Row( spacing: spacing.xxxs, children: [ for (final category in widget.categories) - _CategoryTab( + StreamButton.icon( key: _tabKeys[category], icon: category.icon(context), - isActive: category == widget.activeCategory, + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + size: StreamButtonSize.large, + isSelected: category == widget.activeCategory, onTap: () => widget.onCategorySelected(category), ), ], @@ -498,32 +501,6 @@ class _CategoryTabBarState extends State<_CategoryTabBar> { } } -class _CategoryTab extends StatelessWidget { - const _CategoryTab({ - super.key, - required this.icon, - required this.isActive, - required this.onTap, - }); - - final IconData icon; - final bool isActive; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final colorScheme = context.streamColorScheme; - - // TODO: Use StreamIconButton when available (with isSelected support) - return StreamEmojiButton( - size: .lg, - emoji: Icon(icon, color: colorScheme.textSecondary), - isSelected: isActive, - onPressed: onTap, - ); - } -} - // Insert any item inBetween the iterable items. extension _IterableExtension on Iterable { Iterable insertBetween(T separator) { From 289e48fc1f4e8f19f4ae52ce888cc918432a4542 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 13 Feb 2026 14:40:48 +0530 Subject: [PATCH 13/17] feat(ui): refactor StreamButton to support selection state and improved theming - Add `isSelected` property to `StreamButton` and `StreamButtonProps`. - Convert `DefaultStreamButton` to `StatefulWidget` to manage `WidgetStatesController` for state updates. - Update `StreamButtonThemeStyle` to include `overlayColor` and `elevation` properties. - Refactor default button styles into dedicated classes (e.g., `_PrimarySolidDefaults`) for better maintainability. - Update `StreamButtonSize` enum with explicit pixel values. - Add comprehensive documentation for button components and theme classes. --- .../src/components/buttons/stream_button.dart | 820 ++++++++++++++---- .../theme/components/stream_button_theme.dart | 99 +++ 2 files changed, 753 insertions(+), 166 deletions(-) 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 f86393f..c037ce3 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,19 +2,64 @@ 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/semantics/stream_color_scheme.dart'; import '../../theme/stream_theme_extensions.dart'; +/// A versatile button with support for multiple styles, types, and sizes. +/// +/// [StreamButton] renders a label-based button or an icon-only button via the +/// [StreamButton.icon] constructor. The button adapts its appearance based on +/// the combination of [StreamButtonStyle], [StreamButtonType], and interaction +/// state (hover, pressed, disabled, selected). +/// +/// All visual states can be customized via [StreamButtonTheme]. +/// +/// {@tool snippet} +/// +/// Display a primary solid button: +/// +/// ```dart +/// StreamButton( +/// label: 'Submit', +/// onTap: () => print('submitted'), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Display a selectable ghost button: +/// +/// ```dart +/// StreamButton( +/// label: 'Filter', +/// style: StreamButtonStyle.secondary, +/// type: StreamButtonType.ghost, +/// isSelected: isActive, +/// onTap: () => toggleFilter(), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamButtonTheme], for customizing button appearance. +/// * [StreamButtonStyle], for available style variants. +/// * [StreamButtonType], for available type variants. +/// * [StreamButtonSize], for available size variants. class StreamButton extends StatelessWidget { + /// Creates a label button with optional leading and trailing icons. StreamButton({ super.key, required String label, VoidCallback? onTap, - StreamButtonStyle style = StreamButtonStyle.primary, - StreamButtonType type = StreamButtonType.solid, - StreamButtonSize size = StreamButtonSize.medium, + StreamButtonStyle style = .primary, + StreamButtonType type = .solid, + StreamButtonSize size = .medium, IconData? iconLeft, IconData? iconRight, + bool? isSelected, }) : props = .new( label: label, onTap: onTap, @@ -23,27 +68,32 @@ class StreamButton extends StatelessWidget { size: size, iconLeft: iconLeft, iconRight: iconRight, + isSelected: isSelected, ); + /// Creates a circular icon-only button. + /// + /// Set [isFloating] to true for an floating button with a shadow. StreamButton.icon({ super.key, VoidCallback? onTap, - StreamButtonStyle style = StreamButtonStyle.primary, - StreamButtonType type = StreamButtonType.solid, - StreamButtonSize size = StreamButtonSize.medium, + StreamButtonStyle style = .primary, + StreamButtonType type = .solid, + StreamButtonSize size = .medium, IconData? icon, - bool isFloating = false, + bool? isFloating, + bool? isSelected, }) : props = .new( - label: null, onTap: onTap, style: style, type: type, size: size, iconLeft: icon, - iconRight: null, isFloating: isFloating, + isSelected: isSelected, ); + /// The props controlling the appearance and behavior of this button. final StreamButtonProps props; @override @@ -54,205 +104,643 @@ class StreamButton extends StatelessWidget { } } +/// Properties for configuring a [StreamButton]. +/// +/// This class holds all the configuration options for a button, +/// allowing them to be passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamButton], which uses these properties. +/// * [DefaultStreamButton], the default implementation. class StreamButtonProps { + /// Creates properties for a button. const StreamButtonProps({ - required this.label, - required this.onTap, - required this.style, - required this.type, - required this.size, - required this.iconLeft, - required this.iconRight, - this.isFloating = false, + this.label, + this.onTap, + this.style = .primary, + this.type = .solid, + this.size = .medium, + this.iconLeft, + this.iconRight, + this.isFloating, + this.isSelected, }); + /// The label text displayed on the button. + /// + /// If null, the button is rendered as a circular icon-only button. final String? label; + + /// Called when the button is pressed. + /// + /// If null, the button will be disabled. final VoidCallback? onTap; + + /// The visual style variant of the button. + /// + /// Determines the color scheme used (primary, secondary, destructive). final StreamButtonStyle style; + + /// The type variant of the button. + /// + /// Controls the visual weight (solid, outline, ghost). final StreamButtonType type; + + /// The size of the button. final StreamButtonSize size; + + /// The icon displayed on the left side of the label. final IconData? iconLeft; + + /// The icon displayed on the right side of the label. final IconData? iconRight; - final bool isFloating; + + /// Whether the button has a floating (elevated) appearance. + /// + /// When true, the button gains elevation and a background fill + /// for outline and ghost types. + /// When false or null, the button is not floating. + final bool? isFloating; + + /// Whether the button is in a selected state. + /// + /// When true, the button displays selected styling. + /// When false or null, the button is not selected. + final bool? isSelected; } -enum StreamButtonStyle { primary, secondary, destructive } +/// The color scheme variant for a [StreamButton]. +/// +/// Each style maps to a distinct set of colors defined in the theme. +enum StreamButtonStyle { + /// Uses the brand/accent color scheme. + primary, + + /// Uses the neutral/surface color scheme. + secondary, + + /// Uses the error/danger color scheme. + destructive, +} -enum StreamButtonSize { small, medium, large } +/// The visual weight variant for a [StreamButton]. +/// +/// Controls how prominently the button is displayed. +enum StreamButtonType { + /// Filled background with high visual emphasis. + solid, + + /// Bordered with transparent background for medium emphasis. + outline, + + /// No border or background for low emphasis. + ghost, +} -enum StreamButtonType { solid, outline, ghost } +/// Predefined sizes for [StreamButton]. +/// +/// Each size corresponds to a specific dimension in logical pixels. +/// +/// See also: +/// +/// * [StreamButtonThemeData], for setting global button styles. +enum StreamButtonSize { + /// Small button (32px). + small(32), -class DefaultStreamButton extends StatelessWidget { + /// Medium button (40px). + medium(40), + + /// Large button (48px). + large(48) + ; + + /// Constructs a [StreamButtonSize] with the given dimension. + const StreamButtonSize(this.value); + + /// The dimension of the button in logical pixels. + final double value; +} + +/// Default implementation of [StreamButton]. +/// +/// Renders the button using [ElevatedButton] with theme-aware styling and +/// state-based visual feedback. Uses [WidgetStatesController] to manage +/// the selected state. +class DefaultStreamButton extends StatefulWidget { + /// Creates a default button. const DefaultStreamButton({super.key, required this.props}); + /// The props controlling the appearance and behavior of this button. final StreamButtonProps props; + @override + State createState() => _DefaultStreamButtonState(); +} + +class _DefaultStreamButtonState extends State { + StreamButtonProps get props => widget.props; + late final WidgetStatesController _statesController; + + @override + void initState() { + super.initState(); + _statesController = WidgetStatesController( + {if (props.isSelected ?? false) WidgetState.selected}, + ); + } + + @override + void didUpdateWidget(DefaultStreamButton oldWidget) { + super.didUpdateWidget(oldWidget); + _statesController.update(WidgetState.selected, props.isSelected ?? false); + } + + @override + void dispose() { + _statesController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { + final radius = context.streamRadius; final spacing = context.streamSpacing; final buttonTheme = context.streamButtonTheme; - final colorScheme = context.streamColorScheme; - final defaults = _StreamButtonDefaults(context: context); - - final themeButtonTypeStyle = switch (props.style) { - StreamButtonStyle.primary => buttonTheme.primary, - StreamButtonStyle.secondary => buttonTheme.secondary, - StreamButtonStyle.destructive => buttonTheme.destructive, - }; - final themeStyle = switch (props.type) { - StreamButtonType.solid => themeButtonTypeStyle?.solid, - StreamButtonType.outline => themeButtonTypeStyle?.outline, - StreamButtonType.ghost => themeButtonTypeStyle?.ghost, + final themeStyle = 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 defaultButtonTypeStyle = switch (props.style) { - StreamButtonStyle.primary => defaults.primary, - StreamButtonStyle.secondary => defaults.secondary, - StreamButtonStyle.destructive => defaults.destructive, + 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 defaultStyle = switch (props.type) { - StreamButtonType.solid => defaultButtonTypeStyle.solid, - StreamButtonType.outline => defaultButtonTypeStyle.outline, - StreamButtonType.ghost => defaultButtonTypeStyle.ghost, - }; - - final fallbackBackgroundColor = props.isFloating ? colorScheme.backgroundElevation1 : Colors.transparent; - final backgroundColor = - themeStyle?.backgroundColor ?? - defaultStyle?.backgroundColor ?? - WidgetStateProperty.all(fallbackBackgroundColor); - final foregroundColor = themeStyle?.foregroundColor ?? defaultStyle?.foregroundColor; - final borderColor = themeStyle?.borderColor ?? defaultStyle?.borderColor; - - final minimumSize = switch (props.size) { - StreamButtonSize.small => 32.0, - StreamButtonSize.medium => 40.0, - StreamButtonSize.large => 48.0, - }; + 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; - const iconSize = 20.0; + final buttonSize = props.size.value; final isIconButton = props.label == null; return ElevatedButton( onPressed: props.onTap, + statesController: _statesController, style: ButtonStyle( - backgroundColor: backgroundColor, - foregroundColor: foregroundColor, - minimumSize: WidgetStateProperty.all(Size(minimumSize, minimumSize)), - maximumSize: isIconButton ? WidgetStateProperty.all(Size(minimumSize, minimumSize)) : null, - tapTargetSize: MaterialTapTargetSize.padded, - elevation: WidgetStateProperty.all(props.isFloating ? 4 : 0), - padding: WidgetStateProperty.all( - isIconButton ? EdgeInsets.zero : EdgeInsets.symmetric(horizontal: spacing.md), - ), - - side: borderColor == null - ? null - : WidgetStateProperty.resolveWith( - (states) => BorderSide(color: borderColor.resolve(states)), - ), - shape: props.label == null - ? WidgetStateProperty.all(const CircleBorder()) - : WidgetStateProperty.all( - RoundedRectangleBorder( - borderRadius: BorderRadius.all(context.streamRadius.max), - ), - ), + iconSize: .all(20), + tapTargetSize: .padded, + visualDensity: .standard, + elevation: effectiveElevation, + backgroundColor: effectiveBackgroundColor, + foregroundColor: effectiveForegroundColor, + overlayColor: effectiveOverlayColor, + minimumSize: .all(.square(buttonSize)), + maximumSize: .all(isIconButton ? .square(buttonSize) : .fromHeight(buttonSize)), + padding: .all(isIconButton ? .zero : .symmetric(horizontal: spacing.md)), + side: switch (effectiveBorderColor) { + final color? => .resolveWith((states) => .new(color: color.resolve(states))), + _ => null, + }, + shape: switch (props.label) { + null => .all(const CircleBorder()), + _ => .all(RoundedRectangleBorder(borderRadius: .all(radius.max))), + }, ), - child: isIconButton - ? Icon(props.iconLeft, size: iconSize) - : Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - spacing: spacing.xs, - children: [ - if (props.iconLeft case final iconLeft?) Icon(iconLeft, size: iconSize), - if (props.label case final label?) Text(label), - if (props.iconRight case final iconRight?) Icon(iconRight, size: iconSize), - ], - ), + child: switch (isIconButton) { + true => Icon(props.iconLeft), + false => Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + spacing: spacing.xs, + children: [ + if (props.iconLeft case final iconLeft?) Icon(iconLeft), + if (props.label case final label?) Flexible(child: Text(label)), + if (props.iconRight case final iconRight?) Icon(iconRight), + ], + ), + }, ); } } -class _StreamButtonDefaults { - _StreamButtonDefaults({required this.context}) : _colorScheme = context.streamColorScheme; +// -- Primary defaults ------------------------------------------------------- + +// Default style for primary solid buttons. +class _PrimarySolidDefaults extends StreamButtonThemeStyle { + _PrimarySolidDefaults( + this.context, { + required this.isFloating, + }) : _colorScheme = context.streamColorScheme; final BuildContext context; final StreamColorScheme _colorScheme; + final bool isFloating; - StreamButtonTypeStyle get primary => StreamButtonTypeStyle( - solid: StreamButtonThemeStyle( - backgroundColor: WidgetStateProperty.resolveWith( - (states) => - states.contains(WidgetState.disabled) ? _colorScheme.backgroundDisabled : _colorScheme.brand.shade500, - ), - foregroundColor: WidgetStateProperty.resolveWith( - (states) => states.contains(WidgetState.disabled) ? _colorScheme.textDisabled : _colorScheme.textOnAccent, - ), - ), - outline: StreamButtonThemeStyle( - borderColor: WidgetStateProperty.resolveWith( - (states) => states.contains(WidgetState.disabled) ? _colorScheme.borderDefault : _colorScheme.brand.shade200, - ), - foregroundColor: WidgetStateProperty.resolveWith( - (states) => states.contains(WidgetState.disabled) ? _colorScheme.textDisabled : _colorScheme.brand.shade500, - ), - ), - ghost: StreamButtonThemeStyle( - foregroundColor: WidgetStateProperty.resolveWith( - (states) => states.contains(WidgetState.disabled) ? _colorScheme.textDisabled : _colorScheme.brand.shade500, - ), - ), - ); - - StreamButtonTypeStyle get secondary => StreamButtonTypeStyle( - solid: StreamButtonThemeStyle( - backgroundColor: WidgetStateProperty.resolveWith( - (states) => - states.contains(WidgetState.disabled) ? _colorScheme.backgroundDisabled : _colorScheme.backgroundSurface, - ), - foregroundColor: WidgetStateProperty.resolveWith( - (states) => states.contains(WidgetState.disabled) ? _colorScheme.textDisabled : _colorScheme.textPrimary, - ), - ), - outline: StreamButtonThemeStyle( - borderColor: WidgetStateProperty.resolveWith( - (states) => states.contains(WidgetState.disabled) ? _colorScheme.borderDefault : _colorScheme.borderDefault, - ), - foregroundColor: WidgetStateProperty.resolveWith( - (states) => states.contains(WidgetState.disabled) ? _colorScheme.textDisabled : _colorScheme.textPrimary, - ), - ), - ghost: StreamButtonThemeStyle( - foregroundColor: WidgetStateProperty.resolveWith( - (states) => states.contains(WidgetState.disabled) ? _colorScheme.textDisabled : _colorScheme.textPrimary, - ), - ), - ); - StreamButtonTypeStyle get destructive => StreamButtonTypeStyle( - solid: StreamButtonThemeStyle( - backgroundColor: WidgetStateProperty.resolveWith( - (states) => states.contains(WidgetState.disabled) ? _colorScheme.backgroundDisabled : _colorScheme.accentError, - ), - foregroundColor: WidgetStateProperty.resolveWith( - (states) => states.contains(WidgetState.disabled) ? _colorScheme.textDisabled : _colorScheme.textOnAccent, - ), - ), - outline: StreamButtonThemeStyle( - borderColor: WidgetStateProperty.resolveWith( - (states) => states.contains(WidgetState.disabled) ? _colorScheme.borderDefault : _colorScheme.accentError, - ), - foregroundColor: WidgetStateProperty.resolveWith( - (states) => states.contains(WidgetState.disabled) ? _colorScheme.textDisabled : _colorScheme.accentError, - ), - ), - ghost: StreamButtonThemeStyle( - foregroundColor: WidgetStateProperty.resolveWith( - (states) => states.contains(WidgetState.disabled) ? _colorScheme.textDisabled : _colorScheme.accentError, - ), - ), - ); + @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.stateSelected, base); + return base; + }); + + @override + WidgetStateProperty? get foregroundColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return _colorScheme.textDisabled; + return _colorScheme.textOnAccent; + }); + + @override + WidgetStateProperty? get overlayColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) return _colorScheme.statePressed; + if (states.contains(WidgetState.hovered)) return _colorScheme.stateHover; + return StreamColors.transparent; + }); + + @override + WidgetStateProperty? get elevation => WidgetStateProperty.resolveWith((states) { + if (!isFloating) return 0; + if (states.contains(WidgetState.disabled)) return 0.0; + if (states.contains(WidgetState.pressed)) return 6.0; + if (states.contains(WidgetState.hovered)) return 8.0; + return 6.0; + }); +} + +// Default style for primary outline buttons. +class _PrimaryOutlineDefaults extends StreamButtonThemeStyle { + _PrimaryOutlineDefaults( + this.context, { + required this.isFloating, + }) : _colorScheme = context.streamColorScheme; + + final BuildContext context; + final StreamColorScheme _colorScheme; + final bool isFloating; + + @override + WidgetStateProperty? get backgroundColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return StreamColors.transparent; + final base = isFloating ? _colorScheme.backgroundElevation1 : StreamColors.transparent; + if (states.contains(WidgetState.selected)) return .alphaBlend(_colorScheme.stateSelected, 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; + }); + + @override + WidgetStateProperty? get overlayColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) return _colorScheme.statePressed; + if (states.contains(WidgetState.hovered)) return _colorScheme.stateHover; + return StreamColors.transparent; + }); + + @override + WidgetStateProperty? get elevation => WidgetStateProperty.resolveWith((states) { + if (!isFloating) return 0; + if (states.contains(WidgetState.disabled)) return 0.0; + if (states.contains(WidgetState.pressed)) return 6.0; + if (states.contains(WidgetState.hovered)) return 8.0; + return 6.0; + }); +} + +// Default style for primary ghost buttons. +class _PrimaryGhostDefaults extends StreamButtonThemeStyle { + _PrimaryGhostDefaults( + this.context, { + required this.isFloating, + }) : _colorScheme = context.streamColorScheme; + + final BuildContext context; + final StreamColorScheme _colorScheme; + final bool isFloating; + + @override + WidgetStateProperty? get backgroundColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return StreamColors.transparent; + final base = isFloating ? _colorScheme.backgroundElevation1 : StreamColors.transparent; + if (states.contains(WidgetState.selected)) return .alphaBlend(_colorScheme.stateSelected, base); + return base; + }); + + @override + WidgetStateProperty? get foregroundColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return _colorScheme.textDisabled; + return _colorScheme.accentPrimary; + }); + + @override + WidgetStateProperty? get overlayColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) return _colorScheme.statePressed; + if (states.contains(WidgetState.hovered)) return _colorScheme.stateHover; + return StreamColors.transparent; + }); + + @override + WidgetStateProperty? get elevation => WidgetStateProperty.resolveWith((states) { + if (!isFloating) return 0; + if (states.contains(WidgetState.disabled)) return 0.0; + if (states.contains(WidgetState.pressed)) return 6.0; + if (states.contains(WidgetState.hovered)) return 8.0; + return 6.0; + }); +} + +// -- Secondary defaults ----------------------------------------------------- + +// Default style for secondary solid buttons. +class _SecondarySolidDefaults extends StreamButtonThemeStyle { + _SecondarySolidDefaults( + this.context, { + required this.isFloating, + }) : _colorScheme = context.streamColorScheme; + + final BuildContext context; + final StreamColorScheme _colorScheme; + 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.stateSelected, base); + return base; + }); + + @override + WidgetStateProperty? get foregroundColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return _colorScheme.textDisabled; + return _colorScheme.textPrimary; + }); + + @override + WidgetStateProperty? get overlayColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) return _colorScheme.statePressed; + if (states.contains(WidgetState.hovered)) return _colorScheme.stateHover; + return StreamColors.transparent; + }); + + @override + WidgetStateProperty? get elevation => WidgetStateProperty.resolveWith((states) { + if (!isFloating) return 0; + if (states.contains(WidgetState.disabled)) return 0.0; + if (states.contains(WidgetState.pressed)) return 6.0; + if (states.contains(WidgetState.hovered)) return 8.0; + return 6.0; + }); +} + +// Default style for secondary outline buttons. +class _SecondaryOutlineDefaults extends StreamButtonThemeStyle { + _SecondaryOutlineDefaults( + this.context, { + required this.isFloating, + }) : _colorScheme = context.streamColorScheme; + + final BuildContext context; + final StreamColorScheme _colorScheme; + final bool isFloating; + + @override + WidgetStateProperty? get backgroundColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return StreamColors.transparent; + final base = isFloating ? _colorScheme.backgroundElevation1 : StreamColors.transparent; + if (states.contains(WidgetState.selected)) return .alphaBlend(_colorScheme.stateSelected, 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; + }); + + @override + WidgetStateProperty? get overlayColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) return _colorScheme.statePressed; + if (states.contains(WidgetState.hovered)) return _colorScheme.stateHover; + return StreamColors.transparent; + }); + + @override + WidgetStateProperty? get elevation => WidgetStateProperty.resolveWith((states) { + if (!isFloating) return 0; + if (states.contains(WidgetState.disabled)) return 0.0; + if (states.contains(WidgetState.pressed)) return 6.0; + if (states.contains(WidgetState.hovered)) return 8.0; + return 6.0; + }); +} + +// Default style for secondary ghost buttons. +class _SecondaryGhostDefaults extends StreamButtonThemeStyle { + _SecondaryGhostDefaults( + this.context, { + required this.isFloating, + }) : _colorScheme = context.streamColorScheme; + + final BuildContext context; + final StreamColorScheme _colorScheme; + final bool isFloating; + + @override + WidgetStateProperty? get backgroundColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return StreamColors.transparent; + final base = isFloating ? _colorScheme.backgroundElevation1 : StreamColors.transparent; + if (states.contains(WidgetState.selected)) return .alphaBlend(_colorScheme.stateSelected, base); + return base; + }); + + @override + WidgetStateProperty? get foregroundColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return _colorScheme.textDisabled; + return _colorScheme.textPrimary; + }); + + @override + WidgetStateProperty? get overlayColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) return _colorScheme.statePressed; + if (states.contains(WidgetState.hovered)) return _colorScheme.stateHover; + return StreamColors.transparent; + }); + + @override + WidgetStateProperty? get elevation => WidgetStateProperty.resolveWith((states) { + if (!isFloating) return 0; + if (states.contains(WidgetState.disabled)) return 0.0; + if (states.contains(WidgetState.pressed)) return 6.0; + if (states.contains(WidgetState.hovered)) return 8.0; + return 6.0; + }); +} + +// -- Destructive defaults --------------------------------------------------- + +// Default style for destructive solid buttons. +class _DestructiveSolidDefaults extends StreamButtonThemeStyle { + _DestructiveSolidDefaults( + this.context, { + required this.isFloating, + }) : _colorScheme = context.streamColorScheme; + + final BuildContext context; + final StreamColorScheme _colorScheme; + 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.stateSelected, base); + return base; + }); + + @override + WidgetStateProperty? get foregroundColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return _colorScheme.textDisabled; + return _colorScheme.textOnAccent; + }); + + @override + WidgetStateProperty? get overlayColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) return _colorScheme.statePressed; + if (states.contains(WidgetState.hovered)) return _colorScheme.stateHover; + return StreamColors.transparent; + }); + + @override + WidgetStateProperty? get elevation => WidgetStateProperty.resolveWith((states) { + if (!isFloating) return 0; + if (states.contains(WidgetState.disabled)) return 0.0; + if (states.contains(WidgetState.pressed)) return 6.0; + if (states.contains(WidgetState.hovered)) return 8.0; + return 6.0; + }); +} + +// Default style for destructive outline buttons. +class _DestructiveOutlineDefaults extends StreamButtonThemeStyle { + _DestructiveOutlineDefaults( + this.context, { + required this.isFloating, + }) : _colorScheme = context.streamColorScheme; + + final BuildContext context; + final StreamColorScheme _colorScheme; + final bool isFloating; + + @override + WidgetStateProperty? get backgroundColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return StreamColors.transparent; + final base = isFloating ? _colorScheme.backgroundElevation1 : StreamColors.transparent; + if (states.contains(WidgetState.selected)) return .alphaBlend(_colorScheme.stateSelected, 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; + }); + + @override + WidgetStateProperty? get overlayColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) return _colorScheme.statePressed; + if (states.contains(WidgetState.hovered)) return _colorScheme.stateHover; + return StreamColors.transparent; + }); + + @override + WidgetStateProperty? get elevation => WidgetStateProperty.resolveWith((states) { + if (!isFloating) return 0; + if (states.contains(WidgetState.disabled)) return 0.0; + if (states.contains(WidgetState.pressed)) return 6.0; + if (states.contains(WidgetState.hovered)) return 8.0; + return 6.0; + }); +} + +// Default style for destructive ghost buttons. +class _DestructiveGhostDefaults extends StreamButtonThemeStyle { + _DestructiveGhostDefaults( + this.context, { + required this.isFloating, + }) : _colorScheme = context.streamColorScheme; + + final BuildContext context; + final StreamColorScheme _colorScheme; + final bool isFloating; + + @override + WidgetStateProperty? get backgroundColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return StreamColors.transparent; + final base = isFloating ? _colorScheme.backgroundElevation1 : StreamColors.transparent; + if (states.contains(WidgetState.selected)) return .alphaBlend(_colorScheme.stateSelected, base); + return base; + }); + + @override + WidgetStateProperty? get foregroundColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.disabled)) return _colorScheme.textDisabled; + return _colorScheme.accentError; + }); + + @override + WidgetStateProperty? get overlayColor => WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.pressed)) return _colorScheme.statePressed; + if (states.contains(WidgetState.hovered)) return _colorScheme.stateHover; + return StreamColors.transparent; + }); + + @override + WidgetStateProperty? get elevation => WidgetStateProperty.resolveWith((states) { + if (!isFloating) return 0; + if (states.contains(WidgetState.disabled)) return 0.0; + if (states.contains(WidgetState.pressed)) return 6.0; + if (states.contains(WidgetState.hovered)) return 8.0; + return 6.0; + }); } diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_button_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_button_theme.dart index f586548..7c0b4d0 100644 --- a/packages/stream_core_flutter/lib/src/theme/components/stream_button_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_button_theme.dart @@ -5,15 +5,51 @@ import '../stream_theme.dart'; part 'stream_button_theme.g.theme.dart'; +/// Applies a button theme to descendant [StreamButton] widgets. +/// +/// Wrap a subtree with [StreamButtonTheme] to override button styling. +/// Access the merged theme using [BuildContext.streamButtonTheme]. +/// +/// {@tool snippet} +/// +/// Override button styling for a specific section: +/// +/// ```dart +/// StreamButtonTheme( +/// data: StreamButtonThemeData( +/// primary: StreamButtonTypeStyle( +/// solid: StreamButtonThemeStyle( +/// backgroundColor: WidgetStateProperty.all(Colors.blue), +/// ), +/// ), +/// ), +/// child: StreamButton( +/// label: 'Submit', +/// onTap: () {}, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamButtonThemeData], which describes the button theme. +/// * [StreamButton], which uses this theme. class StreamButtonTheme extends InheritedTheme { + /// Creates a button theme that controls descendant buttons. const StreamButtonTheme({ super.key, required this.data, required super.child, }); + /// The button theme data for descendant widgets. final StreamButtonThemeData data; + /// Returns the [StreamButtonThemeData] from the current theme context. + /// + /// This merges the local theme (if any) with the global theme from + /// [StreamTheme]. static StreamButtonThemeData of(BuildContext context) { final localTheme = context.dependOnInheritedWidgetOfExactType(); return StreamTheme.of(context).buttonTheme.merge(localTheme?.data); @@ -28,42 +64,105 @@ class StreamButtonTheme extends InheritedTheme { bool updateShouldNotify(StreamButtonTheme oldWidget) => data != oldWidget.data; } +/// Theme data for customizing [StreamButton] widgets. +/// +/// Organizes button styles by [StreamButtonStyle] variant (primary, +/// secondary, destructive). Each variant can define styles for all +/// [StreamButtonType]s (solid, outline, ghost). +/// +/// See also: +/// +/// * [StreamButtonTheme], for overriding theme in a widget subtree. +/// * [StreamButtonTypeStyle], for per-type styling. @themeGen @immutable class StreamButtonThemeData with _$StreamButtonThemeData { + /// Creates button theme data with optional style overrides per variant. const StreamButtonThemeData({ this.primary, this.secondary, this.destructive, }); + /// Styles for primary (brand/accent) buttons. final StreamButtonTypeStyle? primary; + + /// Styles for secondary (neutral) buttons. final StreamButtonTypeStyle? secondary; + + /// Styles for destructive (error/danger) buttons. final StreamButtonTypeStyle? destructive; } +/// Organizes button theme styles by [StreamButtonType] variant. +/// +/// See also: +/// +/// * [StreamButtonThemeData], which uses this per style variant. +/// * [StreamButtonThemeStyle], for the individual style properties. @themeGen @immutable class StreamButtonTypeStyle with _$StreamButtonTypeStyle { + /// Creates type-specific button styles. const StreamButtonTypeStyle({ this.solid, this.outline, this.ghost, }); + /// Style for solid (filled) buttons. final StreamButtonThemeStyle? solid; + + /// Style for outline (bordered) buttons. final StreamButtonThemeStyle? outline; + + /// Style for ghost (borderless) buttons. final StreamButtonThemeStyle? ghost; } +/// Visual styling properties for a single button style/type combination. +/// +/// Defines the appearance of buttons including colors and borders. +/// All color properties support state-based styling for interactive feedback +/// (default, hover, pressed, disabled, selected). +/// +/// See also: +/// +/// * [StreamButtonTypeStyle], which wraps this style per type variant. +/// * [StreamButton], which uses this styling. class StreamButtonThemeStyle { + /// Creates button style properties. const StreamButtonThemeStyle({ this.backgroundColor, this.foregroundColor, this.borderColor, + this.overlayColor, + this.elevation, }); + /// The background color for the button. + /// + /// Supports state-based colors for different interaction states + /// (default, hover, pressed, disabled, selected). final WidgetStateProperty? backgroundColor; + + /// The foreground color for the button text and icons. + /// + /// Supports state-based colors for different interaction states. final WidgetStateProperty? foregroundColor; + + /// The border color for the button. + /// + /// Typically used by outline-type buttons. If null, no border is rendered. final WidgetStateProperty? borderColor; + + /// The overlay color for the button's interaction feedback. + /// + /// Supports state-based colors for hover and press states. + final WidgetStateProperty? overlayColor; + + /// The elevation of the button. + /// + /// Controls the shadow depth. Typically non-zero only for floating buttons. + final WidgetStateProperty? elevation; } From baa32fad296f488b2155a2a87430d1c379a67959 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 13 Feb 2026 14:41:59 +0530 Subject: [PATCH 14/17] feat(gallery): add debug tools and refactor toolbar widgets --- .../widgets/toolbar/debug_paint_toggle.dart | 29 ++++++++++ .../lib/widgets/toolbar/toolbar.dart | 54 +++++-------------- .../lib/widgets/toolbar/toolbar_widgets.dart | 11 ---- .../widgets/toolbar/widget_select_toggle.dart | 25 +++++++++ 4 files changed, 66 insertions(+), 53 deletions(-) create mode 100644 apps/design_system_gallery/lib/widgets/toolbar/debug_paint_toggle.dart delete mode 100644 apps/design_system_gallery/lib/widgets/toolbar/toolbar_widgets.dart create mode 100644 apps/design_system_gallery/lib/widgets/toolbar/widget_select_toggle.dart diff --git a/apps/design_system_gallery/lib/widgets/toolbar/debug_paint_toggle.dart b/apps/design_system_gallery/lib/widgets/toolbar/debug_paint_toggle.dart new file mode 100644 index 0000000..e915777 --- /dev/null +++ b/apps/design_system_gallery/lib/widgets/toolbar/debug_paint_toggle.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +import 'toolbar_button.dart'; + +/// Debug paint toggle button for visualizing layout bounds. +class DebugPaintToggle extends StatefulWidget { + const DebugPaintToggle({super.key}); + + @override + State createState() => _DebugPaintToggleState(); +} + +class _DebugPaintToggleState extends State { + void _toggle() { + setState(() => debugPaintSizeEnabled = !debugPaintSizeEnabled); + WidgetsBinding.instance.performReassemble(); + } + + @override + Widget build(BuildContext context) { + return ToolbarButton( + icon: debugPaintSizeEnabled ? Icons.grid_on : Icons.grid_off, + tooltip: 'Layout Bounds', + isActive: debugPaintSizeEnabled, + onTap: _toggle, + ); + } +} diff --git a/apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart b/apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart index ea8c8b2..93883d5 100644 --- a/apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart +++ b/apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart @@ -1,5 +1,5 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:provider/provider.dart'; import 'package:stream_core_flutter/stream_core_flutter.dart'; import 'package:svg_icon_widget/svg_icon_widget.dart'; @@ -7,11 +7,13 @@ import 'package:svg_icon_widget/svg_icon_widget.dart'; import '../../config/preview_configuration.dart'; import '../../config/theme_configuration.dart'; import '../../core/stream_icons.dart'; +import 'debug_paint_toggle.dart'; import 'device_selector.dart'; import 'text_direction_selector.dart'; import 'text_scale_selector.dart'; import 'theme_mode_toggle.dart'; import 'toolbar_button.dart'; +import 'widget_select_toggle.dart'; /// The main toolbar for the design system gallery. /// @@ -55,6 +57,7 @@ class GalleryToolbar extends StatelessWidget { child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( + spacing: spacing.sm, mainAxisSize: MainAxisSize.min, children: [ // Device frame toggle @@ -64,17 +67,14 @@ class GalleryToolbar extends StatelessWidget { isActive: previewConfig.showDeviceFrame, onTap: previewConfig.toggleDeviceFrame, ), - SizedBox(width: spacing.sm), - // Device selector - if (previewConfig.showDeviceFrame) ...[ + // Device frame options + if (previewConfig.showDeviceFrame) DeviceSelector( selectedDevice: previewConfig.selectedDevice, devices: PreviewConfiguration.deviceOptions, onDeviceChanged: previewConfig.setDevice, ), - SizedBox(width: spacing.sm), - ], // Text scale selector TextScaleSelector( @@ -82,7 +82,6 @@ class GalleryToolbar extends StatelessWidget { options: PreviewConfiguration.textScaleOptions, onChanged: previewConfig.setTextScale, ), - SizedBox(width: spacing.sm), // Text direction selector (LTR/RTL) TextDirectionSelector( @@ -90,10 +89,12 @@ class GalleryToolbar extends StatelessWidget { options: PreviewConfiguration.textDirectionOptions, onChanged: previewConfig.setTextDirection, ), - SizedBox(width: spacing.sm), - // Debug paint toggle - const _DebugPaintToggle(), + // Debug tools (debug mode only) + if (kDebugMode) ...[ + const DebugPaintToggle(), + const WidgetSelectToggle(), + ], ], ), ), @@ -122,7 +123,7 @@ class GalleryToolbar extends StatelessWidget { } } -/// Stream branding logo and title. +// Stream branding logo and title. class _StreamBranding extends StatelessWidget { const _StreamBranding(); @@ -161,34 +162,3 @@ class _StreamBranding extends StatelessWidget { ); } } - -/// Debug paint toggle button for visualizing layout bounds. -class _DebugPaintToggle extends StatefulWidget { - const _DebugPaintToggle(); - - @override - State<_DebugPaintToggle> createState() => _DebugPaintToggleState(); -} - -class _DebugPaintToggleState extends State<_DebugPaintToggle> { - var _isActive = false; - - void _toggleDebugPaint() { - setState(() { - _isActive = !_isActive; - debugPaintSizeEnabled = _isActive; - }); - // Force a repaint to show/hide the debug bounds immediately - WidgetsBinding.instance.performReassemble(); - } - - @override - Widget build(BuildContext context) { - return ToolbarButton( - icon: _isActive ? Icons.grid_on : Icons.grid_off, - tooltip: 'Layout Bounds', - isActive: _isActive, - onTap: _toggleDebugPaint, - ); - } -} diff --git a/apps/design_system_gallery/lib/widgets/toolbar/toolbar_widgets.dart b/apps/design_system_gallery/lib/widgets/toolbar/toolbar_widgets.dart deleted file mode 100644 index b59b13a..0000000 --- a/apps/design_system_gallery/lib/widgets/toolbar/toolbar_widgets.dart +++ /dev/null @@ -1,11 +0,0 @@ -/// Toolbar widgets for the design system gallery. -/// -/// This barrel file exports all toolbar-related widgets. -library; - -export 'device_selector.dart'; -export 'text_direction_selector.dart'; -export 'text_scale_selector.dart'; -export 'theme_mode_toggle.dart'; -export 'toolbar.dart'; -export 'toolbar_button.dart'; diff --git a/apps/design_system_gallery/lib/widgets/toolbar/widget_select_toggle.dart b/apps/design_system_gallery/lib/widgets/toolbar/widget_select_toggle.dart new file mode 100644 index 0000000..ffda5b5 --- /dev/null +++ b/apps/design_system_gallery/lib/widgets/toolbar/widget_select_toggle.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +import 'toolbar_button.dart'; + +/// Widget select mode toggle for inspecting widgets in the preview. +class WidgetSelectToggle extends StatelessWidget { + const WidgetSelectToggle({super.key}); + + @override + Widget build(BuildContext context) { + final notifier = WidgetsBinding.instance.debugShowWidgetInspectorOverrideNotifier; + + return ValueListenableBuilder( + valueListenable: notifier, + builder: (context, isActive, _) { + return ToolbarButton( + icon: Icons.ads_click, + tooltip: 'Select Widget', + isActive: isActive, + onTap: () => notifier.value = !isActive, + ); + }, + ); + } +} From 883cba622b4284347a7a44532bbc8ce69e3cb7f6 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 13 Feb 2026 14:42:48 +0530 Subject: [PATCH 15/17] feat(gallery): revamp button showcase and improve preview wrapper - Refactor `PreviewWrapper` to use a nested `Navigator` so dialogs and bottom sheets open within the preview container and state is preserved. - Consolidate `StreamButton` stories into a unified `Showcase` displaying style matrices, size scales, and real-world examples. - Enhance `StreamButton` playground with interactive selection state and new knobs for icon-only and floating variants. - Update `StreamEmoji` showcase to use `Wrap` for better layout of size demos. --- .../lib/app/gallery_app.directories.g.dart | 19 +- .../components/accessories/stream_emoji.dart | 9 +- .../lib/components/buttons/button.dart | 1037 +++++++++++------ .../lib/core/preview_wrapper.dart | 38 +- 4 files changed, 739 insertions(+), 364 deletions(-) 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 38caed3..67bac2b 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 @@ -305,24 +305,9 @@ final directories = <_widgetbook.WidgetbookNode>[ .buildStreamButtonPlayground, ), _widgetbook.WidgetbookUseCase( - name: 'Real-world Example', - builder: _design_system_gallery_components_buttons_button - .buildStreamButtonExample, - ), - _widgetbook.WidgetbookUseCase( - name: 'Size Variants', - builder: _design_system_gallery_components_buttons_button - .buildStreamButtonSizes, - ), - _widgetbook.WidgetbookUseCase( - name: 'Type Variants', - builder: _design_system_gallery_components_buttons_button - .buildStreamButtonTypes, - ), - _widgetbook.WidgetbookUseCase( - name: 'With Icons', + name: 'Showcase', builder: _design_system_gallery_components_buttons_button - .buildStreamButtonWithIcons, + .buildStreamButtonShowcase, ), ], ), diff --git a/apps/design_system_gallery/lib/components/accessories/stream_emoji.dart b/apps/design_system_gallery/lib/components/accessories/stream_emoji.dart index 74eda73..806717e 100644 --- a/apps/design_system_gallery/lib/components/accessories/stream_emoji.dart +++ b/apps/design_system_gallery/lib/components/accessories/stream_emoji.dart @@ -129,12 +129,11 @@ class _SizeVariantsSection extends StatelessWidget { ), ), SizedBox(height: spacing.md), - Row( + Wrap( + spacing: spacing.xl, + runSpacing: spacing.xl, children: [ - for (final size in StreamEmojiSize.values) ...[ - _SizeDemo(size: size), - if (size != StreamEmojiSize.values.last) SizedBox(width: spacing.xl), - ], + for (final size in StreamEmojiSize.values) _SizeDemo(size: size), ], ), ], diff --git a/apps/design_system_gallery/lib/components/buttons/button.dart b/apps/design_system_gallery/lib/components/buttons/button.dart index 51447d7..2e46a09 100644 --- a/apps/design_system_gallery/lib/components/buttons/button.dart +++ b/apps/design_system_gallery/lib/components/buttons/button.dart @@ -13,122 +13,153 @@ import 'package:widgetbook_annotation/widgetbook_annotation.dart' as widgetbook; path: '[Components]/Buttons', ) Widget buildStreamButtonPlayground(BuildContext context) { - final label = context.knobs.string( - label: 'Label', - initialValue: 'Click me', - description: 'The text displayed on the button.', - ); + return const _PlaygroundDemo(); +} - final style = context.knobs.object.dropdown( - label: 'Style', - options: StreamButtonStyle.values, - initialOption: StreamButtonStyle.primary, - labelBuilder: (option) => option.name, - description: 'Button visual style variant.', - ); +class _PlaygroundDemo extends StatefulWidget { + const _PlaygroundDemo(); - final type = context.knobs.object.dropdown( - label: 'Type', - options: StreamButtonType.values, - initialOption: StreamButtonType.solid, - labelBuilder: (option) => option.name, - description: 'Button type variant.', - ); + @override + State<_PlaygroundDemo> createState() => _PlaygroundDemoState(); +} - final size = context.knobs.object.dropdown( - label: 'Size', - options: StreamButtonSize.values, - initialOption: StreamButtonSize.large, - labelBuilder: (option) => option.name, - description: 'Button size preset (affects padding and font size).', - ); +class _PlaygroundDemoState extends State<_PlaygroundDemo> { + var _isSelected = false; - final isDisabled = context.knobs.boolean( - label: 'Disabled', - description: 'Whether the button is disabled (non-interactive).', - ); + @override + Widget build(BuildContext context) { + final label = context.knobs.string( + label: 'Label', + initialValue: 'Click me', + description: 'The text displayed on the button.', + ); - final showLeadingIcon = context.knobs.boolean( - label: 'Leading Icon', - description: 'Show an icon before the label.', - ); + final style = context.knobs.object.dropdown( + label: 'Style', + options: StreamButtonStyle.values, + initialOption: StreamButtonStyle.primary, + labelBuilder: (option) => option.name, + description: 'Button visual style variant.', + ); - final showTrailingIcon = context.knobs.boolean( - label: 'Trailing Icon', - description: 'Show an icon after the label.', - ); + final type = context.knobs.object.dropdown( + label: 'Type', + options: StreamButtonType.values, + initialOption: StreamButtonType.solid, + labelBuilder: (option) => option.name, + description: 'Button type variant.', + ); - return Center( - child: Padding( - padding: const EdgeInsets.all(8), - child: StreamButton( - label: label, - style: style, - type: type, - size: size, - onTap: isDisabled - ? null - : () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Button tapped!'), - duration: Duration(seconds: 1), - ), - ); - }, - iconLeft: showLeadingIcon ? Icons.add : null, - iconRight: showTrailingIcon ? Icons.arrow_forward : null, - ), - ), - ); + final size = context.knobs.object.dropdown( + label: 'Size', + options: StreamButtonSize.values, + initialOption: StreamButtonSize.large, + labelBuilder: (option) => option.name, + description: 'Button size preset (affects padding and font size).', + ); + + final isIconOnly = context.knobs.boolean( + label: 'Icon Only', + description: 'Render as a circular icon-only button.', + ); + + final isDisabled = context.knobs.boolean( + label: 'Disabled', + description: 'Whether the button is disabled (non-interactive).', + ); + + final isFloating = + isIconOnly && + context.knobs.boolean( + label: 'Floating', + description: 'Whether the button has a floating (elevated) appearance.', + ); + + final isSelectable = context.knobs.boolean( + label: 'Selectable', + description: 'Whether the button toggles its selected state on tap.', + ); + + final showLeadingIcon = + !isIconOnly && + context.knobs.boolean( + label: 'Leading Icon', + description: 'Show an icon before the label.', + ); + + final showTrailingIcon = + !isIconOnly && + context.knobs.boolean( + label: 'Trailing Icon', + description: 'Show an icon after the label.', + ); + + void onTap() { + if (isSelectable) { + setState(() => _isSelected = !_isSelected); + } + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + isSelectable ? 'Button ${_isSelected ? 'selected' : 'deselected'}' : 'Button tapped!', + ), + duration: const Duration(seconds: 1), + ), + ); + } + + return Center( + child: isIconOnly + ? StreamButton.icon( + icon: Icons.add, + style: style, + type: type, + size: size, + isFloating: isFloating ? true : null, + isSelected: isSelectable ? _isSelected : null, + onTap: isDisabled ? null : onTap, + ) + : StreamButton( + label: label, + style: style, + type: type, + size: size, + isSelected: isSelectable ? _isSelected : null, + onTap: isDisabled ? null : onTap, + iconLeft: showLeadingIcon ? Icons.add : null, + iconRight: showTrailingIcon ? Icons.arrow_forward : null, + ), + ); + } } // ============================================================================= -// Type Variants +// Showcase // ============================================================================= @widgetbook.UseCase( - name: 'Type Variants', + name: 'Showcase', type: StreamButton, path: '[Components]/Buttons', ) -Widget buildStreamButtonTypes(BuildContext context) { - final theme = StreamTheme.of(context); - final colorScheme = theme.colorScheme; - final textTheme = theme.textTheme; - - return Center( - child: Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: colorScheme.backgroundSurface, - borderRadius: BorderRadius.circular(12), - ), +Widget buildStreamButtonShowcase(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( - mainAxisSize: MainAxisSize.min, - children: [ - for (final type in StreamButtonType.values) ...[ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 100, - child: Text( - type.name, - style: textTheme.captionDefault.copyWith( - color: colorScheme.textSecondary, - ), - ), - ), - StreamButton( - label: 'Button', - type: type, - onTap: () {}, - ), - ], - ), - if (type != StreamButtonType.values.last) const SizedBox(height: 16), - ], + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.xl, + children: const [ + _StyleTypeMatrixSection(), + _SizeScaleSection(), + _RealWorldSection(), ], ), ), @@ -136,298 +167,648 @@ Widget buildStreamButtonTypes(BuildContext context) { } // ============================================================================= -// Size Variants +// Style Γ— Type Matrix Section // ============================================================================= -@widgetbook.UseCase( - name: 'Size Variants', - type: StreamButton, - path: '[Components]/Buttons', -) -Widget buildStreamButtonSizes(BuildContext context) { - final theme = StreamTheme.of(context); - final colorScheme = theme.colorScheme; - final textTheme = theme.textTheme; - - return Center( - child: Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: colorScheme.backgroundSurface, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final size in StreamButtonSize.values) ...[ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 80, - child: Text( - size.name, - style: textTheme.captionDefault.copyWith( - color: colorScheme.textSecondary, - ), - ), - ), - StreamButton( - label: 'Button', - size: size, - onTap: () {}, - ), - ], - ), - if (size != StreamButtonSize.values.last) const SizedBox(height: 16), +class _StyleTypeMatrixSection extends StatelessWidget { + const _StyleTypeMatrixSection(); + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: [ + const _SectionLabel(label: 'STYLE Γ— TYPE'), + Column( + spacing: spacing.sm, + children: [ + for (final style in StreamButtonStyle.values) _StyleMatrixCard(style: style), ], - ], - ), - ), - ); + ), + ], + ); + } } -// ============================================================================= -// With Icons -// ============================================================================= +/// A card showing one style's full type matrix, mirroring the Figma layout: +/// +/// ```text +/// β”Œβ”€β”€ Label ──┐ β”Œβ”€β”€ Icon ──┐ +/// default off default off +/// solid [btn] [btn] [+] [+] +/// outline [btn] [btn] [+] [+] +/// ghost [btn] [btn] [+] [+] +/// ``` +class _StyleMatrixCard extends StatelessWidget { + const _StyleMatrixCard({required this.style}); -@widgetbook.UseCase( - name: 'With Icons', - type: StreamButton, - path: '[Components]/Buttons', -) -Widget buildStreamButtonWithIcons(BuildContext context) { - final theme = StreamTheme.of(context); - final colorScheme = theme.colorScheme; - final textTheme = theme.textTheme; - - final size = context.knobs.object.dropdown( - label: 'Size', - options: StreamButtonSize.values, - initialOption: StreamButtonSize.large, - labelBuilder: (option) => option.name, - description: 'Button size preset (affects padding and font size).', - ); + final StreamButtonStyle style; - return Center( - child: Container( - padding: const EdgeInsets.all(24), + static String _description(StreamButtonStyle style) { + return switch (style) { + StreamButtonStyle.primary => 'Brand/accent color scheme', + StreamButtonStyle.secondary => 'Neutral/surface color scheme', + StreamButtonStyle.destructive => 'Error/danger color scheme', + }; + } + + @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( + clipBehavior: Clip.antiAlias, + padding: EdgeInsets.all(spacing.md), decoration: BoxDecoration( - color: colorScheme.backgroundSurface, - borderRadius: BorderRadius.circular(12), + 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( - mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, children: [ + // Header Row( - mainAxisSize: MainAxisSize.min, + spacing: spacing.sm, children: [ - SizedBox( - width: 80, - child: Text( - 'Leading', - style: textTheme.captionDefault.copyWith( - color: colorScheme.textSecondary, - ), + Text( + style.name, + style: textTheme.captionEmphasis.copyWith( + color: colorScheme.textPrimary, + fontFamily: 'monospace', ), ), - StreamButton( - label: 'Add Item', - iconLeft: Icons.add, - size: size, - onTap: () {}, - ), - ], - ), - const SizedBox(height: 16), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 80, + Expanded( child: Text( - 'Trailing', + _description(style), style: textTheme.captionDefault.copyWith( - color: colorScheme.textSecondary, + color: colorScheme.textTertiary, ), ), ), - StreamButton( - label: 'Continue', - iconRight: Icons.arrow_forward, - size: size, - onTap: () {}, - ), ], ), - const SizedBox(height: 16), - Row( - mainAxisSize: MainAxisSize.min, + // Matrix: column headers + type rows + Column( + spacing: spacing.sm, children: [ - SizedBox( - width: 80, - child: Text( - 'Both', - style: textTheme.captionDefault.copyWith( - color: colorScheme.textSecondary, - ), - ), - ), - StreamButton( - label: 'Upload', - iconLeft: Icons.cloud_upload, - iconRight: Icons.arrow_forward, - size: size, - onTap: () {}, - ), + _MatrixHeaderRow(spacing: spacing, textTheme: textTheme, colorScheme: colorScheme), + for (final type in StreamButtonType.values) _MatrixTypeRow(style: style, type: type), ], ), - const SizedBox(height: 16), - Row( - mainAxisSize: MainAxisSize.min, + ], + ), + ); + } +} + +class _MatrixHeaderRow extends StatelessWidget { + const _MatrixHeaderRow({ + required this.spacing, + required this.textTheme, + required this.colorScheme, + }); + + final StreamSpacing spacing; + final StreamTextTheme textTheme; + final StreamColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + final headerStyle = textTheme.metadataDefault.copyWith( + color: colorScheme.textTertiary, + fontSize: 10, + ); + + return Row( + children: [ + const SizedBox(width: 56), + Expanded( + child: Center(child: Text('default', style: headerStyle)), + ), + Expanded( + child: Center(child: Text('disabled', style: headerStyle)), + ), + SizedBox(width: spacing.md), + Expanded( + child: Center(child: Text('default', style: headerStyle)), + ), + Expanded( + child: Center(child: Text('disabled', style: headerStyle)), + ), + ], + ); + } +} + +class _MatrixTypeRow extends StatelessWidget { + const _MatrixTypeRow({required this.style, required this.type}); + + final StreamButtonStyle style; + final StreamButtonType type; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Row( + children: [ + // Row label + SizedBox( + width: 56, + child: Text( + type.name, + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.textSecondary, + fontSize: 10, + ), + ), + ), + // Label button β€” default + Expanded( + child: Center( + child: StreamButton( + label: 'Label', + style: style, + type: type, + size: StreamButtonSize.small, + onTap: () {}, + ), + ), + ), + // Label button β€” disabled + Expanded( + child: Center( + child: StreamButton( + label: 'Label', + style: style, + type: type, + size: StreamButtonSize.small, + ), + ), + ), + SizedBox(width: spacing.md), + // Icon button β€” default + Expanded( + child: Center( + child: StreamButton.icon( + icon: Icons.add, + style: style, + type: type, + size: StreamButtonSize.small, + onTap: () {}, + ), + ), + ), + // Icon button β€” disabled + Expanded( + child: Center( + child: StreamButton.icon( + icon: Icons.add, + style: style, + type: type, + size: StreamButtonSize.small, + ), + ), + ), + ], + ); + } +} + +// ============================================================================= +// Size Scale Section +// ============================================================================= + +class _SizeScaleSection extends StatelessWidget { + const _SizeScaleSection(); + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final boxShadow = context.streamBoxShadow; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: [ + const _SectionLabel(label: 'SIZE SCALE'), + Container( + width: double.infinity, + clipBehavior: Clip.antiAlias, + padding: EdgeInsets.all(spacing.md), + 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( + spacing: spacing.lg, children: [ - SizedBox( - width: 80, - child: Text( - 'Icon only', - style: textTheme.captionDefault.copyWith( - color: colorScheme.textSecondary, - ), - ), + // Label buttons + Row( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + for (final size in StreamButtonSize.values) _SizeDemo(size: size, isIconOnly: false), + ], ), - StreamButton.icon( - icon: Icons.add, - size: size, - onTap: () {}, + Divider(color: colorScheme.borderSubtle), + // Icon-only buttons + Row( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + for (final size in StreamButtonSize.values) _SizeDemo(size: size, isIconOnly: true), + ], ), ], ), - ], - ), - ), - ); + ), + ], + ); + } +} + +class _SizeDemo extends StatelessWidget { + const _SizeDemo({required this.size, required this.isIconOnly}); + + final StreamButtonSize size; + final bool isIconOnly; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (isIconOnly) + StreamButton.icon(icon: Icons.add, size: size, onTap: () {}) + else + StreamButton(label: 'Button', size: size, onTap: () {}), + SizedBox(height: spacing.sm), + Text( + size.name, + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.accentPrimary, + fontFamily: 'monospace', + ), + ), + Text( + '${size.value.toInt()}px', + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textTertiary, + fontFamily: 'monospace', + fontSize: 10, + ), + ), + ], + ); + } } // ============================================================================= -// Real-world Example +// Real-World Section // ============================================================================= -@widgetbook.UseCase( - name: 'Real-world Example', - type: StreamButton, - path: '[Components]/Buttons', -) -Widget buildStreamButtonExample(BuildContext context) { - final theme = StreamTheme.of(context); - final colorScheme = theme.colorScheme; - final textTheme = theme.textTheme; - - return Center( - child: Container( - padding: const EdgeInsets.all(24), +class _RealWorldSection extends StatelessWidget { + const _RealWorldSection(); + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: [ + const _SectionLabel(label: 'REAL-WORLD EXAMPLES'), + Column( + spacing: spacing.sm, + children: const [ + _ExampleCard( + title: 'Delete Dialog', + description: 'Destructive confirmation with visual hierarchy', + child: _DeleteDialogExample(), + ), + _ExampleCard( + title: 'Chat Composer', + description: 'Icon actions alongside a text field', + child: _ChatComposerExample(), + ), + _ExampleCard( + title: 'Empty State', + description: 'CTA with leading icon on a blank screen', + child: _EmptyStateExample(), + ), + ], + ), + ], + ); + } +} + +class _DeleteDialogExample extends StatelessWidget { + const _DeleteDialogExample(); + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Icon badge + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colorScheme.accentError.withValues(alpha: 0.1), + shape: BoxShape.circle, + ), + child: Icon( + Icons.delete_outline, + color: colorScheme.accentError, + size: 24, + ), + ), + SizedBox(height: spacing.md), + Text( + 'Delete this conversation?', + style: textTheme.headingXs.copyWith( + color: colorScheme.textPrimary, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: spacing.xs), + Text( + 'This will permanently remove all messages and attachments. This action cannot be undone.', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: spacing.lg), + // Buttons β€” full width, stacked + SizedBox( + width: double.infinity, + child: StreamButton( + label: 'Delete Conversation', + style: StreamButtonStyle.destructive, + size: StreamButtonSize.large, + iconLeft: Icons.delete_outline, + onTap: () {}, + ), + ), + SizedBox(height: spacing.sm), + SizedBox( + width: double.infinity, + child: StreamButton( + label: 'Cancel', + style: StreamButtonStyle.secondary, + type: StreamButtonType.outline, + size: StreamButtonSize.large, + onTap: () {}, + ), + ), + ], + ); + } +} + +class _ChatComposerExample extends StatelessWidget { + const _ChatComposerExample(); + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + return Container( + padding: EdgeInsets.symmetric( + horizontal: spacing.sm, + vertical: spacing.xs, + ), decoration: BoxDecoration( - color: colorScheme.backgroundSurface, - borderRadius: BorderRadius.circular(12), + color: colorScheme.backgroundSurfaceSubtle, + borderRadius: BorderRadius.all(radius.lg), + border: Border.all(color: colorScheme.borderDefault), + ), + child: Row( + children: [ + StreamButton.icon( + icon: Icons.add_circle_outline, + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + size: StreamButtonSize.small, + onTap: () {}, + ), + SizedBox(width: spacing.xs), + Expanded( + child: Text( + 'Type a message…', + style: textTheme.bodyDefault.copyWith( + color: colorScheme.textTertiary, + ), + ), + ), + SizedBox(width: spacing.xs), + StreamButton.icon( + icon: Icons.emoji_emotions_outlined, + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + size: StreamButtonSize.small, + onTap: () {}, + ), + SizedBox(width: spacing.xxs), + StreamButton.icon( + icon: Icons.send, + size: StreamButtonSize.small, + onTap: () {}, + ), + ], ), + ); + } +} + +class _EmptyStateExample extends StatelessWidget { + const _EmptyStateExample(); + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Center( child: Column( mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, children: [ + Icon( + Icons.chat_bubble_outline, + size: 48, + color: colorScheme.textTertiary, + ), + SizedBox(height: spacing.md), Text( - 'Common Patterns', + 'No conversations yet', style: textTheme.headingSm.copyWith( color: colorScheme.textPrimary, ), ), - const SizedBox(height: 16), - // Dialog actions - Container( - padding: const EdgeInsets.all(16), - width: 280, - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - color: colorScheme.backgroundApp, - borderRadius: BorderRadius.circular(8), + SizedBox(height: spacing.xs), + Text( + 'Start a new conversation to begin chatting.', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, ), - foregroundDecoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all(color: colorScheme.borderSubtle), + textAlign: TextAlign.center, + ), + SizedBox(height: spacing.lg), + StreamButton( + label: 'New Conversation', + iconLeft: Icons.add, + onTap: () {}, + ), + ], + ), + ); + } +} + +// ============================================================================= +// Shared Widgets +// ============================================================================= + +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, + clipBehavior: Clip.antiAlias, + 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, - mainAxisSize: MainAxisSize.min, children: [ Text( - 'Delete conversation?', - style: textTheme.bodyEmphasis.copyWith( + title, + style: textTheme.captionEmphasis.copyWith( color: colorScheme.textPrimary, ), ), - const SizedBox(height: 4), Text( - 'This action cannot be undone.', - style: textTheme.captionDefault.copyWith( - color: colorScheme.textSecondary, + description, + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textTertiary, ), ), - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - StreamButton( - label: 'Cancel', - style: StreamButtonStyle.secondary, - size: StreamButtonSize.small, - onTap: () {}, - ), - const SizedBox(width: 8), - StreamButton( - label: 'Delete', - style: StreamButtonStyle.destructive, - size: StreamButtonSize.small, - onTap: () {}, - ), - ], - ), ], ), ), - const SizedBox(height: 12), - // Form submit + Divider(height: 1, color: colorScheme.borderSubtle), Container( - padding: const EdgeInsets.all(16), - width: 280, - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - color: colorScheme.backgroundApp, - borderRadius: BorderRadius.circular(8), - ), - foregroundDecoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all(color: colorScheme.borderSubtle), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Ready to send?', - style: textTheme.bodyEmphasis.copyWith( - color: colorScheme.textPrimary, - ), - ), - const SizedBox(height: 12), - StreamButton( - label: 'Send Message', - iconRight: Icons.send, - onTap: () {}, - ), - ], - ), + width: double.infinity, + padding: EdgeInsets.all(spacing.md), + color: colorScheme.backgroundSurfaceSubtle, + child: child, ), ], ), - ), - ); + ); + } +} + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + return Container( + padding: EdgeInsets.symmetric( + horizontal: spacing.sm, + vertical: spacing.xs, + ), + decoration: BoxDecoration( + color: colorScheme.accentPrimary, + borderRadius: BorderRadius.all(radius.xs), + ), + child: Text( + label, + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.textOnAccent, + letterSpacing: 1, + fontSize: 9, + ), + ), + ); + } } diff --git a/apps/design_system_gallery/lib/core/preview_wrapper.dart b/apps/design_system_gallery/lib/core/preview_wrapper.dart index 3cb69e0..71ab291 100644 --- a/apps/design_system_gallery/lib/core/preview_wrapper.dart +++ b/apps/design_system_gallery/lib/core/preview_wrapper.dart @@ -7,8 +7,15 @@ import '../config/preview_configuration.dart'; /// Wrapper widget that applies device frame and text scale to the preview. /// -/// Uses [ListenableBuilder] to explicitly react to [PreviewConfiguration] changes. -/// StreamTheme is already available via MaterialApp's theme extensions. +/// Uses a nested [Navigator] so that dialogs and bottom sheets open within +/// the preview container rather than covering the entire gallery app. +/// +/// The declarative [Navigator.pages] API is used instead of [onGenerateRoute] +/// so that theme changes propagate into the route content without recreating +/// the navigator (which would reset use-case state). +/// +/// A [GlobalObjectKey] is used on the [Navigator] so that toggling the device +/// frame on/off reparents the navigator without losing state. class PreviewWrapper extends StatelessWidget { const PreviewWrapper({super.key, required this.child}); @@ -23,20 +30,24 @@ class PreviewWrapper extends StatelessWidget { final radius = context.streamRadius; final spacing = context.streamSpacing; - final content = Directionality( - textDirection: previewConfig.textDirection, - child: MediaQuery( + final content = Builder( + builder: (context) => MediaQuery( data: MediaQuery.of(context).copyWith( - textScaler: TextScaler.linear(previewConfig.textScale), + textScaler: .linear(previewConfig.textScale), ), - child: ColoredBox( - color: colorScheme.backgroundApp, - child: ScaffoldMessenger( - child: Navigator( - onGenerateRoute: (_) => MaterialPageRoute( - builder: (_) => Scaffold(body: child), + child: Directionality( + textDirection: previewConfig.textDirection, + child: Navigator( + key: const GlobalObjectKey('preview-navigator'), + pages: [ + MaterialPage( + child: ScaffoldMessenger( + child: Scaffold(body: child), + ), ), - ), + ], + // no-op as the preview page should never be popped + onDidRemovePage: (_) {}, ), ), ), @@ -60,7 +71,6 @@ class PreviewWrapper extends StatelessWidget { margin: EdgeInsets.all(spacing.lg), clipBehavior: Clip.antiAlias, decoration: BoxDecoration( - color: colorScheme.backgroundApp, borderRadius: BorderRadius.all(radius.xl), boxShadow: boxShadow.elevation3, ), From f0dca263531677c8c44b680837eed8d86b27c5ab Mon Sep 17 00:00:00 2001 From: xsahil03x <25670178+xsahil03x@users.noreply.github.com> Date: Fri, 13 Feb 2026 09:18:51 +0000 Subject: [PATCH 16/17] chore: Update Goldens --- .../goldens/ci/stream_button_dark_matrix.png | Bin 30371 -> 30371 bytes .../goldens/ci/stream_button_disabled.png | Bin 8205 -> 7655 bytes .../goldens/ci/stream_button_icon_only.png | Bin 8564 -> 8564 bytes .../goldens/ci/stream_button_light_matrix.png | Bin 27699 -> 27699 bytes .../goldens/ci/stream_button_with_icons.png | Bin 3414 -> 3414 bytes ...er_attachment_link_preview_dark_matrix.png | Bin 6481 -> 6481 bytes ...r_attachment_link_preview_light_matrix.png | Bin 5636 -> 5636 bytes ..._composer_attachment_reply_dark_matrix.png | Bin 3841 -> 3841 bytes ...composer_attachment_reply_light_matrix.png | Bin 3593 -> 3593 bytes 9 files changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_dark_matrix.png b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_dark_matrix.png index 38a4e8f8c64e73392674dc38f4daafe039b93379..7faaf0da46ab228c62c828c344e12d731a422a54 100644 GIT binary patch delta 39 tcmZ4dmT~c0#tAA?EX7WqAsj$Z!;#X#z`(#*9OUlAuMe delta 39 tcmZ4dmT~c0#tAA?jKx9jP7LeL$-HD>U|=bB@(kesf*OvLj*WJyWdIFm3~>Me diff --git a/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_disabled.png b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_disabled.png index 373758cbb863aad671754f01299dd33f40e12835..45cb603399d52353d6065e78cc613ffeefd22488 100644 GIT binary patch literal 7655 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 YcIRhvU1RQIxDTSMrGG8wirv%y0`5>nApigX diff --git a/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_icon_only.png b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_icon_only.png index 6b6879122c3b6d66e6bba9d54d58a80cfa0d07d2..a60027eddb8603bf83d9eadf363491a64e6fb0bb 100644 GIT binary patch delta 37 rcmez3^u=j{iWEz+lV=DA5Y%v_bTBY5Fct^7J29*~C-ZWnotYv4*kKDd delta 37 rcmez3^u=j{iWFmUkh>GZx^prw85kH?ik&<|IDnvrBc)@botYv4@3RXx diff --git a/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_light_matrix.png b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_light_matrix.png index 8175168fde951a39ba8827aefa70d6769fd4ed4a..6195b78f53a51ab8de79aadbe5178d1c5a60f471 100644 GIT binary patch delta 39 tcmdmdgK_f>#tAA?EX7WqAsj$Z!;#X#z`(#*9OUlAu#tAA?jKx9jP7LeL$-HD>U|=bB@(kesf*OvLj*WI-vjGh-4D0{^ diff --git a/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_with_icons.png b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_with_icons.png index e84a9ed26730bc6ffb3bada1b1be45a3c447b213..6cbbd5ec5fc8fccf42d4948d639da16225875095 100644 GIT binary patch delta 37 rcmca6bxmr5iWEz+lV=DA5Y%v_bTBY5Fct^7J29*~C-ZWnodhoc#IXu_ delta 37 rcmca6bxmr5iWFmUkh>GZx^prw85kH?ik&<|IDnvrBc)@bodhoc+ye@E diff --git a/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_link_preview_dark_matrix.png b/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_link_preview_dark_matrix.png index ef4b8ef132985fa6e36b849423efa3abfb1880dd..8acc8dd3bf6f4c9086560db2f38c4b798a7ec434 100644 GIT binary patch delta 37 rcmca;bkS&niWEz+lV=DA5Y%v_bTBY5Fct^7J29*~C-ZWnovGZx^prw85kH?ik&<|IDnvrBc)@bovsU diff --git a/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_reply_dark_matrix.png b/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_reply_dark_matrix.png index c79e667ca66c2cdb1b5e339a8fa43b460d564b60..5b4892759ea7a2fb6372271f221449fd2b9f85a6 100644 GIT binary patch delta 37 rcmZpaYm}RyBE?edW delta 37 rcmZpaYm}RyBE?u76DqEBE?ed6DqEBE?u7 Date: Fri, 13 Feb 2026 14:52:49 +0530 Subject: [PATCH 17/17] Trigger Build