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..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 @@ -10,10 +10,24 @@ // ************************************************************************** // 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' as _design_system_gallery_components_message_composer_message_composer; import 'package:design_system_gallery/components/message_composer/message_composer_attachment_link_preview.dart' @@ -22,16 +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/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' @@ -158,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: [ @@ -185,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, ), ], @@ -200,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, ), ], ), @@ -215,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, ), ], ), @@ -235,70 +262,69 @@ 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_badge_stream_badge_count + .buildStreamBadgeCountShowcase, + ), + ], + ), + _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_stream_badge_count - .buildStreamBadgeCountShowcase, + builder: + _design_system_gallery_components_badge_stream_online_indicator + .buildStreamOnlineIndicatorShowcase, ), ], ), ], ), _widgetbook.WidgetbookFolder( - name: 'Button', + name: 'Buttons', children: [ _widgetbook.WidgetbookComponent( name: 'StreamButton', 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 - .buildStreamButtonExample, - ), - _widgetbook.WidgetbookUseCase( - name: 'Size Variants', - builder: _design_system_gallery_components_button - .buildStreamButtonSizes, - ), - _widgetbook.WidgetbookUseCase( - name: 'Type Variants', - builder: _design_system_gallery_components_button - .buildStreamButtonTypes, - ), - _widgetbook.WidgetbookUseCase( - name: 'With Icons', - builder: _design_system_gallery_components_button - .buildStreamButtonWithIcons, + name: 'Showcase', + builder: _design_system_gallery_components_buttons_button + .buildStreamButtonShowcase, ), ], ), - ], - ), - _widgetbook.WidgetbookFolder( - name: 'Indicator', - children: [ _widgetbook.WidgetbookComponent( - name: 'StreamOnlineIndicator', + name: 'StreamEmojiButton', useCases: [ _widgetbook.WidgetbookUseCase( name: 'Playground', builder: - _design_system_gallery_components_stream_online_indicator - .buildStreamOnlineIndicatorPlayground, + _design_system_gallery_components_buttons_stream_emoji_button + .buildStreamEmojiButtonPlayground, ), _widgetbook.WidgetbookUseCase( name: 'Showcase', builder: - _design_system_gallery_components_stream_online_indicator - .buildStreamOnlineIndicatorShowcase, + _design_system_gallery_components_buttons_stream_emoji_button + .buildStreamEmojiButtonShowcase, ), ], ), @@ -359,6 +385,22 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookFolder( + name: 'Reaction', + children: [ + _widgetbook.WidgetbookComponent( + name: 'StreamReactionPickerSheet', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _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..806717e --- /dev/null +++ b/apps/design_system_gallery/lib/components/accessories/stream_emoji.dart @@ -0,0 +1,386 @@ +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), + Wrap( + spacing: spacing.xl, + runSpacing: spacing.xl, + children: [ + for (final size in StreamEmojiSize.values) _SizeDemo(size: size), + ], + ), + ], + ), + ), + ], + ); + } +} + +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 99% 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 index 7135606..1dee61b 100644 --- a/apps/design_system_gallery/lib/components/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/button.dart b/apps/design_system_gallery/lib/components/button.dart deleted file mode 100644 index dbdda8c..0000000 --- a/apps/design_system_gallery/lib/components/button.dart +++ /dev/null @@ -1,433 +0,0 @@ -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: StreamButton, - path: '[Components]/Button', -) -Widget buildStreamButtonPlayground(BuildContext context) { - final label = context.knobs.string( - label: 'Label', - initialValue: 'Click me', - description: 'The text displayed on the button.', - ); - - final style = context.knobs.object.dropdown( - label: 'Style', - options: StreamButtonStyle.values, - initialOption: StreamButtonStyle.primary, - labelBuilder: (option) => option.name, - description: 'Button visual style variant.', - ); - - final type = context.knobs.object.dropdown( - label: 'Type', - options: StreamButtonType.values, - initialOption: StreamButtonType.solid, - labelBuilder: (option) => option.name, - description: 'Button type variant.', - ); - - 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 isDisabled = context.knobs.boolean( - label: 'Disabled', - description: 'Whether the button is disabled (non-interactive).', - ); - - final showLeadingIcon = context.knobs.boolean( - label: 'Leading Icon', - description: 'Show an icon before the label.', - ); - - final showTrailingIcon = context.knobs.boolean( - label: 'Trailing Icon', - description: 'Show an icon after the label.', - ); - - 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, - ), - ), - ); -} - -// ============================================================================= -// Type Variants -// ============================================================================= - -@widgetbook.UseCase( - name: 'Type Variants', - type: StreamButton, - path: '[Components]/Button', -) -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), - ), - 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), - ], - ], - ), - ), - ); -} - -// ============================================================================= -// Size Variants -// ============================================================================= - -@widgetbook.UseCase( - name: 'Size Variants', - type: StreamButton, - path: '[Components]/Button', -) -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), - ], - ], - ), - ), - ); -} - -// ============================================================================= -// With Icons -// ============================================================================= - -@widgetbook.UseCase( - name: 'With Icons', - type: StreamButton, - path: '[Components]/Button', -) -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).', - ); - - return Center( - child: Container( - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - color: colorScheme.backgroundSurface, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 80, - child: Text( - 'Leading', - style: textTheme.captionDefault.copyWith( - color: colorScheme.textSecondary, - ), - ), - ), - StreamButton( - label: 'Add Item', - iconLeft: Icons.add, - size: size, - onTap: () {}, - ), - ], - ), - const SizedBox(height: 16), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 80, - child: Text( - 'Trailing', - style: textTheme.captionDefault.copyWith( - color: colorScheme.textSecondary, - ), - ), - ), - StreamButton( - label: 'Continue', - iconRight: Icons.arrow_forward, - size: size, - onTap: () {}, - ), - ], - ), - const SizedBox(height: 16), - Row( - mainAxisSize: MainAxisSize.min, - 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: () {}, - ), - ], - ), - const SizedBox(height: 16), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 80, - child: Text( - 'Icon only', - style: textTheme.captionDefault.copyWith( - color: colorScheme.textSecondary, - ), - ), - ), - StreamButton.icon( - icon: Icons.add, - size: size, - onTap: () {}, - ), - ], - ), - ], - ), - ), - ); -} - -// ============================================================================= -// Real-world Example -// ============================================================================= - -@widgetbook.UseCase( - name: 'Real-world Example', - type: StreamButton, - path: '[Components]/Button', -) -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), - decoration: BoxDecoration( - color: colorScheme.backgroundSurface, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Common Patterns', - 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), - ), - foregroundDecoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: Border.all(color: colorScheme.borderSubtle), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Delete conversation?', - style: textTheme.bodyEmphasis.copyWith( - color: colorScheme.textPrimary, - ), - ), - const SizedBox(height: 4), - Text( - 'This action cannot be undone.', - style: textTheme.captionDefault.copyWith( - color: colorScheme.textSecondary, - ), - ), - 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 - 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: () {}, - ), - ], - ), - ), - ], - ), - ), - ); -} diff --git a/apps/design_system_gallery/lib/components/buttons/button.dart b/apps/design_system_gallery/lib/components/buttons/button.dart new file mode 100644 index 0000000..2e46a09 --- /dev/null +++ b/apps/design_system_gallery/lib/components/buttons/button.dart @@ -0,0 +1,814 @@ +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: StreamButton, + path: '[Components]/Buttons', +) +Widget buildStreamButtonPlayground(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 label = context.knobs.string( + label: 'Label', + initialValue: 'Click me', + description: 'The text displayed on the button.', + ); + + final style = context.knobs.object.dropdown( + label: 'Style', + options: StreamButtonStyle.values, + initialOption: StreamButtonStyle.primary, + labelBuilder: (option) => option.name, + description: 'Button visual style variant.', + ); + + final type = context.knobs.object.dropdown( + label: 'Type', + options: StreamButtonType.values, + initialOption: StreamButtonType.solid, + labelBuilder: (option) => option.name, + description: 'Button type variant.', + ); + + 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, + ), + ); + } +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamButton, + path: '[Components]/Buttons', +) +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( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.xl, + children: const [ + _StyleTypeMatrixSection(), + _SizeScaleSection(), + _RealWorldSection(), + ], + ), + ), + ); +} + +// ============================================================================= +// Style Γ— Type Matrix Section +// ============================================================================= + +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), + ], + ), + ], + ); + } +} + +/// 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}); + + final StreamButtonStyle style; + + 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.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, + spacing: spacing.md, + children: [ + // Header + Row( + spacing: spacing.sm, + children: [ + Text( + style.name, + style: textTheme.captionEmphasis.copyWith( + color: colorScheme.textPrimary, + fontFamily: 'monospace', + ), + ), + Expanded( + child: Text( + _description(style), + style: textTheme.captionDefault.copyWith( + color: colorScheme.textTertiary, + ), + ), + ), + ], + ), + // Matrix: column headers + type rows + Column( + spacing: spacing.sm, + children: [ + _MatrixHeaderRow(spacing: spacing, textTheme: textTheme, colorScheme: colorScheme), + for (final type in StreamButtonType.values) _MatrixTypeRow(style: style, type: type), + ], + ), + ], + ), + ); + } +} + +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: [ + // Label buttons + Row( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + for (final size in StreamButtonSize.values) _SizeDemo(size: size, isIconOnly: false), + ], + ), + 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 Section +// ============================================================================= + +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.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, + children: [ + Icon( + Icons.chat_bubble_outline, + size: 48, + color: colorScheme.textTertiary, + ), + SizedBox(height: spacing.md), + Text( + 'No conversations yet', + style: textTheme.headingSm.copyWith( + color: colorScheme.textPrimary, + ), + ), + SizedBox(height: spacing.xs), + Text( + 'Start a new conversation to begin chatting.', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + 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, + children: [ + Text( + title, + style: textTheme.captionEmphasis.copyWith( + color: colorScheme.textPrimary, + ), + ), + Text( + description, + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textTertiary, + ), + ), + ], + ), + ), + Divider(height: 1, color: colorScheme.borderSubtle), + Container( + 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/components/buttons/stream_emoji_button.dart b/apps/design_system_gallery/lib/components/buttons/stream_emoji_button.dart new file mode 100644 index 0000000..b6f92be --- /dev/null +++ b/apps/design_system_gallery/lib/components/buttons/stream_emoji_button.dart @@ -0,0 +1,550 @@ +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]/Buttons', +) +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]/Buttons', +) +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..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,15 +30,25 @@ 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: 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: (_) {}, + ), ), ), ); @@ -54,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, ), 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/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 39c0eb1..93883d5 100644 --- a/apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart +++ b/apps/design_system_gallery/lib/widgets/toolbar/toolbar.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:stream_core_flutter/stream_core_flutter.dart'; @@ -6,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. /// @@ -54,6 +57,7 @@ class GalleryToolbar extends StatelessWidget { child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( + spacing: spacing.sm, mainAxisSize: MainAxisSize.min, children: [ // Device frame toggle @@ -63,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( @@ -81,7 +82,6 @@ class GalleryToolbar extends StatelessWidget { options: PreviewConfiguration.textScaleOptions, onChanged: previewConfig.setTextScale, ), - SizedBox(width: spacing.sm), // Text direction selector (LTR/RTL) TextDirectionSelector( @@ -89,6 +89,12 @@ class GalleryToolbar extends StatelessWidget { options: PreviewConfiguration.textDirectionOptions, onChanged: previewConfig.setTextDirection, ), + + // Debug tools (debug mode only) + if (kDebugMode) ...[ + const DebugPaintToggle(), + const WidgetSelectToggle(), + ], ], ), ), @@ -117,7 +123,7 @@ class GalleryToolbar extends StatelessWidget { } } -/// Stream branding logo and title. +// Stream branding logo and title. class _StreamBranding extends StatelessWidget { const _StreamBranding(); 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, + ); + }, + ); + } +} 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/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..46cb141 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' 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; @@ -5,6 +6,8 @@ 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 'components/reaction/picker/stream_reaction_picker_sheet.dart'; export 'factory/stream_component_factory.dart'; 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..f9d24e7 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/accessories/stream_emoji.dart @@ -0,0 +1,203 @@ +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, +/// 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 (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. + StreamEmoji({ + super.key, + 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. + /// + /// 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: effectiveSize, + height: effectiveSize, + child: Center( + child: MediaQuery.withNoTextScaling( + child: FittedBox( + fit: .scaleDown, + child: DefaultTextStyle.merge( + textAlign: .center, + style: TextStyle( + height: 1, + decoration: .none, + fontSize: effectiveSize, + // 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: props.emoji, + ), + ), + ), + ), + ); + } +} 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/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..00776d4 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/buttons/stream_emoji_button.dart @@ -0,0 +1,236 @@ +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, + iconSize: emojiSize.value, + icon: StreamEmoji(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.md, + .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/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..7d7f144 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/reaction/picker/stream_reaction_picker_sheet.dart @@ -0,0 +1,512 @@ +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, + useSafeArea: true, + 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.symmetric( + vertical: spacing.xs, + horizontal: spacing.md, + ), + child: Row( + spacing: spacing.xxxs, + children: [ + for (final category in widget.categories) + StreamButton.icon( + key: _tabKeys[category], + icon: category.icon(context), + style: StreamButtonStyle.secondary, + type: StreamButtonType.ghost, + size: StreamButtonSize.large, + isSelected: category == widget.activeCategory, + onTap: () => widget.onCategorySelected(category), + ), + ], + ), + ), + ); + } +} + +// 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/lib/src/factory/stream_component_factory.dart b/packages/stream_core_flutter/lib/src/factory/stream_component_factory.dart index f254c4f..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,8 @@ class StreamComponentBuilders with _$StreamComponentBuilders { this.avatarStack, this.badgeCount, this.button, + this.emoji, + this.emojiButton, this.fileTypeIcon, this.onlineIndicator, }); @@ -184,6 +186,16 @@ 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]. + 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..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,8 @@ 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, ); @@ -46,6 +48,8 @@ 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, }) { @@ -57,6 +61,8 @@ 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, ); @@ -79,6 +85,8 @@ mixin _$StreamComponentBuilders { avatarStack: other.avatarStack, badgeCount: other.badgeCount, button: other.button, + emoji: other.emoji, + emojiButton: other.emojiButton, fileTypeIcon: other.fileTypeIcon, onlineIndicator: other.onlineIndicator, ); @@ -102,6 +110,8 @@ 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; } @@ -117,6 +127,8 @@ mixin _$StreamComponentBuilders { _this.avatarStack, _this.badgeCount, _this.button, + _this.emoji, + _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_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; } 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..4c90ede --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_button_theme.dart @@ -0,0 +1,188 @@ +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. +@themeGen +@immutable +class StreamEmojiButtonThemeStyle with _$StreamEmojiButtonThemeStyle { + /// Creates emoji button style properties. + const StreamEmojiButtonThemeStyle({ + this.size, + this.backgroundColor, + this.foregroundColor, + this.overlayColor, + WidgetStateProperty? side, + }) // TODO: Fix this or try to find something better + : side = side as WidgetStateBorderSide?; + + /// 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; + + /// 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 new file mode 100644 index 0000000..aaa1978 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_emoji_button_theme.g.theme.dart @@ -0,0 +1,196 @@ +// 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: StreamEmojiButtonThemeStyle.lerp(a.style, b.style, t), + ); + } + + 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: _this.style?.merge(other.style) ?? other.style); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as 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); + } +} + +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, + ); + } +} 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, 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); 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 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 38a4e8f..7faaf0d 100644 Binary files a/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_dark_matrix.png and b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_dark_matrix.png differ 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 373758c..45cb603 100644 Binary files a/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_disabled.png and b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_disabled.png differ 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 6b68791..a60027e 100644 Binary files a/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_icon_only.png and b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_icon_only.png differ 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 8175168..6195b78 100644 Binary files a/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_light_matrix.png and b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_light_matrix.png differ 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 e84a9ed..6cbbd5e 100644 Binary files a/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_with_icons.png and b/packages/stream_core_flutter/test/components/buttons/goldens/ci/stream_button_with_icons.png differ 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 ef4b8ef..8acc8dd 100644 Binary files a/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_link_preview_dark_matrix.png and b/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_link_preview_dark_matrix.png differ diff --git a/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_link_preview_light_matrix.png b/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_link_preview_light_matrix.png index 077d85c..4d0c3c0 100644 Binary files a/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_link_preview_light_matrix.png and b/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_link_preview_light_matrix.png differ 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 c79e667..5b48927 100644 Binary files a/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_reply_dark_matrix.png and b/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_reply_dark_matrix.png differ diff --git a/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_reply_light_matrix.png b/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_reply_light_matrix.png index 744a120..b0b3563 100644 Binary files a/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_reply_light_matrix.png and b/packages/stream_core_flutter/test/components/message_composer/goldens/ci/message_composer_attachment_reply_light_matrix.png differ