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 8d683e8..d6494aa 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 @@ -34,6 +34,8 @@ import 'package:design_system_gallery/components/buttons/stream_emoji_button.dar as _design_system_gallery_components_buttons_stream_emoji_button; import 'package:design_system_gallery/components/common/stream_checkbox.dart' as _design_system_gallery_components_common_stream_checkbox; +import 'package:design_system_gallery/components/common/stream_flex.dart' + as _design_system_gallery_components_common_stream_flex; import 'package:design_system_gallery/components/common/stream_progress_bar.dart' as _design_system_gallery_components_common_stream_progress_bar; import 'package:design_system_gallery/components/context_menu/stream_context_menu.dart' @@ -52,6 +54,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/reaction/stream_reactions.dart' + as _design_system_gallery_components_reaction_stream_reaction; import 'package:design_system_gallery/components/tiles/stream_list_tile.dart' as _design_system_gallery_components_tiles_stream_list_tile; import 'package:design_system_gallery/primitives/colors.dart' @@ -400,6 +404,21 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookComponent( + name: 'StreamFlex', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: _design_system_gallery_components_common_stream_flex + .buildStreamFlexPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: _design_system_gallery_components_common_stream_flex + .buildStreamFlexShowcase, + ), + ], + ), _widgetbook.WidgetbookComponent( name: 'StreamProgressBar', useCases: [ @@ -551,6 +570,28 @@ final directories = <_widgetbook.WidgetbookNode>[ ), ], ), + _widgetbook.WidgetbookFolder( + name: 'Reactions', + children: [ + _widgetbook.WidgetbookComponent( + name: 'StreamReactions', + useCases: [ + _widgetbook.WidgetbookUseCase( + name: 'Playground', + builder: + _design_system_gallery_components_reaction_stream_reaction + .buildStreamReactionsPlayground, + ), + _widgetbook.WidgetbookUseCase( + name: 'Showcase', + builder: + _design_system_gallery_components_reaction_stream_reaction + .buildStreamReactionsShowcase, + ), + ], + ), + ], + ), _widgetbook.WidgetbookFolder( name: 'Tiles', children: [ diff --git a/apps/design_system_gallery/lib/components/common/stream_flex.dart b/apps/design_system_gallery/lib/components/common/stream_flex.dart new file mode 100644 index 0000000..5ddf82f --- /dev/null +++ b/apps/design_system_gallery/lib/components/common/stream_flex.dart @@ -0,0 +1,685 @@ +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: StreamFlex, + path: '[Components]/Common', +) +Widget buildStreamFlexPlayground(BuildContext context) { + final direction = context.knobs.object.dropdown( + label: 'Direction', + options: Axis.values, + labelBuilder: (value) => value.name, + initialOption: Axis.horizontal, + description: 'The main axis direction.', + ); + + final spacing = context.knobs.double.slider( + label: 'Spacing', + initialValue: -8, + min: -24, + max: 32, + description: 'Space between children. Negative values cause overlap.', + ); + + final mainAxisAlignment = context.knobs.object.dropdown( + label: 'Main Axis Alignment', + options: MainAxisAlignment.values, + labelBuilder: (value) => value.name, + initialOption: MainAxisAlignment.start, + description: 'How children are placed along the main axis.', + ); + + final crossAxisAlignment = context.knobs.object.dropdown( + label: 'Cross Axis Alignment', + options: [ + CrossAxisAlignment.start, + CrossAxisAlignment.center, + CrossAxisAlignment.end, + CrossAxisAlignment.stretch, + ], + labelBuilder: (value) => value.name, + initialOption: CrossAxisAlignment.center, + description: 'How children are placed along the cross axis.', + ); + + final mainAxisSize = context.knobs.object.dropdown( + label: 'Main Axis Size', + options: MainAxisSize.values, + labelBuilder: (value) => value.name, + initialOption: MainAxisSize.min, + description: 'Whether to maximize or minimize free space.', + ); + + final clipBehavior = context.knobs.object.dropdown( + label: 'Clip Behavior', + options: Clip.values, + labelBuilder: (value) => value.name, + initialOption: Clip.none, + description: 'How to clip overflowing children.', + ); + + final childCount = context.knobs.int.slider( + label: 'Child Count', + initialValue: 5, + min: 1, + max: 8, + description: 'Number of children.', + ); + + return Center( + child: StreamFlex( + direction: direction, + spacing: spacing, + mainAxisAlignment: mainAxisAlignment, + crossAxisAlignment: crossAxisAlignment, + mainAxisSize: mainAxisSize, + clipBehavior: clipBehavior, + children: [ + for (var i = 0; i < childCount; i++) _PlaygroundChild(index: i, direction: direction), + ], + ), + ); +} + +class _PlaygroundChild extends StatelessWidget { + const _PlaygroundChild({required this.index, required this.direction}); + + final int index; + final Axis direction; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final radius = context.streamRadius; + + final palette = _childPalette[index % _childPalette.length]; + final size = 40.0 + (index % 3) * 12.0; + + return Container( + width: direction == Axis.horizontal ? size : null, + height: direction == Axis.vertical ? size : null, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: palette.bg, + borderRadius: BorderRadius.all(radius.md), + border: Border.all(color: colorScheme.backgroundSurface, width: 2), + ), + alignment: Alignment.center, + child: Text( + '${index + 1}', + style: textTheme.captionEmphasis.copyWith(color: palette.fg), + ), + ); + } +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamFlex, + path: '[Components]/Common', +) +Widget buildStreamFlexShowcase(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 [ + _SpacingValuesSection(), + _NegativeSpacingSection(), + _RealWorldSection(), + ], + ), + ), + ); +} + +// ============================================================================= +// Spacing Values Section +// ============================================================================= + +class _SpacingValuesSection extends StatelessWidget { + const _SpacingValuesSection(); + + @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, + spacing: spacing.md, + children: [ + const _SectionLabel(label: 'SPACING VALUES'), + 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( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: [ + Text( + 'Positive spacing adds gaps, zero makes children flush, ' + 'and negative spacing causes overlap.', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + for (final (label, value) in _spacingValues) _SpacingDemo(label: label, spacing: value), + ], + ), + ), + ], + ); + } +} + +class _SpacingDemo extends StatelessWidget { + const _SpacingDemo({required this.label, required this.spacing}); + + final String label; + final double spacing; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final themeSpacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + spacing: themeSpacing.xs, + children: [ + Text( + label, + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.accentPrimary, + fontFamily: 'monospace', + ), + ), + StreamRow( + spacing: spacing, + children: [ + for (var i = 0; i < 5; i++) _DemoChip(index: i), + ], + ), + ], + ); + } +} + +// ============================================================================= +// Negative Spacing Section +// ============================================================================= + +class _NegativeSpacingSection extends StatelessWidget { + const _NegativeSpacingSection(); + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: const [ + _SectionLabel(label: 'NEGATIVE SPACING'), + _ExampleCard( + title: 'Z-Order (Paint Order)', + description: + 'Later children paint on top of earlier ones. ' + 'Child 1 is behind, child 5 is in front.', + child: _ZOrderDemo(), + ), + _ExampleCard( + title: 'Vertical Overlap', + description: 'Negative spacing works on the vertical axis too.', + child: _VerticalOverlapDemo(), + ), + ], + ); + } +} + +class _ZOrderDemo extends StatelessWidget { + const _ZOrderDemo(); + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final radius = context.streamRadius; + + return StreamRow( + spacing: -16, + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < 5; i++) + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: _childPalette[i].bg, + borderRadius: BorderRadius.all(radius.lg), + border: Border.all( + color: colorScheme.backgroundSurface, + width: 2, + ), + ), + alignment: Alignment.center, + child: Text( + '${i + 1}', + style: textTheme.captionEmphasis.copyWith( + color: _childPalette[i].fg, + ), + ), + ), + ], + ); + } +} + +class _VerticalOverlapDemo extends StatelessWidget { + const _VerticalOverlapDemo(); + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + final boxShadow = context.streamBoxShadow; + + return StreamColumn( + spacing: -12, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < 4; i++) + Container( + width: 200, + padding: EdgeInsets.symmetric( + horizontal: spacing.md, + vertical: spacing.sm, + ), + decoration: BoxDecoration( + color: _childPalette[i].bg, + borderRadius: BorderRadius.all(radius.lg), + border: Border.all( + color: colorScheme.backgroundSurface, + width: 2, + ), + boxShadow: boxShadow.elevation1, + ), + child: Text( + 'Card ${i + 1}', + style: textTheme.captionEmphasis.copyWith( + color: _childPalette[i].fg, + ), + ), + ), + ], + ); + } +} + +// ============================================================================= +// 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'), + _ExampleCard( + title: 'Avatar Stack', + description: + 'Overlapping circular avatars โ€” a common pattern for ' + 'showing group participants.', + child: _AvatarStackDemo(), + ), + _ExampleCard( + title: 'Notification Badges', + description: + 'Overlapping badges that fan out, useful for showing ' + 'multiple notification types.', + child: _NotificationBadgesDemo(), + ), + ], + ); + } +} + +class _AvatarStackDemo extends StatelessWidget { + const _AvatarStackDemo(); + + static const _initials = ['AL', 'BK', 'CM', 'DP', 'EW']; + + @override + Widget build(BuildContext context) { + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.lg, + children: [ + for (final overlap in [-8.0, -12.0, -16.0]) + _VariantDemo( + label: 'spacing: ${overlap.toInt()}', + child: StreamRow( + spacing: overlap, + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < _initials.length; i++) + StreamAvatar( + size: StreamAvatarSize.md, + backgroundColor: _childPalette[i].bg, + foregroundColor: _childPalette[i].fg, + placeholder: (_) => Text(_initials[i]), + ), + ], + ), + ), + ], + ); + } +} + +class _NotificationBadgesDemo extends StatelessWidget { + const _NotificationBadgesDemo(); + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final radius = context.streamRadius; + + final badges = [ + ( + StreamColors.red.shade400.withValues(alpha: 0.85), + StreamColors.red.shade900, + '3', + ), + ( + StreamColors.yellow.shade400.withValues(alpha: 0.85), + StreamColors.yellow.shade900, + '!', + ), + ( + StreamColors.blue.shade400.withValues(alpha: 0.85), + StreamColors.blue.shade900, + '7', + ), + ]; + + return StreamRow( + spacing: -6, + mainAxisSize: MainAxisSize.min, + children: [ + for (final (bg, fg, label) in badges) + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.all(radius.max), + border: Border.all( + color: colorScheme.backgroundSurface, + width: 2, + ), + ), + alignment: Alignment.center, + child: Text( + label, + style: textTheme.metadataEmphasis.copyWith( + color: fg, + fontSize: 11, + ), + ), + ), + ], + ); + } +} + +// ============================================================================= +// Shared Widgets +// ============================================================================= + +class _DemoChip extends StatelessWidget { + const _DemoChip({required this.index}); + + final int index; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final radius = context.streamRadius; + + final palette = _childPalette[index % _childPalette.length]; + + return Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: palette.bg, + borderRadius: BorderRadius.all(radius.md), + border: Border.all(color: colorScheme.backgroundSurface, width: 2), + ), + alignment: Alignment.center, + child: Text( + '${index + 1}', + style: textTheme.captionEmphasis.copyWith(color: palette.fg), + ), + ); + } +} + +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.symmetric( + horizontal: spacing.md, + vertical: 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, + ), + ), + ); + } +} + +class _VariantDemo extends StatelessWidget { + const _VariantDemo({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + spacing: spacing.xs, + children: [ + Text( + label, + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.accentPrimary, + fontFamily: 'monospace', + ), + ), + child, + ], + ); + } +} + +// ============================================================================= +// Helpers +// ============================================================================= + +const _spacingValues = [ + ('spacing: 12', 12.0), + ('spacing: 0', 0.0), + ('spacing: -8', -8.0), + ('spacing: -16', -16.0), +]; + +final _childPalette = [ + ( + bg: StreamColors.blue.shade400.withValues(alpha: 0.8), + fg: StreamColors.blue.shade900, + ), + ( + bg: StreamColors.cyan.shade400.withValues(alpha: 0.8), + fg: StreamColors.cyan.shade900, + ), + ( + bg: StreamColors.green.shade400.withValues(alpha: 0.8), + fg: StreamColors.green.shade900, + ), + ( + bg: StreamColors.purple.shade400.withValues(alpha: 0.8), + fg: StreamColors.purple.shade900, + ), + ( + bg: StreamColors.yellow.shade400.withValues(alpha: 0.8), + fg: StreamColors.yellow.shade900, + ), +]; diff --git a/apps/design_system_gallery/lib/components/controls/stream_emoji_chip.dart b/apps/design_system_gallery/lib/components/controls/stream_emoji_chip.dart index dfeaf75..78b3d70 100644 --- a/apps/design_system_gallery/lib/components/controls/stream_emoji_chip.dart +++ b/apps/design_system_gallery/lib/components/controls/stream_emoji_chip.dart @@ -29,33 +29,62 @@ class _PlaygroundDemoState extends State<_PlaygroundDemo> { @override Widget build(BuildContext context) { - final isAddEmojiType = context.knobs.boolean( - label: 'Add Emoji Type', - description: 'Switches to the add-reaction icon variant (no emoji or count).', + final chipType = context.knobs.object.dropdown( + label: 'Chip Type', + options: _ChipType.values, + initialOption: _ChipType.single, + labelBuilder: (t) => t.label, + description: 'Which chip variant to display.', ); - final emoji = isAddEmojiType - ? null - : context.knobs.object.dropdown( + final isSingle = chipType == _ChipType.single; + final isCluster = chipType == _ChipType.cluster; + final isOverflow = chipType == _ChipType.overflow; + final isAddEmoji = chipType == _ChipType.addEmoji; + + final emoji = isSingle + ? context.knobs.object.dropdown( label: 'Emoji', options: _sampleEmojis, initialOption: _sampleEmojis.first, labelBuilder: (e) => '${e.emoji} ${e.name}', description: 'The emoji to display.', - ); + ) + : null; + + final clusterSize = isCluster + ? context.knobs.int.slider( + label: 'Cluster Size', + initialValue: 3, + min: 1, + max: _sampleEmojis.length, + description: 'Number of emoji icons shown inside the cluster chip.', + ) + : null; + + final overflowCount = isOverflow + ? context.knobs.int.slider( + label: 'Overflow Count', + initialValue: 7, + min: 1, + max: 99, + description: 'The overflow count displayed as +N.', + ) + : null; final showCount = - !isAddEmojiType && + !isAddEmoji && + !isOverflow && context.knobs.boolean( label: 'Show Count', initialValue: true, description: 'Whether to show the reaction count label.', ); - final count = (!isAddEmojiType && showCount) + final count = (!isAddEmoji && !isOverflow && showCount) ? context.knobs.int.slider( label: 'Count', - initialValue: 1, + initialValue: isCluster ? 12 : 1, min: 1, max: 99, description: 'The reaction count to display.', @@ -96,18 +125,32 @@ class _PlaygroundDemoState extends State<_PlaygroundDemo> { } return Center( - child: isAddEmojiType - ? StreamEmojiChip.addEmoji( - onPressed: isDisabled ? null : onPressed, - onLongPress: (showLongPress && !isDisabled) ? onLongPressed : null, - ) - : StreamEmojiChip( - emoji: Text(emoji!.emoji), - count: count, - isSelected: _isSelected, - onPressed: isDisabled ? null : onPressed, - onLongPress: (showLongPress && !isDisabled) ? onLongPressed : null, - ), + child: switch (chipType) { + _ChipType.addEmoji => StreamEmojiChip.addEmoji( + onPressed: isDisabled ? null : onPressed, + onLongPress: (showLongPress && !isDisabled) ? onLongPressed : null, + ), + _ChipType.overflow => StreamEmojiChip.overflow( + count: overflowCount!, + onPressed: isDisabled ? null : onPressed, + ), + _ChipType.cluster => StreamEmojiChip.cluster( + emojis: [ + for (final e in _sampleEmojis.take(clusterSize!)) Text(e.emoji), + ], + count: count, + isSelected: _isSelected, + onPressed: isDisabled ? null : onPressed, + onLongPress: (showLongPress && !isDisabled) ? onLongPressed : null, + ), + _ChipType.single => StreamEmojiChip( + emoji: Text(emoji!.emoji), + count: count, + isSelected: _isSelected, + onPressed: isDisabled ? null : onPressed, + onLongPress: (showLongPress && !isDisabled) ? onLongPressed : null, + ), + }, ); } } @@ -135,6 +178,8 @@ Widget buildStreamEmojiChipShowcase(BuildContext context) { spacing: spacing.xl, children: const [ _TypeVariantsSection(), + _ClusterVariantsSection(), + _OverflowVariantsSection(), _CountValuesSection(), _StateMatrixSection(), _RealWorldSection(), @@ -182,7 +227,8 @@ class _TypeVariantsSection extends StatelessWidget { spacing: spacing.md, children: [ Text( - 'Standard chip (with count, without count, selected) and Add Emoji chip.', + 'Single-emoji chip (with count, without count, selected), ' + 'cluster chip, overflow chip, and Add Emoji chip.', style: textTheme.captionDefault.copyWith(color: colorScheme.textSecondary), ), Wrap( @@ -213,6 +259,21 @@ class _TypeVariantsSection extends StatelessWidget { onPressed: () {}, ), ), + _TypeDemo( + label: 'Cluster', + child: StreamEmojiChip.cluster( + emojis: const [Text('๐Ÿ‘'), Text('โค๏ธ'), Text('๐Ÿ˜‚')], + count: 12, + onPressed: () {}, + ), + ), + _TypeDemo( + label: 'Overflow', + child: StreamEmojiChip.overflow( + count: 7, + onPressed: () {}, + ), + ), _TypeDemo( label: 'Add Emoji', child: StreamEmojiChip.addEmoji(onPressed: () {}), @@ -256,6 +317,180 @@ class _TypeDemo extends StatelessWidget { } } +// ============================================================================= +// Cluster Variants Section +// ============================================================================= + +class _ClusterVariantsSection extends StatelessWidget { + const _ClusterVariantsSection(); + + @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, + spacing: spacing.md, + children: [ + const _SectionLabel(label: 'CLUSTER VARIANTS'), + 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( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: [ + Text( + 'Cluster chips render 1โ€“4 emoji icons side-by-side, each ' + 'individually sized. The total reaction count is shown after the icons.', + style: textTheme.captionDefault.copyWith(color: colorScheme.textSecondary), + ), + Wrap( + spacing: spacing.md, + runSpacing: spacing.md, + children: [ + _TypeDemo( + label: '1 emoji', + child: StreamEmojiChip.cluster( + emojis: const [Text('๐Ÿ‘')], + count: 5, + onPressed: () {}, + ), + ), + _TypeDemo( + label: '2 emojis', + child: StreamEmojiChip.cluster( + emojis: const [Text('๐Ÿ‘'), Text('โค๏ธ')], + count: 9, + onPressed: () {}, + ), + ), + _TypeDemo( + label: '3 emojis', + child: StreamEmojiChip.cluster( + emojis: const [Text('๐Ÿ‘'), Text('โค๏ธ'), Text('๐Ÿ˜‚')], + count: 17, + onPressed: () {}, + ), + ), + _TypeDemo( + label: '4 emojis', + child: StreamEmojiChip.cluster( + emojis: const [Text('๐Ÿ‘'), Text('โค๏ธ'), Text('๐Ÿ˜‚'), Text('๐Ÿ”ฅ')], + count: 42, + onPressed: () {}, + ), + ), + _TypeDemo( + label: 'no count', + child: StreamEmojiChip.cluster( + emojis: const [Text('๐Ÿ‘'), Text('โค๏ธ'), Text('๐Ÿ˜‚')], + onPressed: () {}, + ), + ), + _TypeDemo( + label: 'selected', + child: StreamEmojiChip.cluster( + emojis: const [Text('๐Ÿ‘'), Text('โค๏ธ'), Text('๐Ÿ˜‚')], + count: 12, + isSelected: true, + onPressed: () {}, + ), + ), + ], + ), + ], + ), + ), + ], + ); + } +} + +// ============================================================================= +// Overflow Variants Section +// ============================================================================= + +class _OverflowVariantsSection extends StatelessWidget { + const _OverflowVariantsSection(); + + @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, + spacing: spacing.md, + children: [ + const _SectionLabel(label: 'OVERFLOW VARIANTS'), + 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( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: [ + Text( + 'Overflow chips display a +N label for collapsed items. ' + 'The count uses the numeric text style rather than emoji sizing.', + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + Wrap( + spacing: spacing.md, + runSpacing: spacing.md, + children: [ + for (final count in [1, 3, 7, 15, 42, 99]) + _TypeDemo( + label: '+$count', + child: StreamEmojiChip.overflow( + count: count, + onPressed: () {}, + ), + ), + _TypeDemo( + label: 'disabled', + child: StreamEmojiChip.overflow(count: 5), + ), + ], + ), + ], + ), + ), + ], + ); + } +} + // ============================================================================= // Count Values Section // ============================================================================= @@ -364,72 +599,112 @@ class _StateMatrixSection extends StatelessWidget { children: [ Text( 'Hover and press states are interactive โ€” try them. ' - 'Selected state applies only to the standard chip.', + 'Selected state applies only to single and cluster chips. ' + 'Overflow chips do not support selection.', style: textTheme.captionDefault.copyWith(color: colorScheme.textSecondary), ), - // Header - Row( - children: [ - const SizedBox(width: 88), - Expanded( - child: Center( - child: Text( - 'Standard', - style: textTheme.metadataEmphasis.copyWith( - color: colorScheme.textTertiary, - fontSize: 10, + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 520), + child: Column( + spacing: spacing.md, + children: [ + _StateRow( + stateLabel: '', + standardChip: Text( + 'Single', + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.textTertiary, + fontSize: 10, + ), + ), + clusterChip: Text( + 'Cluster', + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.textTertiary, + fontSize: 10, + ), + ), + overflowChip: Text( + 'Overflow', + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.textTertiary, + fontSize: 10, + ), + ), + addEmojiChip: Text( + 'Add Emoji', + style: textTheme.metadataEmphasis.copyWith( + color: colorScheme.textTertiary, + fontSize: 10, + ), ), ), - ), - ), - Expanded( - child: Center( - child: Text( - 'Add Emoji', - style: textTheme.metadataEmphasis.copyWith( - color: colorScheme.textTertiary, - fontSize: 10, + _StateRow( + stateLabel: 'default', + standardChip: StreamEmojiChip( + emoji: const Text('๐Ÿ‘'), + count: 3, + onPressed: () {}, + ), + clusterChip: StreamEmojiChip.cluster( + emojis: const [Text('๐Ÿ‘'), Text('โค๏ธ'), Text('๐Ÿ˜‚')], + count: 12, + onPressed: () {}, + ), + overflowChip: StreamEmojiChip.overflow( + count: 7, + onPressed: () {}, ), + addEmojiChip: StreamEmojiChip.addEmoji(onPressed: () {}), ), - ), + _StateRow( + stateLabel: 'selected', + standardChip: StreamEmojiChip( + emoji: const Text('๐Ÿ‘'), + count: 3, + isSelected: true, + onPressed: () {}, + ), + clusterChip: StreamEmojiChip.cluster( + emojis: const [Text('๐Ÿ‘'), Text('โค๏ธ'), Text('๐Ÿ˜‚')], + count: 12, + isSelected: true, + onPressed: () {}, + ), + addEmojiChip: null, + ), + _StateRow( + stateLabel: 'disabled', + standardChip: StreamEmojiChip( + emoji: const Text('๐Ÿ‘'), + count: 3, + ), + clusterChip: StreamEmojiChip.cluster( + emojis: const [Text('๐Ÿ‘'), Text('โค๏ธ'), Text('๐Ÿ˜‚')], + count: 12, + ), + overflowChip: StreamEmojiChip.overflow(count: 7), + addEmojiChip: StreamEmojiChip.addEmoji(), + ), + _StateRow( + stateLabel: 'selected\n+ disabled', + standardChip: StreamEmojiChip( + emoji: const Text('๐Ÿ‘'), + count: 3, + isSelected: true, + ), + clusterChip: StreamEmojiChip.cluster( + emojis: const [Text('๐Ÿ‘'), Text('โค๏ธ'), Text('๐Ÿ˜‚')], + count: 12, + isSelected: true, + ), + addEmojiChip: null, + ), + ], ), - ], - ), - _StateRow( - stateLabel: 'default', - standardChip: StreamEmojiChip( - emoji: const Text('๐Ÿ‘'), - count: 3, - onPressed: () {}, - ), - addEmojiChip: StreamEmojiChip.addEmoji(onPressed: () {}), - ), - _StateRow( - stateLabel: 'selected', - standardChip: StreamEmojiChip( - emoji: const Text('๐Ÿ‘'), - count: 3, - isSelected: true, - onPressed: () {}, - ), - addEmojiChip: null, // selection not applicable for addEmoji - ), - _StateRow( - stateLabel: 'disabled', - standardChip: StreamEmojiChip( - emoji: const Text('๐Ÿ‘'), - count: 3, - ), - addEmojiChip: StreamEmojiChip.addEmoji(), - ), - _StateRow( - stateLabel: 'selected\n+ disabled', - standardChip: StreamEmojiChip( - emoji: const Text('๐Ÿ‘'), - count: 3, - isSelected: true, ), - addEmojiChip: null, ), ], ), @@ -443,18 +718,37 @@ class _StateRow extends StatelessWidget { const _StateRow({ required this.stateLabel, required this.standardChip, + required this.clusterChip, + this.overflowChip, required this.addEmojiChip, }); final String stateLabel; final Widget standardChip; + final Widget? clusterChip; + final Widget? overflowChip; final Widget? addEmojiChip; + static const _cellWidth = 108.0; + @override Widget build(BuildContext context) { final colorScheme = context.streamColorScheme; final textTheme = context.streamTextTheme; + Widget naText() => Text( + 'n/a', + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textDisabled, + fontSize: 10, + ), + ); + + Widget cell(Widget? child) => SizedBox( + width: _cellWidth, + child: Center(child: child ?? naText()), + ); + return Row( children: [ SizedBox( @@ -467,20 +761,10 @@ class _StateRow extends StatelessWidget { ), ), ), - Expanded(child: Center(child: standardChip)), - Expanded( - child: Center( - child: - addEmojiChip ?? - Text( - 'n/a', - style: textTheme.metadataDefault.copyWith( - color: colorScheme.textDisabled, - fontSize: 10, - ), - ), - ), - ), + cell(standardChip), + cell(clusterChip), + cell(overflowChip), + cell(addEmojiChip), ], ); } @@ -509,6 +793,13 @@ class _RealWorldSection extends StatelessWidget { 'long-press would open a skin-tone picker.', child: _MessageReactionsExample(), ), + _ExampleCard( + title: 'Clustered Reactions', + description: + 'All reactions grouped into a single cluster chip โ€” tap the chip ' + 'to see the total count; commonly used in compact message threads.', + child: _ClusteredReactionsExample(), + ), _ExampleCard( title: 'Busy Reaction Bar', description: @@ -610,6 +901,71 @@ class _MessageReactionsExampleState extends State<_MessageReactionsExample> { } } +class _ClusteredReactionsExample extends StatefulWidget { + const _ClusteredReactionsExample(); + + @override + State<_ClusteredReactionsExample> createState() => _ClusteredReactionsExampleState(); +} + +class _ClusteredReactionsExampleState extends State<_ClusteredReactionsExample> { + static const _allReactions = [ + ('๐Ÿ‘', 4), + ('โค๏ธ', 3), + ('๐Ÿ˜‚', 2), + ('๐Ÿ”ฅ', 1), + ]; + + var _isSelected = false; + + int get _totalCount => _allReactions.fold(0, (sum, r) => sum + r.$2); + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + spacing: spacing.xs, + children: [ + Container( + constraints: const BoxConstraints(maxWidth: 280), + padding: EdgeInsets.symmetric(horizontal: spacing.md, vertical: spacing.sm), + decoration: BoxDecoration( + color: colorScheme.backgroundSurface, + borderRadius: BorderRadius.all(radius.lg), + border: Border.all(color: colorScheme.borderSubtle), + ), + child: Text( + 'Looks great! ๐ŸŽ‰ Really happy with how this turned out.', + style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), + ), + ), + StreamEmojiChip.cluster( + emojis: [for (final (emoji, _) in _allReactions) Text(emoji)], + count: _totalCount, + isSelected: _isSelected, + onPressed: () { + setState(() => _isSelected = !_isSelected); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + const SnackBar( + content: Text('Cluster tapped โ€” show reaction details'), + duration: Duration(seconds: 1), + ), + ); + }, + ), + ], + ); + } +} + class _BusyReactionsExample extends StatelessWidget { const _BusyReactionsExample(); @@ -745,6 +1101,21 @@ class _SectionLabel extends StatelessWidget { // Helpers // ============================================================================= +enum _ChipType { + single, + cluster, + overflow, + addEmoji + ; + + String get label => switch (this) { + _ChipType.single => 'Single', + _ChipType.cluster => 'Cluster', + _ChipType.overflow => 'Overflow', + _ChipType.addEmoji => 'Add Emoji', + }; +} + Emoji _byName(String name) => UnicodeEmojis.allEmojis.firstWhere((e) => e.name == name); final _sampleEmojis = [ diff --git a/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart b/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart new file mode 100644 index 0000000..b7d906b --- /dev/null +++ b/apps/design_system_gallery/lib/components/reaction/stream_reactions.dart @@ -0,0 +1,663 @@ +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: StreamReactions, + path: '[Components]/Reactions', +) +Widget buildStreamReactionsPlayground(BuildContext context) { + final type = context.knobs.object.dropdown( + label: 'Type', + options: StreamReactionsType.values, + initialOption: StreamReactionsType.segmented, + labelBuilder: (option) => option.name, + description: 'Segmented shows individual pills; clustered groups into one.', + ); + final position = context.knobs.object.dropdown( + label: 'Position', + options: StreamReactionsPosition.values, + initialOption: StreamReactionsPosition.header, + labelBuilder: (option) => option.name, + description: 'Where reactions sit relative to the bubble.', + ); + final alignment = context.knobs.object.dropdown( + label: 'Alignment', + options: StreamReactionsAlignment.values, + initialOption: StreamReactionsAlignment.end, + labelBuilder: (option) => option.name, + description: 'Horizontal alignment of reactions relative to the bubble.', + ); + final overlap = context.knobs.boolean( + label: 'Overlap', + initialValue: true, + description: 'Reactions overlap the bubble edge with negative spacing.', + ); + final indent = context.knobs.double.slider( + label: 'Indent', + initialValue: 8, + min: -8, + max: 8, + divisions: 8, + description: 'Horizontal shift applied to the reaction strip.', + ); + final max = overlap + ? context.knobs.int.slider( + label: 'Max Visible', + initialValue: 4, + min: 1, + max: 6, + divisions: 5, + description: 'Reactions beyond this collapse into a +N chip.', + ) + : null; + final direction = context.knobs.object.dropdown( + label: 'Direction', + options: _MessageDirection.values, + initialOption: _MessageDirection.incoming, + labelBuilder: (option) => option.name, + description: 'Incoming (start-aligned bubble) or outgoing (end-aligned).', + ); + final reactionCount = context.knobs.int.slider( + label: 'Reaction Count', + initialValue: 5, + min: 1, + max: _allReactionItems.length, + description: 'Number of distinct reaction types to display.', + ); + + final items = _allReactionItems.take(reactionCount).toList(); + final spacing = context.streamSpacing; + final isOutgoing = direction.isOutgoing; + final crossAxis = isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start; + + Widget buildReaction({required Widget bubble}) => switch (type) { + StreamReactionsType.segmented => StreamReactions.segmented( + items: items, + position: position, + alignment: alignment, + crossAxisAlignment: crossAxis, + max: max, + overlap: overlap, + indent: indent, + onPressed: () => _showSnack(context, 'Reaction tapped'), + child: bubble, + ), + StreamReactionsType.clustered => StreamReactions.clustered( + items: items, + position: position, + alignment: alignment, + crossAxisAlignment: crossAxis, + max: max, + overlap: overlap, + indent: indent, + onPressed: () => _showSnack(context, 'Reaction tapped'), + child: bubble, + ), + }; + + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Padding( + padding: EdgeInsets.all(spacing.lg), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: .stretch, + spacing: spacing.xl, + children: [ + _ChatBubble( + message: _mediumMessage, + direction: direction, + reactionBuilder: buildReaction, + ), + _ChatBubble( + message: _shortMessage, + direction: direction, + reactionBuilder: buildReaction, + ), + _ChatBubble( + message: _longMessage, + direction: direction, + reactionBuilder: buildReaction, + ), + ], + ), + ), + ), + ); +} + +/// A realistic chat bubble used in playground and showcase. +class _ChatBubble extends StatelessWidget { + const _ChatBubble({ + required this.message, + required this.direction, + required this.reactionBuilder, + }); + + final String message; + final _MessageDirection direction; + final Widget Function({required Widget bubble}) reactionBuilder; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final spacing = context.streamSpacing; + + final isOutgoing = direction.isOutgoing; + final bubbleColor = isOutgoing ? colorScheme.brand.shade100 : colorScheme.backgroundSurface; + + const bubbleRadius = Radius.circular(20); + final bubbleBorderRadius = isOutgoing + ? const BorderRadius.only( + topLeft: bubbleRadius, + topRight: bubbleRadius, + bottomLeft: bubbleRadius, + ) + : const BorderRadius.only( + topLeft: bubbleRadius, + topRight: bubbleRadius, + bottomRight: bubbleRadius, + ); + + final bubble = Container( + constraints: const BoxConstraints(maxWidth: 280), + padding: EdgeInsets.symmetric( + horizontal: spacing.sm, + vertical: spacing.xs, + ), + decoration: BoxDecoration( + color: bubbleColor, + borderRadius: bubbleBorderRadius, + ), + child: Text( + message, + style: textTheme.bodyDefault.copyWith(color: colorScheme.textPrimary), + ), + ); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: isOutgoing ? CrossAxisAlignment.end : CrossAxisAlignment.start, + children: [ + reactionBuilder(bubble: bubble), + SizedBox(height: spacing.xxs), + Padding( + padding: EdgeInsets.symmetric(horizontal: spacing.xxs), + child: Text( + isOutgoing ? '09:41 ยท Read' : '09:40', + style: textTheme.metadataDefault.copyWith( + color: colorScheme.textTertiary, + ), + ), + ), + ], + ); + } +} + +// ============================================================================= +// Showcase +// ============================================================================= + +@widgetbook.UseCase( + name: 'Showcase', + type: StreamReactions, + path: '[Components]/Reactions', +) +Widget buildStreamReactionsShowcase(BuildContext context) { + final spacing = context.streamSpacing; + + return SingleChildScrollView( + padding: EdgeInsets.all(spacing.lg), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.xl, + children: const [ + _ShowcaseSection( + title: 'SEGMENTED โ€” FOOTER', + description: + 'Individual pills per reaction, positioned as a footer ' + 'with overlap. Varying reaction counts across messages.', + threads: [ + _ThreadMessage( + message: _mediumMessage, + direction: _MessageDirection.incoming, + type: StreamReactionsType.segmented, + position: StreamReactionsPosition.footer, + items: _allReactionItems, + max: 4, + ), + _ThreadMessage( + message: _shortMessage, + direction: _MessageDirection.outgoing, + type: StreamReactionsType.segmented, + position: StreamReactionsPosition.footer, + items: [ + StreamReactionsItem(emoji: Text('๐Ÿ‘'), count: 2), + StreamReactionsItem(emoji: Text('โค'), count: 1), + ], + ), + _ThreadMessage( + message: _longMessage, + direction: _MessageDirection.incoming, + type: StreamReactionsType.segmented, + position: StreamReactionsPosition.footer, + items: [ + StreamReactionsItem(emoji: Text('๐Ÿ˜‚'), count: 5), + StreamReactionsItem(emoji: Text('๐Ÿ”ฅ'), count: 3), + StreamReactionsItem(emoji: Text('๐Ÿ‘'), count: 7), + StreamReactionsItem(emoji: Text('๐ŸŽ‰'), count: 2), + ], + ), + ], + ), + _ShowcaseSection( + title: 'SEGMENTED โ€” HEADER', + description: + 'Individual pills as a header. Reactions paint on top ' + 'of the child via z-ordering.', + threads: [ + _ThreadMessage( + message: _shortMessage, + direction: _MessageDirection.incoming, + type: StreamReactionsType.segmented, + position: StreamReactionsPosition.header, + items: [ + StreamReactionsItem(emoji: Text('๐Ÿ‘'), count: 3), + StreamReactionsItem(emoji: Text('โค'), count: 8), + StreamReactionsItem(emoji: Text('๐Ÿ˜‚'), count: 2), + ], + ), + _ThreadMessage( + message: _longMessage, + direction: _MessageDirection.outgoing, + type: StreamReactionsType.segmented, + position: StreamReactionsPosition.header, + items: _allReactionItems, + max: 5, + ), + ], + ), + _ShowcaseSection( + title: 'CLUSTERED', + description: + 'All reactions grouped into a single chip. Shown in both ' + 'header and footer positions.', + threads: [ + _ThreadMessage( + message: _longMessage, + direction: _MessageDirection.incoming, + type: StreamReactionsType.clustered, + position: StreamReactionsPosition.footer, + items: _allReactionItems, + ), + _ThreadMessage( + message: _shortMessage, + direction: _MessageDirection.outgoing, + type: StreamReactionsType.clustered, + position: StreamReactionsPosition.header, + items: [ + StreamReactionsItem(emoji: Text('๐Ÿ‘'), count: 4), + StreamReactionsItem(emoji: Text('โค'), count: 3), + StreamReactionsItem(emoji: Text('๐Ÿ˜‚'), count: 2), + ], + ), + _ThreadMessage( + message: _mediumMessage, + direction: _MessageDirection.incoming, + type: StreamReactionsType.clustered, + position: StreamReactionsPosition.footer, + items: [ + StreamReactionsItem(emoji: Text('๐Ÿ”ฅ'), count: 6), + StreamReactionsItem(emoji: Text('๐Ÿ™'), count: 1), + ], + ), + ], + ), + _ShowcaseSection( + title: 'OVERFLOW', + description: + 'When reactions exceed the max visible limit, extras are ' + 'collapsed into a +N overflow chip.', + threads: [ + _ThreadMessage( + message: _mediumMessage, + direction: _MessageDirection.incoming, + type: StreamReactionsType.segmented, + position: StreamReactionsPosition.footer, + items: _allReactionItems, + max: 3, + ), + _ThreadMessage( + message: _longMessage, + direction: _MessageDirection.outgoing, + type: StreamReactionsType.segmented, + position: StreamReactionsPosition.footer, + items: _allReactionItems, + max: 2, + ), + ], + ), + _ShowcaseSection( + title: 'COUNT RULES', + description: + 'If any reaction has count > 1, all chips show counts. ' + 'When all have count 1, no counts are displayed.', + threads: [ + _ThreadMessage( + message: 'Single emoji, count 1 โ€” no count shown.', + direction: _MessageDirection.incoming, + type: StreamReactionsType.segmented, + position: StreamReactionsPosition.footer, + items: [StreamReactionsItem(emoji: Text('๐Ÿ‘'))], + ), + _ThreadMessage( + message: 'Single emoji, count 5 โ€” count shown.', + direction: _MessageDirection.outgoing, + type: StreamReactionsType.segmented, + position: StreamReactionsPosition.footer, + items: [StreamReactionsItem(emoji: Text('โค'), count: 5)], + ), + _ThreadMessage( + message: 'Multiple emojis, all count 1 โ€” no counts.', + direction: _MessageDirection.incoming, + type: StreamReactionsType.segmented, + position: StreamReactionsPosition.footer, + items: [ + StreamReactionsItem(emoji: Text('๐Ÿ‘')), + StreamReactionsItem(emoji: Text('โค')), + StreamReactionsItem(emoji: Text('๐Ÿ˜‚')), + ], + ), + _ThreadMessage( + message: 'Mixed counts โ€” all show counts.', + direction: _MessageDirection.outgoing, + type: StreamReactionsType.segmented, + position: StreamReactionsPosition.footer, + items: [ + StreamReactionsItem(emoji: Text('๐Ÿ‘'), count: 8), + StreamReactionsItem(emoji: Text('โค')), + StreamReactionsItem(emoji: Text('๐Ÿ˜‚'), count: 3), + StreamReactionsItem(emoji: Text('๐Ÿ”ฅ')), + ], + ), + _ThreadMessage( + message: 'Clustered โ€” total count shown when > 1.', + direction: _MessageDirection.incoming, + type: StreamReactionsType.clustered, + position: StreamReactionsPosition.footer, + items: [ + StreamReactionsItem(emoji: Text('๐Ÿ‘')), + StreamReactionsItem(emoji: Text('โค')), + StreamReactionsItem(emoji: Text('๐Ÿ˜‚')), + ], + ), + ], + ), + _ShowcaseSection( + title: 'DETACHED', + description: + 'Reactions with overlap disabled โ€” separated from the bubble ' + 'by a fixed gap. Both segmented and clustered.', + threads: [ + _ThreadMessage( + message: _mediumMessage, + direction: _MessageDirection.incoming, + type: StreamReactionsType.segmented, + position: StreamReactionsPosition.footer, + items: [ + StreamReactionsItem(emoji: Text('๐Ÿ˜‚'), count: 5), + StreamReactionsItem(emoji: Text('๐Ÿ‘'), count: 2), + StreamReactionsItem(emoji: Text('โค'), count: 8), + ], + overlap: false, + ), + _ThreadMessage( + message: _shortMessage, + direction: _MessageDirection.outgoing, + type: StreamReactionsType.clustered, + position: StreamReactionsPosition.footer, + items: [ + StreamReactionsItem(emoji: Text('๐Ÿ”ฅ'), count: 3), + StreamReactionsItem(emoji: Text('๐ŸŽ‰'), count: 1), + ], + overlap: false, + ), + _ThreadMessage( + message: _longMessage, + direction: _MessageDirection.incoming, + type: StreamReactionsType.segmented, + position: StreamReactionsPosition.footer, + items: _allReactionItems, + overlap: false, + ), + ], + ), + ], + ), + ), + ), + ); +} + +// ============================================================================= +// Showcase helpers +// ============================================================================= + +@immutable +class _ThreadMessage { + const _ThreadMessage({ + required this.message, + required this.direction, + required this.type, + required this.position, + required this.items, + this.max, + this.overlap = true, + }); + + final String message; + final _MessageDirection direction; + final StreamReactionsType type; + final StreamReactionsPosition position; + final List items; + final int? max; + final bool overlap; +} + +class _ShowcaseSection extends StatelessWidget { + const _ShowcaseSection({ + required this.title, + required this.description, + required this.threads, + }); + + final String title; + final String description; + final List<_ThreadMessage> threads; + + @override + Widget build(BuildContext context) { + final colorScheme = context.streamColorScheme; + final textTheme = context.streamTextTheme; + final radius = context.streamRadius; + final spacing = context.streamSpacing; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: spacing.md, + children: [ + _SectionLabel(label: title), + Container( + width: double.infinity, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: colorScheme.backgroundApp, + borderRadius: BorderRadius.all(radius.lg), + ), + 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.xs, + ), + child: Text( + description, + style: textTheme.captionDefault.copyWith( + color: colorScheme.textSecondary, + ), + ), + ), + Divider(height: 1, color: colorScheme.borderSubtle), + Padding( + padding: EdgeInsets.all(spacing.md), + child: Column( + spacing: spacing.lg, + crossAxisAlignment: .stretch, + children: [ + for (final t in threads) + _ChatBubble( + message: t.message, + direction: t.direction, + reactionBuilder: ({required bubble}) { + final isOut = t.direction.isOutgoing; + final reactionAlignment = t.overlap + ? (isOut ? StreamReactionsAlignment.start : StreamReactionsAlignment.end) + : (isOut ? StreamReactionsAlignment.end : StreamReactionsAlignment.start); + final crossAxis = isOut ? CrossAxisAlignment.end : CrossAxisAlignment.start; + + return switch (t.type) { + StreamReactionsType.segmented => StreamReactions.segmented( + items: t.items, + position: t.position, + alignment: reactionAlignment, + crossAxisAlignment: crossAxis, + max: t.max, + overlap: t.overlap, + child: bubble, + onPressed: () {}, + ), + StreamReactionsType.clustered => StreamReactions.clustered( + items: t.items, + position: t.position, + alignment: reactionAlignment, + crossAxisAlignment: crossAxis, + max: t.max, + overlap: t.overlap, + child: bubble, + onPressed: () {}, + ), + }; + }, + ), + ], + ), + ), + ], + ), + ), + ], + ); + } +} + +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, + ), + ), + ); + } +} + +void _showSnack(BuildContext context, String message) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(message), + duration: const Duration(seconds: 1), + ), + ); +} + +// ============================================================================= +// Enums +// ============================================================================= + +enum _MessageDirection { + incoming, + outgoing + ; + + bool get isOutgoing => this == outgoing; +} + +// ============================================================================= +// Sample data +// ============================================================================= + +const _allReactionItems = [ + StreamReactionsItem(emoji: Text('๐Ÿ‘'), count: 8), + StreamReactionsItem(emoji: Text('โค'), count: 14), + StreamReactionsItem(emoji: Text('๐Ÿ˜‚'), count: 5), + StreamReactionsItem(emoji: Text('๐Ÿ”ฅ'), count: 3), + StreamReactionsItem(emoji: Text('๐ŸŽ‰'), count: 2), + StreamReactionsItem(emoji: Text('๐Ÿ‘'), count: 7), + StreamReactionsItem(emoji: Text('๐Ÿ˜ฎ')), + StreamReactionsItem(emoji: Text('๐Ÿ™'), count: 4), + StreamReactionsItem(emoji: Text('๐Ÿš€'), count: 6), + StreamReactionsItem(emoji: Text('๐Ÿ˜ข'), count: 2), +]; + +const _shortMessage = 'Sure ๐Ÿ‘'; + +const _mediumMessage = 'Hey, did you check the venue options?'; + +const _longMessage = + 'Hey, did you get a chance to look at the venue options for Saturday? ' + 'I found a couple of great places downtown that might work.'; diff --git a/packages/stream_core_flutter/lib/src/components.dart b/packages/stream_core_flutter/lib/src/components.dart index c8515f7..07ad15c 100644 --- a/packages/stream_core_flutter/lib/src/components.dart +++ b/packages/stream_core_flutter/lib/src/components.dart @@ -11,6 +11,7 @@ export 'components/badge/stream_online_indicator.dart' hide DefaultStreamOnlineI export 'components/buttons/stream_button.dart' hide DefaultStreamButton; export 'components/buttons/stream_emoji_button.dart' hide DefaultStreamEmojiButton; export 'components/common/stream_checkbox.dart' hide DefaultStreamCheckbox; +export 'components/common/stream_flex.dart'; export 'components/common/stream_progress_bar.dart' hide DefaultStreamProgressBar; export 'components/context_menu/stream_context_menu.dart'; export 'components/context_menu/stream_context_menu_action.dart' hide DefaultStreamContextMenuAction; @@ -22,5 +23,6 @@ export 'components/emoji/data/stream_supported_emojis.dart'; export 'components/emoji/stream_emoji_picker_sheet.dart'; export 'components/list/stream_list_tile.dart' hide DefaultStreamListTile; export 'components/message_composer.dart'; +export 'components/reaction/stream_reactions.dart' hide DefaultStreamReactions; export 'factory/stream_component_factory.dart'; diff --git a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart index 062beac..facc65b 100644 --- a/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart +++ b/packages/stream_core_flutter/lib/src/components/avatar/stream_avatar_stack.dart @@ -1,11 +1,10 @@ -import 'dart:math' as math; - import 'package:flutter/material.dart'; import '../../factory/stream_component_factory.dart'; import '../../theme/components/stream_avatar_theme.dart'; import '../../theme/components/stream_badge_count_theme.dart'; import '../badge/stream_badge_count.dart'; +import '../common/stream_flex.dart'; /// Predefined avatar stack sizes. /// @@ -186,46 +185,24 @@ class DefaultStreamAvatarStack extends StatelessWidget { final extraBadgeSize = _badgeCountSizeForStackSize(effectiveSize); final diameter = avatarSize.value; - final badgeDiameter = extraBadgeSize.value; - // Split children into visible and overflow final visible = props.children.take(props.max).toList(); final extraCount = props.children.length - visible.length; - // Build the list of widgets to display - final displayChildren = [ - ...visible, - if (extraCount > 0) StreamBadgeCount(label: '+$extraCount', size: extraBadgeSize), - ]; - - // Calculate the offset between each avatar (how much of each avatar is visible) - final visiblePortion = diameter * (1 - props.overlap); - final badgeVisiblePortion = badgeDiameter * (1 - props.overlap); - - // Total width: first avatar full + remaining avatars visible portion - var totalWidth = diameter + (visible.length - 1) * visiblePortion; - if (extraCount > 0) totalWidth += badgeVisiblePortion; - - return AnimatedContainer( - width: totalWidth, - height: math.max(diameter, badgeDiameter), - duration: kThemeChangeDuration, - // Need to disable text scaling here so that the text doesn't - // escape the avatar when the textScaleFactor is large. - child: MediaQuery.withNoTextScaling( - child: StreamAvatarTheme( - data: StreamAvatarThemeData(size: avatarSize), - child: Stack( - clipBehavior: Clip.none, - alignment: Alignment.center, - children: [ - for (var i = 0; i < displayChildren.length; i++) - Positioned( - left: i * visiblePortion, - child: displayChildren[i], - ), - ], - ), + return MediaQuery.withNoTextScaling( + child: StreamAvatarTheme( + data: StreamAvatarThemeData(size: avatarSize), + child: StreamRow( + spacing: -diameter * props.overlap, + mainAxisSize: MainAxisSize.min, + children: [ + ...visible, + if (extraCount > 0) + StreamBadgeCount( + label: '+$extraCount', + size: extraBadgeSize, + ), + ], ), ), ); diff --git a/packages/stream_core_flutter/lib/src/components/common/stream_flex.dart b/packages/stream_core_flutter/lib/src/components/common/stream_flex.dart new file mode 100644 index 0000000..9964b11 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/common/stream_flex.dart @@ -0,0 +1,295 @@ +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// A widget that displays its children in a one-dimensional array, +/// supporting negative [spacing] for overlapping layouts. +/// +/// [StreamFlex] allows you to control the axis along which the children are +/// placed (horizontal or vertical). The [spacing] parameter can be positive +/// (gap between children), zero (flush), or negative (children overlap). +/// +/// If you know the main axis in advance, consider using [StreamRow] +/// (horizontal) or [StreamColumn] (vertical) instead, since that will be +/// less verbose. +/// +/// {@tool snippet} +/// +/// Overlapping avatars with -8px spacing: +/// +/// ```dart +/// StreamRow( +/// spacing: -8, +/// children: [ +/// CircleAvatar(child: Text('A')), +/// CircleAvatar(child: Text('B')), +/// CircleAvatar(child: Text('C')), +/// ], +/// ) +/// ``` +/// {@end-tool} +/// +/// ## Layout algorithm +/// +/// 1. Layout inflexible children with unbounded main-axis constraints. +/// 2. Distribute remaining main-axis space to flexible children. +/// 3. Layout flexible children with allocated space. +/// 4. Cross-axis extent = maximum child cross extent. +/// 5. Main-axis extent determined by [mainAxisSize] and constraints. +/// 6. Position children according to [mainAxisAlignment] and +/// [crossAxisAlignment], applying [spacing] between each pair. +/// +/// With negative spacing, the total main-axis extent shrinks and children +/// are positioned closer together, producing overlap. Later children in the +/// list paint on top of earlier ones (natural z-order). +/// +/// See also: +/// +/// * [StreamRow], for a horizontal variant. +/// * [StreamColumn], for a vertical variant. +class StreamFlex extends MultiChildRenderObjectWidget { + /// Creates a flex layout that supports negative spacing. + /// + /// The [direction] is required. + /// + /// The [spacing] parameter can be negative to produce overlapping children. + /// When negative, later children in the list paint on top of earlier ones. + /// + /// If [crossAxisAlignment] is [CrossAxisAlignment.baseline], then + /// [textBaseline] must not be null. + /// + /// The [textDirection] argument defaults to the ambient [Directionality], if + /// any. If there is no ambient directionality, and a text direction is going + /// to be necessary to decide which direction to lay the children in or to + /// disambiguate `start` or `end` values for the main or cross axis + /// directions, the [textDirection] must not be null. + const StreamFlex({ + super.key, + required this.direction, + this.mainAxisAlignment = MainAxisAlignment.start, + this.mainAxisSize = MainAxisSize.max, + this.crossAxisAlignment = CrossAxisAlignment.center, + this.textDirection, + this.verticalDirection = VerticalDirection.down, + this.textBaseline, // NO DEFAULT: we don't know what the text's baseline should be + this.clipBehavior = Clip.none, + this.spacing = 0.0, + super.children, + }) : assert( + !identical(crossAxisAlignment, CrossAxisAlignment.baseline) || textBaseline != null, + 'textBaseline is required if you specify the crossAxisAlignment with CrossAxisAlignment.baseline', + ); + // Cannot use == in the assert above instead of identical because of https://github.com/dart-lang/language/issues/1811. + + /// The direction to use as the main axis. + /// + /// If you know the axis in advance, then consider using a [StreamRow] + /// (if it's horizontal) or [StreamColumn] (if it's vertical) instead, + /// since that will be less verbose. + final Axis direction; + + /// How the children should be placed along the main axis. + /// + /// For example, [MainAxisAlignment.start], the default, places the children + /// at the start (i.e., the left for a [StreamRow] or the top for a + /// [StreamColumn]) of the main axis. + final MainAxisAlignment mainAxisAlignment; + + /// How much space should be occupied in the main axis. + /// + /// After allocating space to children, there might be some remaining free + /// space. This value controls whether to maximize or minimize the amount of + /// free space, subject to the incoming layout constraints. + final MainAxisSize mainAxisSize; + + /// How the children should be placed along the cross axis. + /// + /// For example, [CrossAxisAlignment.center], the default, centers the + /// children in the cross axis. + final CrossAxisAlignment crossAxisAlignment; + + /// Determines the order to lay children out horizontally and how to interpret + /// `start` and `end` in the horizontal direction. + /// + /// Defaults to the ambient [Directionality]. + final TextDirection? textDirection; + + /// Determines the order to lay children out vertically and how to interpret + /// `start` and `end` in the vertical direction. + /// + /// Defaults to [VerticalDirection.down]. + final VerticalDirection verticalDirection; + + /// If aligning items according to their baseline, which baseline to use. + /// + /// This must be set if using baseline alignment. There is no default because + /// there is no way for the framework to know the correct baseline _a priori_. + final TextBaseline? textBaseline; + + /// How to clip children that extend beyond the widget's bounds. + /// + /// Defaults to [Clip.none]. + final Clip clipBehavior; + + /// The amount of space between children along the main axis. + /// + /// Positive values add gaps between children. Zero makes children flush. + /// Negative values cause children to overlap โ€” later children in the list + /// paint on top of earlier ones. + /// + /// Defaults to 0.0. + final double spacing; + + bool get _needTextDirection => switch (direction) { + Axis.horizontal => true, + Axis.vertical => crossAxisAlignment == CrossAxisAlignment.start || crossAxisAlignment == CrossAxisAlignment.end, + }; + + /// The resolved text direction for layout. + /// + /// This value is derived from the [textDirection] property and the ambient + /// [Directionality]. Returns null when text direction is not needed for + /// the current layout configuration. + /// + /// This method exists so that subclasses of [StreamFlex] that create their + /// own render objects can reuse the text direction resolution logic. + @protected + TextDirection? getEffectiveTextDirection(BuildContext context) { + return textDirection ?? (_needTextDirection ? Directionality.maybeOf(context) : null); + } + + @override + RenderFlex createRenderObject(BuildContext context) { + return _RenderStreamFlex( + direction: direction, + mainAxisAlignment: mainAxisAlignment, + mainAxisSize: mainAxisSize, + crossAxisAlignment: crossAxisAlignment, + textDirection: getEffectiveTextDirection(context), + verticalDirection: verticalDirection, + textBaseline: textBaseline, + clipBehavior: clipBehavior, + spacing: spacing, + ); + } + + @override + void updateRenderObject( + BuildContext context, + covariant RenderFlex renderObject, + ) { + renderObject + ..direction = direction + ..mainAxisAlignment = mainAxisAlignment + ..mainAxisSize = mainAxisSize + ..crossAxisAlignment = crossAxisAlignment + ..textDirection = getEffectiveTextDirection(context) + ..verticalDirection = verticalDirection + ..textBaseline = textBaseline + ..clipBehavior = clipBehavior + ..spacing = spacing; + } +} + +/// A widget that displays its children in a horizontal array, +/// supporting negative [spacing] for overlapping layouts. +/// +/// This is the horizontal specialization of [StreamFlex]. +/// +/// {@tool snippet} +/// +/// Overlapping chips with -4px horizontal spacing: +/// +/// ```dart +/// StreamRow( +/// spacing: -4, +/// children: [ +/// Chip(label: Text('One')), +/// Chip(label: Text('Two')), +/// Chip(label: Text('Three')), +/// ], +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamColumn], the vertical variant. +/// * [StreamFlex], the direction-agnostic variant. +class StreamRow extends StreamFlex { + /// Creates a horizontal flex layout that supports negative spacing. + const StreamRow({ + super.key, + super.mainAxisAlignment, + super.mainAxisSize, + super.crossAxisAlignment, + super.textDirection, + super.verticalDirection, + super.textBaseline, + super.clipBehavior, + super.spacing, + super.children, + }) : super(direction: Axis.horizontal); +} + +/// A widget that displays its children in a vertical array, +/// supporting negative [spacing] for overlapping layouts. +/// +/// This is the vertical specialization of [StreamFlex]. +/// +/// {@tool snippet} +/// +/// Stacked cards with -12px vertical overlap: +/// +/// ```dart +/// StreamColumn( +/// spacing: -12, +/// children: [ +/// Card(child: Text('Top')), +/// Card(child: Text('Middle')), +/// Card(child: Text('Bottom')), +/// ], +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamRow], the horizontal variant. +/// * [StreamFlex], the direction-agnostic variant. +class StreamColumn extends StreamFlex { + /// Creates a vertical flex layout that supports negative spacing. + const StreamColumn({ + super.key, + super.mainAxisAlignment, + super.mainAxisSize, + super.crossAxisAlignment, + super.textDirection, + super.verticalDirection, + super.textBaseline, + super.clipBehavior, + super.spacing, + super.children, + }) : super(direction: Axis.vertical); +} + +// Render object for [StreamFlex] that supports negative [spacing]. +// +// When [spacing] is negative, children overlap along the main axis. +// Later children in the child list paint on top of earlier ones. +class _RenderStreamFlex extends RenderFlex { + _RenderStreamFlex({ + super.direction, + super.mainAxisAlignment, + super.mainAxisSize, + super.crossAxisAlignment, + super.textDirection, + super.verticalDirection, + super.textBaseline, + super.clipBehavior, + double spacing = 0.0, + }) { + // RenderFlex asserts spacing >= 0 in its constructor. The setter has no + // such assertion, so we set the value here to support negative spacing. + this.spacing = spacing; + } +} diff --git a/packages/stream_core_flutter/lib/src/components/controls/stream_emoji_chip.dart b/packages/stream_core_flutter/lib/src/components/controls/stream_emoji_chip.dart index 98ad8bc..87ea183 100644 --- a/packages/stream_core_flutter/lib/src/components/controls/stream_emoji_chip.dart +++ b/packages/stream_core_flutter/lib/src/components/controls/stream_emoji_chip.dart @@ -8,16 +8,17 @@ import '../accessories/stream_emoji.dart'; /// A pill-shaped chip for displaying emoji reactions with an optional count. /// -/// [StreamEmojiChip] renders an emoji alongside an optional reaction count. -/// Use [StreamEmojiChip.addEmoji] for the add-reaction button variant, which -/// shows the add-reaction icon instead. +/// [StreamEmojiChip] renders one or more emojis alongside an optional reaction +/// count. Use the default constructor for a single-emoji chip, [StreamEmojiChip.cluster] +/// for a multi-emoji chip, and [StreamEmojiChip.addEmoji] for the add-reaction +/// button variant. /// -/// Both variants share the same theming and support hover, press, selected, +/// All variants share the same theming and support hover, press, selected, /// and disabled interaction states. /// /// {@tool snippet} /// -/// Display a reaction chip: +/// Display a single-emoji reaction chip: /// /// ```dart /// StreamEmojiChip( @@ -31,6 +32,19 @@ import '../accessories/stream_emoji.dart'; /// /// {@tool snippet} /// +/// Display a clustered chip with multiple emojis and a total count: +/// +/// ```dart +/// StreamEmojiChip.cluster( +/// emojis: [Text('๐Ÿ‘'), Text('โค๏ธ'), Text('๐Ÿ˜‚')], +/// count: 12, +/// onPressed: () => showReactionDetails(), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// /// Display an add-reaction chip: /// /// ```dart @@ -45,7 +59,7 @@ import '../accessories/stream_emoji.dart'; /// * [StreamEmojiChipTheme], for customizing chip appearance. /// * [StreamEmojiButton], for a circular emoji-only button. class StreamEmojiChip extends StatelessWidget { - /// Creates an emoji count chip displaying [emoji] and an optional [count]. + /// Creates a single-emoji chip displaying [emoji] and an optional [count]. /// /// When [count] is null the count label is hidden. /// When [onPressed] is null the chip is disabled. @@ -57,13 +71,53 @@ class StreamEmojiChip extends StatelessWidget { VoidCallback? onLongPress, bool isSelected = false, }) : props = .new( - emoji: emoji, + emojis: [emoji], + count: count, + onPressed: onPressed, + onLongPress: onLongPress, + isSelected: isSelected, + ); + + /// Creates a clustered chip displaying multiple [emojis] and an optional [count]. + /// + /// Each emoji in [emojis] is rendered individually at the chip's icon size, + /// so the full list is visible without overflow. + /// + /// When [count] is null the count label is hidden. + /// When [onPressed] is null the chip is disabled. + StreamEmojiChip.cluster({ + super.key, + required List emojis, + int? count, + VoidCallback? onPressed, + VoidCallback? onLongPress, + bool isSelected = false, + }) : props = .new( + emojis: emojis, count: count, onPressed: onPressed, onLongPress: onLongPress, isSelected: isSelected, ); + /// Creates an overflow chip displaying a `+N` count label. + /// + /// Unlike the default constructor, the `+` is rendered as text using the + /// chip's text style rather than going through [StreamEmoji]. + static Widget overflow({ + Key? key, + required int count, + VoidCallback? onPressed, + VoidCallback? onLongPress, + }) { + return _OverflowEmojiChip( + key: key, + count: count, + onPressed: onPressed, + onLongPress: onLongPress, + ); + } + /// Creates an add-emoji chip showing the add-reaction icon. /// /// When [onPressed] is null the chip is disabled. @@ -108,21 +162,24 @@ class StreamEmojiChip extends StatelessWidget { class StreamEmojiChipProps { /// Creates properties for an emoji chip. const StreamEmojiChipProps({ - required this.emoji, + required this.emojis, this.count, this.onPressed, this.onLongPress, this.isSelected = false, - }); + }) : assert(emojis.length > 0, 'emojis must not be empty'); - /// The emoji content to display inside the chip. + /// The emoji widgets to display inside the chip. + /// + /// For a standard single-emoji chip this is a one-element list, e.g. + /// `[Text('๐Ÿ‘')]`. For a clustered chip it contains multiple emoji widgets, + /// e.g. `[Text('๐Ÿ‘'), Text('โค๏ธ'), Text('๐Ÿ˜‚')]`. /// - /// Typically a [Text] widget containing a Unicode emoji character, e.g. - /// `Text('๐Ÿ‘')`. The chip wraps this in a [StreamEmoji] internally to - /// ensure consistent sizing and platform-specific font fallbacks. - final Widget emoji; + /// Each item is individually wrapped in a [StreamEmoji] to ensure consistent + /// sizing and platform-specific font fallbacks. + final List emojis; - /// The reaction count to display next to [emoji]. + /// The reaction count to display next to the emojis. /// /// When null the count label is hidden. final int? count; @@ -151,6 +208,75 @@ class DefaultStreamEmojiChip extends StatelessWidget { /// The props controlling the appearance and behavior of this chip. final StreamEmojiChipProps props; + @override + Widget build(BuildContext context) { + return _RawEmojiChip( + onPressed: props.onPressed, + onLongPress: props.onLongPress, + isSelected: props.isSelected, + child: Row( + mainAxisSize: .min, + textBaseline: .alphabetic, + crossAxisAlignment: .baseline, + spacing: context.streamSpacing.xxs, + children: [ + for (final emoji in props.emojis) StreamEmoji(emoji: emoji), + if (props.count case final count?) Text('$count'), + ], + ), + ); + } +} + +// Renders the add-reaction icon using the current theme's icon set. +class _AddEmojiIcon extends StatelessWidget { + const _AddEmojiIcon(); + + @override + Widget build(BuildContext context) => Icon(context.streamIcons.emojiAddReaction); +} + +// Overflow chip that renders `+N` as a single text label styled with the +// chip's text style. Bypasses [StreamEmoji] so the `+` uses the numeric +// font rather than emoji font sizing. +class _OverflowEmojiChip extends StatelessWidget { + const _OverflowEmojiChip({ + super.key, + required this.count, + this.onPressed, + this.onLongPress, + }); + + final int count; + final VoidCallback? onPressed; + final VoidCallback? onLongPress; + + @override + Widget build(BuildContext context) { + return _RawEmojiChip( + onPressed: onPressed, + onLongPress: onLongPress, + child: Text('+$count'), + ); + } +} + +// Shared themed button shell used by both [DefaultStreamEmojiChip] and +// [_OverflowEmojiChip]. Resolves all chip theme properties and renders +// an [IconButton] with the given [child]. +class _RawEmojiChip extends StatelessWidget { + const _RawEmojiChip({ + required this.child, + this.onPressed, + this.onLongPress, + this.isSelected = false, + }); + + final Widget child; + final VoidCallback? onPressed; + final VoidCallback? onLongPress; + final bool isSelected; + @override Widget build(BuildContext context) { final chipThemeStyle = context.streamEmojiChipTheme.style; @@ -168,11 +294,13 @@ class DefaultStreamEmojiChip extends StatelessWidget { final effectiveSide = chipThemeStyle?.side ?? defaults.side; return IconButton( - onPressed: props.onPressed, - onLongPress: props.onLongPress, - isSelected: props.isSelected, + onPressed: onPressed, + onLongPress: onLongPress, + isSelected: isSelected, iconSize: effectiveEmojiSize, - icon: _EmojiChipContent(emoji: props.emoji, count: props.count), + // Need to disable text scaling here so that the text doesn't + // escape the chip when the textScaleFactor is large. + icon: MediaQuery.withNoTextScaling(child: child), style: ButtonStyle( tapTargetSize: .shrinkWrap, visualDensity: .standard, @@ -190,40 +318,6 @@ class DefaultStreamEmojiChip extends StatelessWidget { } } -// Internal widget to layout the emoji and count label inside the chip. -class _EmojiChipContent extends StatelessWidget { - const _EmojiChipContent({required this.emoji, this.count}); - - final Widget emoji; - final int? count; - - @override - Widget build(BuildContext context) { - // Need to disable text scaling here so that the text doesn't - // escape the chip when the textScaleFactor is large. - return MediaQuery.withNoTextScaling( - child: Row( - mainAxisSize: .min, - textBaseline: .alphabetic, - crossAxisAlignment: .baseline, - spacing: context.streamSpacing.xxs, - children: [ - StreamEmoji(emoji: emoji), - if (count case final count?) Text('$count'), - ], - ), - ); - } -} - -// Renders the add-reaction icon using the current theme's icon set. -class _AddEmojiIcon extends StatelessWidget { - const _AddEmojiIcon(); - - @override - Widget build(BuildContext context) => Icon(context.streamIcons.emojiAddReaction); -} - // Provides default values for [StreamEmojiChipThemeStyle] based on // the current [StreamColorScheme]. class _StreamEmojiChipThemeDefaults extends StreamEmojiChipThemeStyle { diff --git a/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart b/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart new file mode 100644 index 0000000..268c9a8 --- /dev/null +++ b/packages/stream_core_flutter/lib/src/components/reaction/stream_reactions.dart @@ -0,0 +1,380 @@ +import 'package:flutter/material.dart'; +import 'package:stream_core/stream_core.dart'; + +import '../../factory/stream_component_factory.dart'; +import '../../theme/components/stream_emoji_chip_theme.dart'; +import '../../theme/components/stream_reactions_theme.dart'; +import '../../theme/stream_theme_extensions.dart'; +import '../accessories/stream_emoji.dart'; +import '../common/stream_flex.dart'; +import '../controls/stream_emoji_chip.dart'; + +/// Displays reactions as either individual chips or a single grouped chip. +/// +/// Use [StreamReactions.segmented] to render each reaction type as its own +/// chip, and [StreamReactions.clustered] to group all reaction types into a +/// single chip. +/// +/// Reactions can be displayed on their own or positioned relative to a +/// [child], such as a message bubble or container. +/// +/// {@tool snippet} +/// +/// Display segmented reactions below a child: +/// +/// ```dart +/// StreamReactions.segmented( +/// items: [ +/// StreamReactionsItem(emoji: Text('๐Ÿ‘'), count: 3), +/// StreamReactionsItem(emoji: Text('โค๏ธ'), count: 2), +/// ], +/// child: Container( +/// padding: EdgeInsets.all(12), +/// child: Text('Looks good to me'), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// {@tool snippet} +/// +/// Display clustered reactions above a child: +/// +/// ```dart +/// StreamReactions.clustered( +/// items: [ +/// StreamReactionsItem(emoji: Text('๐Ÿ‘'), count: 4), +/// StreamReactionsItem(emoji: Text('๐Ÿ˜‚'), count: 2), +/// StreamReactionsItem(emoji: Text('๐Ÿ”ฅ')), +/// ], +/// position: StreamReactionsPosition.header, +/// child: Container( +/// padding: EdgeInsets.all(12), +/// child: Text('Let us ship this'), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamReactionsTheme], for customizing reaction layout. +/// * [StreamEmojiChipTheme], for customizing chip appearance. +class StreamReactions extends StatelessWidget { + /// Creates segmented reactions where each type is rendered as its own chip. + StreamReactions.segmented({ + super.key, + required List items, + Widget? child, + StreamReactionsPosition position = .footer, + StreamReactionsAlignment alignment = .start, + int? max, + bool overlap = true, + double? indent, + CrossAxisAlignment? crossAxisAlignment, + Clip clipBehavior = Clip.none, + VoidCallback? onPressed, + }) : props = .new( + items: items, + child: child, + type: .segmented, + position: position, + alignment: alignment, + max: max, + overlap: overlap, + indent: indent, + crossAxisAlignment: crossAxisAlignment, + clipBehavior: clipBehavior, + onPressed: onPressed, + ); + + /// Creates clustered reactions that group all reaction types into one chip. + StreamReactions.clustered({ + super.key, + required List items, + Widget? child, + StreamReactionsPosition position = .header, + StreamReactionsAlignment alignment = .end, + int? max, + bool overlap = true, + double? indent, + CrossAxisAlignment? crossAxisAlignment, + Clip clipBehavior = Clip.none, + VoidCallback? onPressed, + }) : props = .new( + items: items, + child: child, + type: .clustered, + position: position, + alignment: alignment, + max: max, + overlap: overlap, + indent: indent, + crossAxisAlignment: crossAxisAlignment, + clipBehavior: clipBehavior, + onPressed: onPressed, + ); + + /// The properties that configure this widget. + final StreamReactionsProps props; + + @override + Widget build(BuildContext context) { + final builder = StreamComponentFactory.of(context).reactions; + if (builder != null) return builder(context, props); + + final spacing = context.streamSpacing; + final textTheme = context.streamTextTheme; + + return StreamEmojiChipTheme( + data: StreamEmojiChipThemeData( + style: StreamEmojiChipThemeStyle( + // Reaction chips must shrink to their content width so that multiple + // chips fit side-by-side within the bubble bounds. The global default + // (64px minimum) is designed for stand-alone emoji chip bars and is + // too wide for a segmented reaction row. + minimumSize: const Size(32, 24), + maximumSize: const Size.fromHeight(28), + emojiSize: StreamEmojiSize.sm.value, + backgroundColor: .all(context.streamColorScheme.backgroundElevation2), + textStyle: .all(textTheme.numericMd.copyWith(fontFeatures: const [.tabularFigures()])), + padding: EdgeInsetsGeometry.symmetric(vertical: spacing.xxxs, horizontal: spacing.xs), + ), + ), + child: DefaultStreamReactions(props: props), + ); + } +} + +/// Properties for configuring [StreamReactions]. +/// +/// This class holds the configuration for a reactions widget so it can be +/// passed through the [StreamComponentFactory]. +/// +/// See also: +/// +/// * [StreamReactions], which uses these properties. +/// * [DefaultStreamReactions], the default implementation. +@immutable +class StreamReactionsProps { + /// Creates reaction properties. + const StreamReactionsProps({ + required this.type, + required this.items, + this.child, + required this.position, + required this.alignment, + this.max, + this.overlap = true, + this.indent, + this.crossAxisAlignment, + this.clipBehavior = Clip.none, + this.onPressed, + }); + + /// The reaction presentation style. + final StreamReactionsType type; + + /// The reaction items to display. + final List items; + + /// Optional widget the reactions should be positioned relative to. + /// + /// Typically a message bubble or any container widget. + /// + /// When null, [StreamReactions] renders as a standalone reaction strip. + final Widget? child; + + /// The vertical position of the reactions relative to the child. + final StreamReactionsPosition position; + + /// The horizontal alignment of the reactions relative to the child. + final StreamReactionsAlignment alignment; + + /// Maximum number of visible items. + /// + /// In segmented mode, items beyond this limit are collapsed into an overflow + /// chip. In clustered mode, this limits how many emoji widgets are shown in + /// the cluster. + final int? max; + + /// Whether reactions overlap the child edge. + /// + /// When `false`, reactions are displayed with a gap from the child. + final bool overlap; + + /// Horizontal offset applied to the reaction strip. + final double? indent; + + /// Cross-axis alignment used when laying out the child and reactions. + final CrossAxisAlignment? crossAxisAlignment; + + /// The clip behavior applied to the layout. + final Clip clipBehavior; + + /// Called when any reaction chip is tapped. + /// + /// In segmented mode, this is used for each visible chip, including the + /// overflow chip. In clustered mode, it is used for the grouped chip. + final VoidCallback? onPressed; +} + +/// A single reaction item with an emoji widget and optional count. +/// +/// Used by [StreamReactions] to describe each distinct reaction type. +/// +/// See also: +/// +/// * [StreamReactionsProps], which holds a list of these items. +@immutable +class StreamReactionsItem { + /// Creates a reaction item. + const StreamReactionsItem({ + required this.emoji, + this.count, + }); + + /// The widget representing this reaction's emoji. + /// + /// Typically a [Text] widget containing an emoji character + /// (e.g. `Text('๐Ÿ‘')`). + final Widget emoji; + + /// The number of times this reaction was used. + /// + /// When null, the reaction is treated as having a count of 1. + final int? count; +} + +const _kMaxVisibleSegments = 4; + +/// Default implementation of [StreamReactions]. +/// +/// See also: +/// +/// * [StreamReactions], the public API widget. +/// * [StreamReactionsProps], which configures this widget. +class DefaultStreamReactions extends StatelessWidget { + /// Creates a default reaction widget with the given [props]. + const DefaultStreamReactions({super.key, required this.props}); + + /// The properties that configure this widget. + final StreamReactionsProps props; + + @override + Widget build(BuildContext context) { + if (props.items.isEmpty) return props.child ?? const SizedBox.shrink(); + + final reactionTheme = context.streamReactionsTheme; + final defaults = _StreamReactionsThemeDefaults(context); + + final effectiveSpacing = reactionTheme.spacing ?? defaults.spacing; + final effectiveGap = reactionTheme.gap ?? defaults.gap; + final effectiveOverlapExtent = reactionTheme.overlapExtent ?? defaults.overlapExtent; + // Limit is only applied when reactions overlap the child; otherwise show all. + final maxVisible = props.overlap ? (props.max ?? _kMaxVisibleSegments) : props.items.length; + + final reactionStrip = switch (props.type) { + .clustered => _buildClustered(maxVisible), + .segmented => _buildSegmented(effectiveSpacing, maxVisible), + }; + + // Standalone mode โ€” no child to position relative to. + if (props.child == null) return reactionStrip; + + // Negative spacing when overlapping makes reactions overlap the child edge. + final columnSpacing = props.overlap ? -effectiveOverlapExtent : effectiveGap; + + final effectiveCrossAxisAlignment = props.crossAxisAlignment ?? CrossAxisAlignment.start; + + final effectiveIndent = props.indent ?? reactionTheme.indent ?? defaults.indent; + final indentedStrip = Transform.translate(offset: .new(effectiveIndent, 0), child: reactionStrip); + + final alignedStrip = switch (props.alignment) { + .start => Align(alignment: AlignmentDirectional.centerStart, child: indentedStrip), + .end => Align(alignment: AlignmentDirectional.centerEnd, child: indentedStrip), + }; + + // Reactions are always the LAST child so they paint on top of the child + // when overlapping (later children have higher z-order in Flex layout). + // For top-positioned reactions we flip verticalDirection so the column + // still lays out bottom-to-top while keeping reactions last in the + // paint order. + final column = StreamColumn( + mainAxisSize: .min, + spacing: columnSpacing, + crossAxisAlignment: effectiveCrossAxisAlignment, + clipBehavior: props.clipBehavior, + verticalDirection: switch (props.position) { + .header => VerticalDirection.up, + .footer => VerticalDirection.down, + }, + children: [props.child!, alignedStrip], + ); + + if (props.overlap) return IntrinsicWidth(child: column); + return column; + } + + Widget _buildSegmented(double itemSpacing, int maxVisible) { + final items = props.items; + final showCounts = items.any((item) => (item.count ?? 1) > 1); + + final visible = items.take(maxVisible).toList(); + final overflow = items.skip(maxVisible).toList(); + final overflowCount = overflow.sumOf((item) => item.count ?? 1); + + final children = [ + for (final item in visible) + StreamEmojiChip( + emoji: item.emoji, + count: showCounts ? item.count ?? 1 : null, + onPressed: props.onPressed, + ), + if (overflow.isNotEmpty) + StreamEmojiChip.overflow( + count: overflowCount, + onPressed: props.onPressed, + ), + ]; + + if (props.overlap) return Row(mainAxisSize: .min, spacing: itemSpacing, children: children); + return Wrap(spacing: itemSpacing, runSpacing: itemSpacing, children: children); + } + + Widget _buildClustered(int maxVisible) { + final items = props.items; + final visible = items.take(maxVisible).map((item) => item.emoji).toList(); + final totalCount = items.sumOf((item) => item.count ?? 1); + + return StreamEmojiChip.cluster( + emojis: visible, + count: totalCount > 1 ? totalCount : null, + onPressed: props.onPressed, + ); + } +} + +// Context-aware default values for [StreamReactionsThemeData]. +// +// Used by [DefaultStreamReactions] as a fallback when a property is not +// explicitly set in the inherited theme. +class _StreamReactionsThemeDefaults extends StreamReactionsThemeData { + _StreamReactionsThemeDefaults(this._context); + + final BuildContext _context; + + late final _spacing = _context.streamSpacing; + + @override + double get spacing => _spacing.xxs; + + @override + double get gap => _spacing.xxs; + + @override + double get overlapExtent => _spacing.xs; + + @override + double get indent => 0; +} 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 d176b0d..a0c3a68 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 @@ -147,6 +147,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { StreamComponentBuilder? listTile, StreamComponentBuilder? onlineIndicator, StreamComponentBuilder? progressBar, + StreamComponentBuilder? reactions, Iterable>? extensions, }) { extensions ??= >[]; @@ -168,6 +169,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { listTile: listTile, onlineIndicator: onlineIndicator, progressBar: progressBar, + reactions: reactions, extensions: _extensionIterableToMap(extensions), ); } @@ -190,6 +192,7 @@ class StreamComponentBuilders with _$StreamComponentBuilders { required this.listTile, required this.onlineIndicator, required this.progressBar, + required this.reactions, required this.extensions, }); @@ -293,6 +296,11 @@ class StreamComponentBuilders with _$StreamComponentBuilders { /// When null, [StreamProgressBar] uses [DefaultStreamProgressBar]. final StreamComponentBuilder? progressBar; + /// Custom builder for reaction widgets. + /// + /// When null, [StreamReactions] uses [DefaultStreamReactions]. + final StreamComponentBuilder? reactions; + // Convert the [extensionsIterable] passed to [StreamComponentBuilders.new] // to the stored [extensions] map, where each entry's key consists of the extension's type. static Map> _extensionIterableToMap( 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 c8539d9..2e76e62 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 @@ -47,6 +47,7 @@ mixin _$StreamComponentBuilders { listTile: t < 0.5 ? a.listTile : b.listTile, onlineIndicator: t < 0.5 ? a.onlineIndicator : b.onlineIndicator, progressBar: t < 0.5 ? a.progressBar : b.progressBar, + reactions: t < 0.5 ? a.reactions : b.reactions, ); } @@ -71,6 +72,7 @@ mixin _$StreamComponentBuilders { Widget Function(BuildContext, StreamListTileProps)? listTile, Widget Function(BuildContext, StreamOnlineIndicatorProps)? onlineIndicator, Widget Function(BuildContext, StreamProgressBarProps)? progressBar, + Widget Function(BuildContext, StreamReactionsProps)? reactions, }) { final _this = (this as StreamComponentBuilders); @@ -92,6 +94,7 @@ mixin _$StreamComponentBuilders { listTile: listTile ?? _this.listTile, onlineIndicator: onlineIndicator ?? _this.onlineIndicator, progressBar: progressBar ?? _this.progressBar, + reactions: reactions ?? _this.reactions, ); } @@ -124,6 +127,7 @@ mixin _$StreamComponentBuilders { listTile: other.listTile, onlineIndicator: other.onlineIndicator, progressBar: other.progressBar, + reactions: other.reactions, ); } @@ -156,7 +160,8 @@ mixin _$StreamComponentBuilders { _other.fileTypeIcon == _this.fileTypeIcon && _other.listTile == _this.listTile && _other.onlineIndicator == _this.onlineIndicator && - _other.progressBar == _this.progressBar; + _other.progressBar == _this.progressBar && + _other.reactions == _this.reactions; } @override @@ -182,6 +187,7 @@ mixin _$StreamComponentBuilders { _this.listTile, _this.onlineIndicator, _this.progressBar, + _this.reactions, ); } } diff --git a/packages/stream_core_flutter/lib/src/theme.dart b/packages/stream_core_flutter/lib/src/theme.dart index 2371ac7..c4e8284 100644 --- a/packages/stream_core_flutter/lib/src/theme.dart +++ b/packages/stream_core_flutter/lib/src/theme.dart @@ -15,6 +15,7 @@ export 'theme/components/stream_list_tile_theme.dart'; export 'theme/components/stream_message_theme.dart'; export 'theme/components/stream_online_indicator_theme.dart'; export 'theme/components/stream_progress_bar_theme.dart'; +export 'theme/components/stream_reactions_theme.dart'; export 'theme/primitives/stream_colors.dart'; export 'theme/primitives/stream_icons.dart'; diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_reactions_theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_reactions_theme.dart new file mode 100644 index 0000000..f8bcddd --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_reactions_theme.dart @@ -0,0 +1,161 @@ +import 'package:flutter/widgets.dart'; +import 'package:theme_extensions_builder_annotation/theme_extensions_builder_annotation.dart'; + +import '../stream_theme.dart'; + +part 'stream_reactions_theme.g.theme.dart'; + +/// The visual presentation style of [StreamReactions]. +/// +/// Determines whether reactions are shown as individual chips or as a single +/// grouped chip. +enum StreamReactionsType { + /// Shows each reaction type as an individual chip. + segmented, + + /// Groups multiple reaction types into a single chip. + clustered, +} + +/// The vertical position of [StreamReactions] relative to the child. +enum StreamReactionsPosition { + /// Places reactions above the child. + header, + + /// Places reactions below the child. + footer, +} + +/// The horizontal alignment of [StreamReactions] relative to the child. +enum StreamReactionsAlignment { + /// Aligns reactions to the leading edge of the child. + start, + + /// Aligns reactions to the trailing edge of the child. + end, +} + +/// Applies a reactions theme to descendant [StreamReactions] widgets. +/// +/// Wrap a subtree with [StreamReactionsTheme] to override reaction layout. +/// Access the merged theme using [BuildContext.streamReactionsTheme]. +/// +/// {@tool snippet} +/// +/// Override reaction layout for a specific section: +/// +/// ```dart +/// StreamReactionsTheme( +/// data: StreamReactionsThemeData( +/// spacing: 4, +/// gap: 6, +/// overlapExtent: 8, +/// indent: 4, +/// ), +/// child: StreamReactions.segmented( +/// items: [ +/// StreamReactionsItem(emoji: Text('๐Ÿ‘'), count: 3), +/// StreamReactionsItem(emoji: Text('โค๏ธ'), count: 2), +/// ], +/// child: Container( +/// padding: EdgeInsets.all(12), +/// child: Text('Looks good'), +/// ), +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamReactionsThemeData], which describes the reactions theme. +/// * [StreamReactions], the widget affected by this theme. +class StreamReactionsTheme extends InheritedTheme { + /// Creates a reactions theme that controls descendant reactions. + const StreamReactionsTheme({ + super.key, + required this.data, + required super.child, + }); + + /// The reaction theme data for descendant widgets. + final StreamReactionsThemeData data; + + /// Returns the [StreamReactionsThemeData] merged from local and global themes. + /// + /// Local values from the nearest [StreamReactionsTheme] ancestor take + /// precedence over global values from [StreamTheme.of]. + static StreamReactionsThemeData of(BuildContext context) { + final localTheme = context.dependOnInheritedWidgetOfExactType(); + return StreamTheme.of(context).reactionsTheme.merge(localTheme?.data); + } + + @override + Widget wrap(BuildContext context, Widget child) { + return StreamReactionsTheme(data: data, child: child); + } + + @override + bool updateShouldNotify(StreamReactionsTheme oldWidget) => data != oldWidget.data; +} + +/// Theme data for customizing [StreamReactions] layout. +/// +/// {@tool snippet} +/// +/// Customize reaction layout globally: +/// +/// ```dart +/// StreamTheme( +/// reactionsTheme: StreamReactionsThemeData( +/// spacing: 4, +/// gap: 6, +/// overlapExtent: 8, +/// indent: 4, +/// ), +/// ) +/// ``` +/// {@end-tool} +/// +/// See also: +/// +/// * [StreamReactions], the widget that uses this theme data. +/// * [StreamReactionsTheme], for overriding theme in a widget subtree. +/// * [StreamEmojiChipTheme], for customizing chip appearance. +@themeGen +@immutable +class StreamReactionsThemeData with _$StreamReactionsThemeData { + /// Creates reaction theme data with optional overrides. + const StreamReactionsThemeData({ + this.spacing, + this.gap, + this.overlapExtent, + this.indent, + }); + + /// The gap between adjacent reaction chips. + final double? spacing; + + /// The gap between the reaction strip and the child. + /// + /// Applied when reactions do not overlap the child. + final double? gap; + + /// How much the reaction strip overlaps the child. + /// + /// Higher values move the reactions further into the child. + final double? overlapExtent; + + /// The horizontal offset applied to the reaction strip. + /// + /// Positive values move reactions toward the trailing side, while negative + /// values move them toward the leading side. + final double? indent; + + /// Linearly interpolate between two [StreamReactionsThemeData] objects. + static StreamReactionsThemeData? lerp( + StreamReactionsThemeData? a, + StreamReactionsThemeData? b, + double t, + ) => _$StreamReactionsThemeData.lerp(a, b, t); +} diff --git a/packages/stream_core_flutter/lib/src/theme/components/stream_reactions_theme.g.theme.dart b/packages/stream_core_flutter/lib/src/theme/components/stream_reactions_theme.g.theme.dart new file mode 100644 index 0000000..7b3d87c --- /dev/null +++ b/packages/stream_core_flutter/lib/src/theme/components/stream_reactions_theme.g.theme.dart @@ -0,0 +1,106 @@ +// dart format width=80 +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, unused_element + +part of 'stream_reactions_theme.dart'; + +// ************************************************************************** +// ThemeGenGenerator +// ************************************************************************** + +mixin _$StreamReactionsThemeData { + bool get canMerge => true; + + static StreamReactionsThemeData? lerp( + StreamReactionsThemeData? a, + StreamReactionsThemeData? 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 StreamReactionsThemeData( + spacing: lerpDouble$(a.spacing, b.spacing, t), + gap: lerpDouble$(a.gap, b.gap, t), + overlapExtent: lerpDouble$(a.overlapExtent, b.overlapExtent, t), + indent: lerpDouble$(a.indent, b.indent, t), + ); + } + + StreamReactionsThemeData copyWith({ + double? spacing, + double? gap, + double? overlapExtent, + double? indent, + }) { + final _this = (this as StreamReactionsThemeData); + + return StreamReactionsThemeData( + spacing: spacing ?? _this.spacing, + gap: gap ?? _this.gap, + overlapExtent: overlapExtent ?? _this.overlapExtent, + indent: indent ?? _this.indent, + ); + } + + StreamReactionsThemeData merge(StreamReactionsThemeData? other) { + final _this = (this as StreamReactionsThemeData); + + if (other == null || identical(_this, other)) { + return _this; + } + + if (!other.canMerge) { + return other; + } + + return copyWith( + spacing: other.spacing, + gap: other.gap, + overlapExtent: other.overlapExtent, + indent: other.indent, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + + if (other.runtimeType != runtimeType) { + return false; + } + + final _this = (this as StreamReactionsThemeData); + final _other = (other as StreamReactionsThemeData); + + return _other.spacing == _this.spacing && + _other.gap == _this.gap && + _other.overlapExtent == _this.overlapExtent && + _other.indent == _this.indent; + } + + @override + int get hashCode { + final _this = (this as StreamReactionsThemeData); + + return Object.hash( + runtimeType, + _this.spacing, + _this.gap, + _this.overlapExtent, + _this.indent, + ); + } +} 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 0189025..f0d35e2 100644 --- a/packages/stream_core_flutter/lib/src/theme/stream_theme.dart +++ b/packages/stream_core_flutter/lib/src/theme/stream_theme.dart @@ -19,6 +19,7 @@ import 'components/stream_list_tile_theme.dart'; import 'components/stream_message_theme.dart'; import 'components/stream_online_indicator_theme.dart'; import 'components/stream_progress_bar_theme.dart'; +import 'components/stream_reactions_theme.dart'; import 'primitives/stream_icons.dart'; import 'primitives/stream_radius.dart'; import 'primitives/stream_spacing.dart'; @@ -108,6 +109,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { StreamInputThemeData? inputTheme, StreamOnlineIndicatorThemeData? onlineIndicatorTheme, StreamProgressBarThemeData? progressBarTheme, + StreamReactionsThemeData? reactionsTheme, }) { platform ??= defaultTargetPlatform; final isDark = brightness == Brightness.dark; @@ -139,6 +141,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { inputTheme ??= const StreamInputThemeData(); onlineIndicatorTheme ??= const StreamOnlineIndicatorThemeData(); progressBarTheme ??= const StreamProgressBarThemeData(); + reactionsTheme ??= const StreamReactionsThemeData(); return .raw( brightness: brightness, @@ -164,6 +167,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { inputTheme: inputTheme, onlineIndicatorTheme: onlineIndicatorTheme, progressBarTheme: progressBarTheme, + reactionsTheme: reactionsTheme, ); } @@ -203,6 +207,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { required this.inputTheme, required this.onlineIndicatorTheme, required this.progressBarTheme, + required this.reactionsTheme, }); /// Returns the [StreamTheme] from the closest [Theme] ancestor. @@ -308,6 +313,9 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { /// The progress bar theme for this theme. final StreamProgressBarThemeData progressBarTheme; + /// The reaction theme for this theme. + final StreamReactionsThemeData reactionsTheme; + /// Creates a copy of this theme but with platform-dependent primitives /// recomputed for the given [platform]. /// @@ -352,6 +360,7 @@ class StreamTheme extends ThemeExtension with _$StreamTheme { inputTheme: inputTheme, onlineIndicatorTheme: onlineIndicatorTheme, progressBarTheme: progressBarTheme, + reactionsTheme: reactionsTheme, ); } } 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 8ef6173..46d74ea 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 @@ -35,6 +35,7 @@ mixin _$StreamTheme on ThemeExtension { StreamInputThemeData? inputTheme, StreamOnlineIndicatorThemeData? onlineIndicatorTheme, StreamProgressBarThemeData? progressBarTheme, + StreamReactionsThemeData? reactionsTheme, }) { final _this = (this as StreamTheme); @@ -64,6 +65,7 @@ mixin _$StreamTheme on ThemeExtension { inputTheme: inputTheme ?? _this.inputTheme, onlineIndicatorTheme: onlineIndicatorTheme ?? _this.onlineIndicatorTheme, progressBarTheme: progressBarTheme ?? _this.progressBarTheme, + reactionsTheme: reactionsTheme ?? _this.reactionsTheme, ); } @@ -155,6 +157,11 @@ mixin _$StreamTheme on ThemeExtension { other.progressBarTheme, t, )!, + reactionsTheme: StreamReactionsThemeData.lerp( + _this.reactionsTheme, + other.reactionsTheme, + t, + )!, ); } @@ -193,7 +200,8 @@ mixin _$StreamTheme on ThemeExtension { _other.messageTheme == _this.messageTheme && _other.inputTheme == _this.inputTheme && _other.onlineIndicatorTheme == _this.onlineIndicatorTheme && - _other.progressBarTheme == _this.progressBarTheme; + _other.progressBarTheme == _this.progressBarTheme && + _other.reactionsTheme == _this.reactionsTheme; } @override @@ -225,6 +233,7 @@ mixin _$StreamTheme on ThemeExtension { _this.inputTheme, _this.onlineIndicatorTheme, _this.progressBarTheme, + _this.reactionsTheme, ]); } } 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 4917120..3321b72 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 @@ -15,6 +15,7 @@ import 'components/stream_list_tile_theme.dart'; import 'components/stream_message_theme.dart'; import 'components/stream_online_indicator_theme.dart'; import 'components/stream_progress_bar_theme.dart'; +import 'components/stream_reactions_theme.dart'; import 'primitives/stream_icons.dart'; import 'primitives/stream_radius.dart'; import 'primitives/stream_spacing.dart'; @@ -113,4 +114,7 @@ extension StreamThemeExtension on BuildContext { /// Returns the [StreamProgressBarThemeData] from the nearest ancestor. StreamProgressBarThemeData get streamProgressBarTheme => StreamProgressBarTheme.of(this); + + /// Returns the [StreamReactionsThemeData] from the nearest ancestor. + StreamReactionsThemeData get streamReactionsTheme => StreamReactionsTheme.of(this); }